Quellcode durchsuchen

PUT /food implemented and tested.

Thomas Flucke vor 7 Jahren
Ursprung
Commit
860b49a3fa

+ 52 - 15
app/controllers/FoodController.scala

@@ -1,25 +1,62 @@
 package controllers
 
+import scala.concurrent.Await
+import scala.concurrent.duration.Duration
+import javax.inject._
 import models.{Food,Unit,Mass,Measure}
 import play.api.mvc._
 import play.api.libs.json._
+import play.api.test._
+import org.mongodb.scala.model.Filters
+import org.mongodb.scala.{MongoDatabase,MongoCollection,Observable,Completed}
 
-class FoodController extends Controller {
-  private def expFood() = new Food("Example",
-    false, false, false,
-    Map.empty,
-    "Tom Flucke",
-    Seq("Poultry", "Southern"),
-    Mass,
-    2, 30, 1237,
-    Nil
-  )
+@Singleton
+class FoodController(collection: MongoCollection[Food], sync: Boolean = true) extends Controller {
+  def this(db: MongoDatabase, sync: Boolean) {
+    this(db.getCollection[Food]("Food"), sync)
+  }
+  def this() {
+    this(MongoDB.con, false)
+  }
+
+  def put(): Action[JsValue] = Action(parse.json) { request: Request[JsValue] =>
+    try {
+      val food: Food = request.body.as[Food]
+      val query: Observable[Completed] = collection.insertOne(food)
+      if (sync)
+      {
+        Await.result(query.toFuture, Duration.Inf)
+      }
+      Ok(Json.toJson(food))
+    }
+    catch {
+      case jsre: JsResultException => BadRequest(s"Could not parse json into Food.")
+    }
+  }
 
-  def put(): Action[Food] = null
-  def update(id: Int): Action[Food] = null
-  def get(id: Int): Action[Food] = null
-  def query(query: String = ""): Action[AnyContent] = Action {
+  def update(id: String): Action[JsValue] = Action(parse.json) { request: Request[JsValue] =>
+    try {
+      val query: Observable[Food] = collection.find(Filters.equal[String]("_id", id))
+      val food: Food = Await.result(query.toFuture, Duration.Inf)
+      val newJson: JsObject = Json.toJson(expFood) ++ request.body
+      val save: Observable[Completed] = collection.save(food.as[Food])
+      if (sync)
+      {
+        Await.result(query.toFuture, Duration.Inf)
+      }
+      Ok(newJson)
+    }
+    catch {
+      case jsre: JsResultException => BadRequest(s"Could not parse json into Food.")
+    }
+  }
+  def get(id: Int): Action[JsValue] = Action(parse.json) { request: Request[JsValue] =>
+    Ok(Json.toJson(expFood))
+  }
+  def query(query: String = ""): Action[JsValue] = Action(parse.json) { request: Request[JsValue] =>
     Ok(Json.toJson(expFood))
   }
-  def delete(id: Int): Action[Food] = null
+  def delete(id: Int): Action[JsValue] = Action(parse.json) { request: Request[JsValue] =>
+    Ok(request.body)
+  }
 }

+ 41 - 0
app/controllers/MongoDB.scala

@@ -0,0 +1,41 @@
+package controllers
+
+import org.mongodb.scala.{MongoClient,MongoDatabase};
+import org.mongodb.scala.bson.codecs.Macros._
+import org.mongodb.scala.bson.codecs.DEFAULT_CODEC_REGISTRY;
+import org.bson.codecs.configuration.CodecRegistries.{fromRegistries, fromProviders, fromCodecs}
+import org.bson.codecs.{Codec,DecoderContext,EncoderContext}
+import org.bson.types.ObjectId
+import org.bson.{BsonWriter,BsonReader}
+import models._
+
+object MongoDB {
+  private def encoderCodec[T](cls: Class[T]): Codec[T] = new Codec[T] {
+    def decode(reader: BsonReader, decoderContext: DecoderContext): T = throw new UnsupportedOperationException()
+    def encode(writer: BsonWriter, value: T, encoderContext: EncoderContext) = writer.writeString(cls.getSimpleName)
+    def getEncoderClass(): Class[T] = cls
+  }
+
+  val codecRegistry = fromRegistries(
+    fromProviders(classOf[Food]),
+    fromCodecs(encoderCodec[Mass](classOf[Mass]),
+      encoderCodec[Volume](classOf[Volume]),
+      encoderCodec[Number](classOf[Number]),
+      new Codec[Measure] {
+        def decode(reader: BsonReader, decoderContext: DecoderContext): Measure =
+          reader.readString() match {
+            case "Mass" => Mass()
+            case "Volume" => Volume()
+            case "Number" => Number()
+            case other: String => throw new RuntimeException(s"Did not recognize measure string: $other")
+          }
+        def encode(writer: BsonWriter, value: Measure, encoderContext: EncoderContext) = throw new UnsupportedOperationException()
+        def getEncoderClass(): Class[Measure] = classOf[Measure]
+    }),
+    DEFAULT_CODEC_REGISTRY
+  )
+
+  val con: MongoDatabase = MongoClient.apply()
+    .getDatabase("recipes")
+    .withCodecRegistry(MongoDB.codecRegistry)
+}

+ 19 - 5
app/models/Food.scala

@@ -1,8 +1,9 @@
 package models
 
-import play.api.libs.json._
+import org.bson.types.ObjectId
 
-case class Food(
+case class Food (
+  _id: String = "",
   val name: String,
   val glutenFree: Boolean = false,
   val vegitarian: Boolean = false,
@@ -14,11 +15,24 @@ case class Food(
   val density: Float,
   val mass_p_u: Float,
   val price: Int,
-  val alternatives: Seq[Food] = Nil
+  val alternatives: Seq[String] = Nil
     //val picture: Image
 )
 
 object Food {
-  import play.api.libs.json.Json
-  implicit val foodFormats = Json.format[Food]
+  import play.api.libs.json._
+
+  private def roundJsValue(jsval: JsValue, perc: Int): JsValue = {
+    jsval match {
+      case JsNumber(num) => JsNumber(num.setScale(perc, BigDecimal.RoundingMode.HALF_UP))
+      case JsArray(seq) => JsArray(seq.map(roundJsValue(_, perc)))
+      case JsObject(map) => JsObject(map.mapValues(roundJsValue(_, perc)))
+      case _ => jsval
+    }
+  }
+
+  implicit val foodFormats = Format[Food](
+    Json.using[Json.WithDefaultValues].reads[Food],
+    Json.using[Json.WithDefaultValues].writes[Food].transform(roundJsValue(_, 2))
+  )
 }

+ 7 - 7
app/models/Measure.scala

@@ -1,6 +1,6 @@
 package models
 
-sealed trait Measure {
+sealed abstract class Measure {
   def productPrefix: String
   def name: String = this.productPrefix.split("\\.").last
   //  def primaryUnit: Uni
@@ -15,26 +15,26 @@ object Measure {
     def reads(jsValue: JsValue): JsResult[Measure] =
       jsValue.validate[String] match {
         case s: JsSuccess[String] => s.get match {
-          case "mass" => JsSuccess(Mass)
-          case "volume" => JsSuccess(Volume)
-          case "number" => JsSuccess(Number)
+          case "mass" => JsSuccess(Mass())
+          case "volume" => JsSuccess(Volume())
+          case "number" => JsSuccess(Number())
         }
         case e: JsError => e
       }
   }
 }
 
-case object Mass extends Measure
+case class Mass() extends Measure
 // {
 //   val primaryUnit = Gram
 // }
 
-case object Volume extends Measure
+case class Volume() extends Measure
 // {
 //   val primaryUnit = Liter
 // }
 
-case object Number extends Measure
+case class Number() extends Measure
 // {
 //   val primaryUnit = Count
 // }

+ 16 - 2
build.sbt

@@ -7,8 +7,16 @@ lazy val root = (project in file(".")).enablePlugins(PlayScala)
 
 scalaVersion := "2.12.4"
 
-libraryDependencies += guice
-libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.1.2" % Test
+// Unit testing frameworks
+libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "3.0.0" % "test"
+// Add the ScalaMock library (versions 4.0.0 onwards)
+//libraryDependencies += "org.scalamock" %% "scalamock" % "4.1.0" % Test
+// also add ScalaTest as a framework to run the tests
+//libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.4" % Test
+// https://mvnrepository.com/artifact/org.mongodb.scala/mongo-scala-driver
+libraryDependencies += "org.mongodb.scala" %% "mongo-scala-driver" % "2.4.0"
+// https://mvnrepository.com/artifact/com.github.fakemongo/fongo
+libraryDependencies += "com.github.simplyscala" %% "scalatest-embedmongo" % "0.2.4" % "test"
 
 // https://mvnrepository.com/artifact/org.webjars/angularjs
 libraryDependencies += "org.webjars" % "angularjs" % "1.6.10"
@@ -17,6 +25,12 @@ libraryDependencies += "org.webjars.bower" % "angular-resource" % "1.6.9"
 // https://mvnrepository.com/artifact/org.webjars/angular-ui-bootstrap
 libraryDependencies += "org.webjars" % "angular-ui-bootstrap" % "2.5.0"
 
+// DOA
+// https://mvnrepository.com/artifact/com.mongodb.casbah/casbah-core
+//libraryDependencies += "com.github.salat" %% "salat" % "1.11.2"
+//libraryDependencies += "com.h2database" % "h2" % "1.4.196"
+//libraryDependencies += "org.hibernate.ogm" % "hibernate-ogm-core" % "5.0.0.Final"
+//libraryDependencies += "org.hibernate.ogm" % "hibernate-ogm-mongodb" % "5.0.0.Final"
 
 // Adds additional packages into Twirl
 //TwirlKeys.templateImports += "com.example.controllers._"

+ 28 - 0
conf/META-INF/persistence.xml

@@ -0,0 +1,28 @@
+<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence"
+             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd"
+             version="2.1">
+  <!--
+    <persistence-unit name="defaultPersistenceUnit" transaction-type="RESOURCE_LOCAL">
+        <provider>org.hibernate.ogm.jpa.HibernateOgmPersistence</provider>
+        <non-jta-data-source>java:/DefaultDS</non-jta-data-source>
+        <properties>
+            <property name="hibernate.ogm.datastore.provider"
+            		 value="org.hibernate.ogm.datastore.mongodb.impl.MongoDBDatastoreProvider"/>
+            <property name="hibernate.ogm.datastore.host" value="127.0.0.1"/>
+            <property name="hibernate.ogm.datastore.port" value="27017"/>
+            <property name="hibernate.ogm.datastore.database" value="craftCharacter"/>
+            <property name="hibernate.ogm.datastore.safe" value="true"/>
+        </properties>
+        </persistence-unit>
+        -->
+    <persistence-unit name="defaultPersistenceUnit" transaction-type="JTA">
+        <provider>org.hibernate.ogm.jpa.HibernateOgmPersistence</provider>
+        <non-jta-data-source>DefaultDS</non-jta-data-source>
+        <properties>
+            <property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect"/>
+            <property name="schemaUpdate" value="true" />
+            <property name="hibernate.hbm2ddl.auto" value="update" />
+        </properties>
+    </persistence-unit>
+</persistence>

+ 8 - 4
conf/application.conf

@@ -344,13 +344,17 @@ db {
   # By convention, the default datasource is named `default`
 
   # https://www.playframework.com/documentation/latest/Developing-with-the-H2-Database
-  #default.driver = org.h2.Driver
-  #default.url = "jdbc:h2:mem:play"
-  #default.jndiName=DefaultDS
+  default.driver = org.h2.Driver
+  default.url = "jdbc:h2:mem:default"
+  default.jndiName = DefaultDS
+  
+  test.driver = org.h2.Driver
+  test.url = "jdbc:h2:mem:testing"
+  test.jndiName = TestDS
 
   # You can turn on SQL logging for any datasource
   # https://www.playframework.com/documentation/latest/Highlights25#Logging-SQL-statements
   #default.logSql=true
 }
 
-jpa.default=defaultPersistenceUnit
+jpa.default=defaultPersistenceUnit

+ 179 - 0
test/controllers/FoodControllerSpec.scala

@@ -0,0 +1,179 @@
+package controllers
+
+import scala.concurrent.Await
+import scala.concurrent.duration.Duration
+import org.scalatestplus.play._
+import akka.stream.Materializer
+
+import play.api.mvc._
+import play.api.test._
+import play.api.test.Helpers._
+import play.api.http.Status
+import play.api.http.HeaderNames
+import play.api.libs.json.{JsValue,JsObject,Json}
+
+import org.scalatest._
+import org.scalatestplus.play._
+import org.scalatestplus.play.guice._
+
+import com.github.simplyscala.{MongoEmbedDatabase,MongodProps}
+import org.mongodb.scala.{MongoClient,MongoDatabase,MongoCollection,Observer};
+
+import models.Food
+
+class FoodControllerSpec
+    extends PlaySpec
+    with GuiceOneAppPerTest
+    with Injecting
+    with MongoEmbedDatabase {
+
+  val testDBPort: Int = 12345
+  implicit lazy val materializer: Materializer = app.materializer
+
+  def getDBColl(): MongoCollection[Food] = {
+    val db: MongoDatabase = MongoClient.apply(s"mongodb://localhost:${testDBPort}")
+      .getDatabase("recipes")
+      .withCodecRegistry(MongoDB.codecRegistry)
+    db.createCollection("Food")
+    db.getCollection[Food]("Food")
+  }
+
+  // def blockingObserver[T](sem: Semaphore, processor: T => Unit) = new Observer[T] {
+  //   override def onNext(result: T) = {println("next");processor(result)}
+  //   override def onError(e: Throwable) = {
+  //     println("error")
+  //     sem.release()
+  //     throw e
+  //   }
+  //   override def onComplete() = {println("complete");sem.release()}
+  // }
+
+  "FoodController PUT /food" should {
+    "return the resulting food object and insert into the DB" in {
+      val body: JsValue = Json.parse(
+        """{"name": "Example Food",
+      |"glutenFree": false,
+      |"vegitarian": true,
+      |"vegan": false,
+      |"nutrients": {"iron": 1.2, "B": 7},
+      |"source": "Test Script",
+      |"category": ["Breakfast", "Asian"],
+      |"primaryMeasure": "mass",
+      |"density": 3.2,
+      |"mass_p_u": 2.7,
+      |"price": 1237,
+      |"alternatives": ["Scrambled Eggs"]
+      |} """.stripMargin
+      )
+      val fakeRequ: FakeRequest[JsValue] = FakeRequest[JsValue](PUT, "/food", FakeHeaders(), body)
+        .withHeaders(HeaderNames.CONTENT_TYPE -> "application/json")
+      withEmbedMongoFixture() { mongodProps: MongodProps =>
+        val coll: MongoCollection[Food] = getDBColl
+        val controller: FoodController = new FoodController(coll)
+        val result = controller.put().apply(fakeRequ)
+        status(result) mustBe OK
+        contentType(result) mustBe Some("application/json")
+        val resultJs: JsObject = contentAsJson(result).asInstanceOf[JsObject]
+        resultJs - "_id" mustBe body
+        Await.result(coll.countDocuments().toFuture, Duration.Inf) mustBe 1
+        Await.result(coll.find().first().toFuture, Duration.Inf) mustBe resultJs.as[Food]
+      }
+    }
+
+    // Removed: not presenting id's to front-end
+    // "return the resulting food object with a distinct id" in {
+    //   val controller = inject[HomeController]
+    //   val home = controller.index().apply(FakeRequest(GET, "/"))
+
+    //   status(home) mustBe OK
+    //   contentType(home) mustBe Some("text/html")
+    //   contentAsString(home) must include ("Welcome to Play")
+    // }
+
+    "return an error when given an empty request and not " +
+      "insert into the DB" in {
+        val fakeRequ = FakeRequest(PUT, "/food", FakeHeaders(), "")
+          .withHeaders(HeaderNames.CONTENT_TYPE -> "application/json")
+        withEmbedMongoFixture() { mongodProps: MongodProps =>
+          val coll: MongoCollection[Food] = getDBColl
+          val controller: FoodController = new FoodController(coll)
+          val result = controller.put().apply(fakeRequ)
+          status(result) mustBe Status.BAD_REQUEST
+          Await.result(coll.countDocuments().toFuture, Duration.Inf) mustBe 0
+        }
+      }
+
+    "return an error when given an invalid request and not " +
+      "insert into the DB" in {
+        val body: JsValue = Json.parse(// Name intentionally moved
+          """{
+      |"glutenFree": false,
+      |"vegitarian": true,
+      |"vegan": false,
+      |"nutrients": {"iron": 1.2, "B": 7},
+      |"source": "Test Script",
+      |"category": ["Breakfast", "Asian"],
+      |"primaryMeasure": "mass",
+      |"density": 3.2,
+      |"mass_p_u": 2.7,
+      |"price": 1237,
+      |"alternatives": ["Scrambled Eggs"]
+      |} """.stripMargin
+        )
+        val fakeRequ = FakeRequest(PUT, "/food", FakeHeaders(), body)
+        .withHeaders(HeaderNames.CONTENT_TYPE -> "application/json")
+        withEmbedMongoFixture() { mongodProps: MongodProps =>
+          val coll: MongoCollection[Food] = getDBColl
+          val controller: FoodController = new FoodController(coll)
+          val result = controller.put().apply(fakeRequ)
+          status(result) mustBe Status.BAD_REQUEST
+          Await.result(coll.countDocuments().toFuture, Duration.Inf) mustBe 0
+        }
+      }
+
+    "return the resulting object with default values and " +
+    "insert into the DB" in {
+      val body: JsValue = Json.parse(
+        """{"name": "Example Food",
+      |"vegitarian": true,
+      |"nutrients": {"iron": 1.2, "B": 7},
+      |"source": "Test Script",
+      |"category": ["Breakfast", "Asian"],
+      |"primaryMeasure": "mass",
+      |"density": 3.2,
+      |"mass_p_u": 2.7,
+      |"price": 1237,
+      |"alternatives": ["Scrambled Eggs"]
+      |} """.stripMargin
+      )
+      val expected: JsValue = Json.parse(
+        """{"name": "Example Food",
+      |"glutenFree": false,
+      |"vegitarian": true,
+      |"vegan": false,
+      |"nutrients": {"iron": 1.2, "B": 7},
+      |"source": "Test Script",
+      |"category": ["Breakfast", "Asian"],
+      |"primaryMeasure": "mass",
+      |"density": 3.2,
+      |"mass_p_u": 2.7,
+      |"price": 1237,
+      |"alternatives": ["Scrambled Eggs"]
+      |} """.stripMargin
+      )
+      val fakeRequ = FakeRequest(PUT, "/food", FakeHeaders(), body)
+        .withHeaders(HeaderNames.CONTENT_TYPE -> "application/json")
+      withEmbedMongoFixture() { mongodProps: MongodProps =>
+        val coll: MongoCollection[Food] = getDBColl
+        val controller: FoodController = new FoodController(coll)
+        val result = controller.put().apply(fakeRequ)
+        status(result) mustBe OK
+        contentType(result) mustBe Some("application/json")
+        val resultJs: JsObject = contentAsJson(result).asInstanceOf[JsObject]
+        resultJs - "_id"  mustBe expected
+        Await.result(coll.countDocuments().toFuture, Duration.Inf) mustBe 1
+        Await.result(coll.find().first().toFuture, Duration.Inf) mustBe resultJs.as[Food]
+      }
+    }
+  }
+}