├── project ├── build.properties ├── sbt-revolver.sbt └── sbt-scalariform.sbt ├── .gitignore ├── src ├── test │ ├── resources │ │ └── application.conf │ └── scala │ │ └── example │ │ ├── ModelSpec.scala │ │ ├── ModelActorSpec.scala │ │ ├── TopLevelSpec.scala │ │ └── ServiceSpec.scala └── main │ ├── scala │ └── example │ │ ├── package.scala │ │ ├── Boot.scala │ │ ├── Model.scala │ │ ├── ModelActor.scala │ │ ├── ServiceJsonProtocol.scala │ │ ├── TopLevel.scala │ │ └── Service.scala │ └── resources │ └── application.conf ├── LICENSE.md └── README.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .idea_modules/ 3 | target/ 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /project/sbt-revolver.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.7.1") 2 | -------------------------------------------------------------------------------- /project/sbt-scalariform.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.typesafe.sbt" % "sbt-scalariform" % "1.2.1") 2 | -------------------------------------------------------------------------------- /src/test/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | loggers = ["akka.testkit.TestEventListener"] 3 | loglevel = INFO 4 | 5 | log-dead-letters = off 6 | } 7 | -------------------------------------------------------------------------------- /src/main/scala/example/package.scala: -------------------------------------------------------------------------------- 1 | package example { 2 | 3 | case class Item(id: Int, stock: Int, title: String, desc: String) 4 | case class ItemSummary(id: Int, stock: Int, title: String) 5 | 6 | } 7 | -------------------------------------------------------------------------------- /src/main/scala/example/Boot.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import akka.actor.ActorSystem 4 | 5 | object Boot extends App { 6 | 7 | implicit val system = ActorSystem("my-example") 8 | system.actorOf(TopLevel.props, TopLevel.name) 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | loggers = ["akka.event.slf4j.Slf4jLogger"] 3 | loglevel = INFO 4 | loglevel = ${?LOGLEVEL} 5 | 6 | actor.debug = { 7 | receive = on 8 | autoreceive = on 9 | } 10 | 11 | } 12 | 13 | spray.can.server { 14 | request-timeout = 10s 15 | } 16 | 17 | example-app { 18 | service { 19 | interface = localhost 20 | interface = ${?HOST} 21 | 22 | port = 8080 23 | port = ${?PORT} 24 | 25 | ask-timeout = 11s 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/scala/example/Model.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | trait Model { 4 | private val items = Item(1, 2, "foo", "More information about Foo") :: 5 | Item(2, 3, "bar", "More information about Bar") :: 6 | Item(3, 5, "qux", "More information about Qux") :: 7 | Item(4, 7, "quux", "More information about Quux") :: 8 | Item(5, 7, "quuux", "More information about Quuux") :: 9 | Nil 10 | 11 | val summary = (i: Item) => ItemSummary(i.id, i.stock, i.title) 12 | 13 | def get(id: Int) = items find (_.id == id) 14 | 15 | def list = items map { summary } 16 | 17 | def query(s: String) = items filter (_.desc.contains(s)) map summary 18 | 19 | } -------------------------------------------------------------------------------- /src/main/scala/example/ModelActor.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import akka.actor.{ Props, Actor } 4 | 5 | object ModelActor { 6 | def props: Props = Props[ModelActor] 7 | def name = "model" 8 | 9 | case object ItemNotFound 10 | case class ItemSummaries(items: Seq[ItemSummary]) 11 | } 12 | 13 | class ModelActor extends Actor with Model { 14 | import ModelActor._ 15 | 16 | def receive = { 17 | case id: Int => 18 | sender ! get(id).getOrElse(ItemNotFound) 19 | 20 | case 'list => 21 | sender ! ItemSummaries(list) 22 | 23 | case ('query, term: String) => 24 | sender ! ItemSummaries(query(term)) 25 | 26 | } 27 | 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/test/scala/example/ModelSpec.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import org.scalatest.FlatSpec 4 | 5 | class ModelSpec extends FlatSpec { 6 | 7 | "A Model" should "return a list of 5 ItemSummaries" in new Model { 8 | assert(list.size === 5) 9 | } 10 | 11 | it should "return 3 items containing 'Qu'" in new Model { 12 | assert(query("Qu").size === 3) 13 | } 14 | 15 | it should "return item 1 when asked" in new Model { 16 | val item = get(1).get 17 | assert(item.id === 1) 18 | assert(item.title === "foo") 19 | assert(item.stock === 2) 20 | } 21 | 22 | it should "return None when requested item is not found" in new Model { 23 | assert(get(100) === None) 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Stig Brautaset 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/test/scala/example/ModelActorSpec.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import org.scalatest.{ FlatSpecLike, BeforeAndAfterAll } 4 | import akka.testkit.{ TestActorRef, ImplicitSender, TestKit } 5 | import akka.actor.{ Props, ActorSystem } 6 | 7 | class ModelActorSpec extends TestKit(ActorSystem()) with FlatSpecLike with ImplicitSender with BeforeAndAfterAll { 8 | 9 | import ModelActor._ 10 | 11 | override def afterAll() { 12 | system.shutdown() 13 | } 14 | 15 | val model = TestActorRef(Props[ModelActor]) 16 | 17 | "A Model" should "return a list of 5 ItemSummaries" in { 18 | model ! 'list 19 | val lst = expectMsgType[ItemSummaries] 20 | assert(lst.items.size === 5) 21 | } 22 | 23 | it should "return 3 items containing 'Qu'" in { 24 | model ! ('query, "Qu") 25 | val lst = expectMsgType[ItemSummaries] 26 | assert(lst.items.size === 3) 27 | } 28 | 29 | it should "return item 1 when asked" in { 30 | model ! 1 31 | val item = expectMsgType[Item] 32 | assert(item.id === 1) 33 | assert(item.title === "foo") 34 | } 35 | 36 | it should "return ItemNotFound when requested item is not found" in { 37 | model ! 10 38 | expectMsg(ItemNotFound) 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/example/ServiceJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import spray.json._ 4 | 5 | sealed trait StockLevel 6 | object StockLevel { 7 | def apply(level: Int) = 8 | if (level > 3) InStock 9 | else if (level > 0) LowStock 10 | else SoldOut 11 | } 12 | case object InStock extends StockLevel 13 | case object LowStock extends StockLevel 14 | case object SoldOut extends StockLevel 15 | 16 | case class PublicItem(id: Int, stockLevel: StockLevel, title: String, desc: String) 17 | object PublicItem { 18 | def apply(i: Item): PublicItem = PublicItem(i.id, StockLevel(i.stock), i.title, i.desc) 19 | } 20 | 21 | case class PublicItemSummary(id: Int, stockLevel: StockLevel, title: String) 22 | object PublicItemSummary { 23 | def apply(i: ItemSummary): PublicItemSummary = PublicItemSummary(i.id, StockLevel(i.stock), i.title) 24 | } 25 | 26 | trait ServiceJsonProtocol extends DefaultJsonProtocol { 27 | 28 | implicit object StockLevelFmt extends JsonFormat[StockLevel] { 29 | def write(obj: StockLevel) = JsString(obj.toString) 30 | def read(json: JsValue): StockLevel = json match { 31 | case JsString("InStock") => InStock 32 | case JsString("LowStock") => LowStock 33 | case JsString("SoldOut") => SoldOut 34 | case _ => throw new Exception("Unsupported StockLevel") 35 | } 36 | } 37 | 38 | implicit val publicItemFmt = jsonFormat4(PublicItem.apply) 39 | implicit val publicItemSummaryFmt = jsonFormat3(PublicItemSummary.apply) 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/example/TopLevel.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import akka.actor._ 4 | import spray.can.Http 5 | import akka.io.IO 6 | import akka.actor.Terminated 7 | import akka.actor.SupervisorStrategy.{ Restart, Stop } 8 | import akka.util.Timeout 9 | 10 | object TopLevel { 11 | def props: Props = Props[ProductionTopLevel] 12 | def name = "top-level" 13 | } 14 | 15 | class ProductionTopLevel extends TopLevel with TopLevelConfig { 16 | private def c = context.system.settings.config 17 | def interface = c.getString("example-app.service.interface") 18 | def port = c.getInt("example-app.service.port") 19 | implicit def askTimeout = Timeout(c.getMilliseconds("example-app.service.ask-timeout")) 20 | 21 | def createModel = context.actorOf(ModelActor.props, ModelActor.name) 22 | def createService(model: ActorRef) = context.actorOf(ServiceActor.props(model), ServiceActor.name) 23 | } 24 | 25 | trait TopLevelConfig { 26 | def createModel: ActorRef 27 | def createService(model: ActorRef): ActorRef 28 | def interface: String 29 | def port: Int 30 | } 31 | 32 | class TopLevel extends Actor with ActorLogging { 33 | this: TopLevelConfig => 34 | 35 | val model = createModel 36 | context watch model 37 | 38 | val service = createService(model) 39 | context watch service 40 | 41 | import context._ 42 | IO(Http) ! Http.Bind(service, interface, port) 43 | 44 | override def supervisorStrategy: SupervisorStrategy = OneForOneStrategy() { 45 | case _ if model == sender => Stop 46 | case _ if service == sender => Restart 47 | } 48 | 49 | def receive = { 50 | case Http.CommandFailed(_) => context stop self 51 | case Terminated(`model`) => context stop self 52 | case _ => 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/test/scala/example/TopLevelSpec.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import org.scalatest.{ BeforeAndAfterAll, FlatSpecLike } 4 | import akka.actor.ActorDSL._ 5 | import akka.actor._ 6 | import akka.testkit.{ EventFilter, TestActorRef, ImplicitSender, TestKit } 7 | import akka.actor.Terminated 8 | 9 | class Crash extends Actor { 10 | def receive = { case _ => throw new Exception("crash") } 11 | } 12 | 13 | class Boom extends Actor { 14 | def receive = { case _ => throw new Exception("boom") } 15 | } 16 | 17 | class TopLevelSpec extends TestKit(ActorSystem()) with FlatSpecLike with BeforeAndAfterAll with ImplicitSender { 18 | 19 | override def afterAll() { 20 | super.afterAll() 21 | system.shutdown() 22 | } 23 | 24 | trait Case { 25 | val top = TestActorRef(new TopLevel with TopLevelConfig { 26 | def createModel = context.actorOf(Props[Crash]) 27 | def createService(model: ActorRef) = context.actorOf(Props[Boom]) 28 | def interface: String = "localhost" 29 | def port: Int = (10000 + math.random * 50000).toInt 30 | }) 31 | watch(top) 32 | } 33 | 34 | "TopLevel" should "restart service if it dies once" in new Case { 35 | EventFilter[Exception](occurrences = 1) intercept { 36 | top.underlyingActor.service ! 'bang 37 | } 38 | expectNoMsg() 39 | } 40 | 41 | it should "restart service if it dies 10 times" in new Case { 42 | EventFilter[Exception](occurrences = 10) intercept { 43 | (1 to 10) foreach { x => 44 | top.underlyingActor.service ! 'bang 45 | } 46 | } 47 | expectNoMsg() 48 | } 49 | 50 | it should "stop itself if model dies" in new Case { 51 | EventFilter[Exception](occurrences = 2) intercept { 52 | top.underlyingActor.model ! 'bang 53 | } 54 | 55 | assert(expectMsgType[Terminated].actor === top) 56 | } 57 | 58 | it should "terminate if it cannot start" in { 59 | val top = TestActorRef(new TopLevel with TopLevelConfig { 60 | def createModel = context.actorOf(Props[Crash]) 61 | def createService(model: ActorRef) = context.actorOf(Props[Boom]) 62 | def interface: String = "localhost" 63 | def port: Int = 666 64 | }) 65 | watch(top) 66 | 67 | assert(expectMsgType[Terminated].actor === top) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/scala/example/Service.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import spray.routing.HttpService 4 | import akka.actor.{ Props, Actor, ActorRef } 5 | import akka.pattern.ask 6 | import akka.util.Timeout 7 | import scala.concurrent.duration._ 8 | import spray.httpx.SprayJsonSupport._ 9 | import spray.http.StatusCodes 10 | import spray.http.CacheDirectives.`max-age` 11 | import spray.http.HttpHeaders.`Cache-Control` 12 | import spray.http.StatusCodes._ 13 | 14 | object ServiceActor { 15 | def props(model: ActorRef)(implicit askTimeout: Timeout): Props = Props(classOf[ServiceActor], model, askTimeout) 16 | def name = "service" 17 | } 18 | 19 | class ServiceActor(model: ActorRef, implicit val askTimeout: Timeout) extends Actor with Service { 20 | def actorRefFactory = context 21 | def receive = runRoute(route(model)) 22 | } 23 | 24 | trait Service extends HttpService with ServiceJsonProtocol { 25 | 26 | import ModelActor._ 27 | 28 | import scala.language.postfixOps // for 'q ? in parameter() below 29 | 30 | implicit def ec = actorRefFactory.dispatcher 31 | 32 | val CacheHeader = (maxAge: Long) => `Cache-Control`(`max-age`(maxAge)) :: Nil 33 | 34 | // Cache items by a function of their stock level, but avoid leaking exact 35 | // stock levels via the Cache-Control header's max-age. Because this is an 36 | // example I'm hard-coding cache multipliers here. 37 | val MaxAge = (stockLevel: Int) => 10 * math.sqrt(10 + stockLevel).toLong 38 | val MaxAge404 = 600l 39 | 40 | def route(model: ActorRef)(implicit askTimeout: Timeout) = 41 | get { 42 | path("items") { 43 | parameter('q ?) { term => 44 | val msg = term.map('query -> _).getOrElse('list) 45 | onSuccess(model ? msg) { 46 | case ItemSummaries(summaries) => 47 | val maxAge = summaries match { 48 | case Nil => MaxAge404 49 | // Use smallest stock value in list for calculating max-age 50 | case xs => MaxAge(xs.map(_.stock).reduce(math.min)) 51 | } 52 | complete(OK, CacheHeader(maxAge), summaries map { PublicItemSummary(_) }) 53 | } 54 | } 55 | } ~ 56 | path("items" / IntNumber) { id => 57 | onSuccess(model ? id) { 58 | case item: Item => 59 | complete(OK, CacheHeader(MaxAge(item.stock)), PublicItem(item)) 60 | 61 | case ItemNotFound => 62 | complete(StatusCodes.NotFound, CacheHeader(MaxAge404), "Not Found") 63 | } 64 | } 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Spray Example 2 | ============= 3 | 4 | I want this to be an example of a Spray API that goes a bit beyond just the 5 | routing layer. In particular, it shows how to wire together an API that uses a 6 | separate service and model actor. It also shows off a few things I consider good 7 | practice: 8 | 9 | * "Intelligent" cache control. Tailor the upstream cache time per resource. 10 | 11 | * Separate on-the-wire protocol. I often see code bases where the domain objects 12 | contain more annotations than code---often for both JSON and ORM mappings. 13 | I think this is bad practice and prefer to use different objects. 14 | 15 | * Use [sbt-revolver][]. This is great plugin by the Spray guys to simplify and 16 | speed up the dev/build/test cycle. 17 | 18 | * Stub out child actors for testing using the *Cake Pattern*. See the 19 | `TopLevelConfig` trait and its corresponding `ProductionTopLevelConfig` 20 | implementation for more details. 21 | 22 | [sbt-revolver]: https://github.com/spray/sbt-revolver 23 | 24 | Running the example service 25 | --------------------------- 26 | 27 | To start the example service, launch a terminal and cd into the directory and 28 | run sbt: 29 | 30 | $ sbt 31 | 32 | Once sbt starts the prompt will change. You can start the example service in the background using the `re-start` command, provided by the `sbt-revolver` plugin: 33 | 34 | > re-start 35 | 36 | In a different terminal (or a browser), call the service: 37 | 38 | $ curl localhost:8080/items 39 | 40 | You should see: 41 | 42 | [{ 43 | "id": 1, 44 | "stockLevel": "LowStock", 45 | "title": "foo" 46 | }, { 47 | "id": 2, 48 | "stockLevel": "LowStock", 49 | "title": "bar" 50 | }, { 51 | "id": 3, 52 | "stockLevel": "InStock", 53 | "title": "qux" 54 | }, { 55 | "id": 4, 56 | "stockLevel": "InStock", 57 | "title": "quux" 58 | }, { 59 | "id": 5, 60 | "stockLevel": "InStock", 61 | "title": "quuux" 62 | }] 63 | 64 | You can also try getting only the items where the description contains 'Qu': 65 | 66 | $ curl 'localhost:8080/items?q=Qu' 67 | 68 | The quotes are necessary because `?` would otherwise be interpreted by the 69 | shell. Anyway, you should see this: 70 | 71 | [{ 72 | "id": 3, 73 | "stockLevel": "InStock", 74 | "title": "qux" 75 | }, { 76 | "id": 4, 77 | "stockLevel": "InStock", 78 | "title": "quux" 79 | }, { 80 | "id": 5, 81 | "stockLevel": "InStock", 82 | "title": "quuux" 83 | }] 84 | 85 | Now try getting a single item: 86 | 87 | $ curl localhost:8080/items/2 88 | 89 | You should see: 90 | 91 | { 92 | "id": 2, 93 | "stockLevel": "LowStock", 94 | "title": "bar", 95 | "desc": "More information about Bar" 96 | } 97 | 98 | Finally, try getting an item that doesn't exist: 99 | 100 | $ curl localhost:8080/items/23 101 | 102 | You should get a "Not Found" message, and the status code 404. 103 | 104 | -------------------------------------------------------------------------------- /src/test/scala/example/ServiceSpec.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import akka.actor.ActorDSL._ 4 | import org.scalatest.FlatSpec 5 | import spray.testkit.ScalatestRouteTest 6 | import akka.util.Timeout 7 | import scala.concurrent.duration._ 8 | import spray.http.StatusCodes 9 | import spray.httpx.SprayJsonSupport._ 10 | import spray.http.CacheDirectives.`max-age` 11 | import spray.http.HttpHeaders.`Cache-Control` 12 | 13 | class ServiceSpec extends FlatSpec with ScalatestRouteTest with ServiceJsonProtocol { 14 | 15 | import ModelActor._ 16 | 17 | val data = for (i <- 0 to 100) yield Item(i, i, s"title-$i", s"desc-$i") 18 | val summary = (i: Item) => ItemSummary(i.id, i.stock, i.title) 19 | 20 | val model = actor(new Act { 21 | become { 22 | case i: Int => sender ! data.find(_.id == i).getOrElse(ItemNotFound) 23 | case 'list => sender ! ItemSummaries(data.map(summary)) 24 | case ('query, x: String) => sender ! ItemSummaries(data.filter(_.desc.contains(x)).map(summary)) 25 | 26 | } 27 | }) 28 | 29 | implicit def timeout = Timeout(3.second) 30 | 31 | def route = new Service { 32 | def actorRefFactory = system 33 | }.route(model) 34 | 35 | "The Service" should "return a list of 10 items" in { 36 | Get("/items") ~> route ~> check { 37 | assert(status === StatusCodes.OK) 38 | assert(header[`Cache-Control`] === Some(`Cache-Control`(`max-age`(30)))) 39 | 40 | val res = responseAs[Seq[PublicItemSummary]] 41 | assert(res.size === data.size) 42 | assert(res.head === PublicItemSummary(summary(data.head))) 43 | } 44 | } 45 | 46 | it should "return a list of 2 items containing '10'" in { 47 | Get("/items?q=10") ~> route ~> check { 48 | assert(status === StatusCodes.OK) 49 | assert(header[`Cache-Control`] === Some(`Cache-Control`(`max-age`(40)))) 50 | 51 | val res = responseAs[Seq[PublicItemSummary]] 52 | assert(res.size === 2) 53 | assert(res === (data(10) :: data.last :: Nil map summary).map(PublicItemSummary(_))) 54 | } 55 | } 56 | 57 | it should "return a list of 1 item containing '50'" in { 58 | Get("/items?q=50") ~> route ~> check { 59 | assert(status === StatusCodes.OK) 60 | assert(header[`Cache-Control`] === Some(`Cache-Control`(`max-age`(70)))) 61 | 62 | val res = responseAs[Seq[PublicItemSummary]] 63 | assert(res.size === 1) 64 | assert(res === (data(50) :: Nil map summary).map(PublicItemSummary(_))) 65 | } 66 | } 67 | 68 | it should "return an empty list if nothing matches" in { 69 | Get("/items?q=this-query-should-match-nothing") ~> route ~> check { 70 | assert(status === StatusCodes.OK) 71 | assert(header[`Cache-Control`] === Some(`Cache-Control`(`max-age`(600)))) 72 | 73 | val res = responseAs[Seq[PublicItemSummary]] 74 | assert(res === Nil) 75 | } 76 | } 77 | 78 | it should "return single items" in { 79 | Get("/items/1") ~> route ~> check { 80 | assert(status === StatusCodes.OK) 81 | assert(header[`Cache-Control`] === Some(`Cache-Control`(`max-age`(30)))) 82 | assert(responseAs[PublicItem] === PublicItem(1, LowStock, "title-1", "desc-1")) 83 | } 84 | 85 | Get("/items/9") ~> route ~> check { 86 | assert(status === StatusCodes.OK) 87 | assert(header[`Cache-Control`] === Some(`Cache-Control`(`max-age`(40)))) 88 | assert(responseAs[PublicItem] === PublicItem(9, InStock, "title-9", "desc-9")) 89 | } 90 | 91 | Get("/items/100") ~> route ~> check { 92 | assert(status === StatusCodes.OK) 93 | assert(header[`Cache-Control`] === Some(`Cache-Control`(`max-age`(100)))) 94 | assert(responseAs[PublicItem] === PublicItem(100, InStock, "title-100", "desc-100")) 95 | } 96 | 97 | } 98 | 99 | it should "return 404 for non-existent items" in { 100 | Get("/items/404") ~> route ~> check { 101 | assert(status === StatusCodes.NotFound) 102 | assert(header[`Cache-Control`] === Some(`Cache-Control`(`max-age`(600)))) 103 | response === "Not Found" 104 | } 105 | } 106 | 107 | } 108 | --------------------------------------------------------------------------------