Browse Source

Initial commit

Thomas Flucke 5 năm trước cách đây
commit
5aeae54a9c
86 tập tin đã thay đổi với 4966 bổ sung0 xóa
  1. 1 0
      .gitignore
  2. 60 0
      README.md
  3. 87 0
      build.sbt
  4. 39 0
      client/src/main/scala/com/weEat/Cookie.scala
  5. 200 0
      client/src/main/scala/com/weEat/Main.scala
  6. 45 0
      client/src/main/scala/com/weEat/Storage.scala
  7. 11 0
      client/src/main/scala/com/weEat/models/Nutrient.scala
  8. 169 0
      client/src/main/scala/com/weEat/modules/MHtmlHelpers.scala
  9. 102 0
      client/src/main/scala/com/weEat/modules/Overlay.scala
  10. 59 0
      client/src/main/scala/com/weEat/modules/PageSelect.scala
  11. 43 0
      client/src/main/scala/com/weEat/modules/PaginatedTable.scala
  12. 63 0
      client/src/main/scala/com/weEat/modules/SearchBar.scala
  13. 24 0
      client/src/main/scala/com/weEat/modules/Select.scala
  14. 270 0
      client/src/main/scala/com/weEat/modules/USDAEditor.scala
  15. 86 0
      client/src/main/scala/com/weEat/modules/UsdaImporter.scala
  16. 31 0
      fdc/js/src/main/scala/gov/usda/nal/fdc/DateHelper.scala
  17. 8 0
      fdc/jvm/src/main/scala/gov/usda/nal/fdc/DateHelper.scala
  18. 260 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/controllers/FoodController.scala
  19. 15 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/AbridgedFoodNutrient.scala
  20. 30 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/DataType.scala
  21. 19 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodAttribute.scala
  22. 11 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodCategory.scala
  23. 15 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodComponent.scala
  24. 164 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodItem.scala
  25. 25 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodListCriteria.scala
  26. 21 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodNutrient.scala
  27. 12 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodNutrientDerivation.scala
  28. 11 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodNutrientSource.scala
  29. 18 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodPortion.scala
  30. 28 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodSearchCriteria.scala
  31. 28 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodUpdateLog.scala
  32. 29 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodsCriteria.scala
  33. 49 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/InputFood.scala
  34. 23 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/LabelNutrients.scala
  35. 11 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/MeasureUnit.scala
  36. 13 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/Nutrient.scala
  37. 14 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/NutrientAcquisitionDetails.scala
  38. 16 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/NutrientAnalysisDetails.scala
  39. 22 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/NutrientConversionFactor.scala
  40. 11 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/RetentionFactor.scala
  41. 13 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SearchResult.scala
  42. 29 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SearchResultFood.scala
  43. 15 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SortCriteria.scala
  44. 13 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SortOrder.scala
  45. 10 0
      fdc/shared/src/main/scala/gov/usda/nal/fdc/models/WweiaFoodCategory.scala
  46. 1 0
      project/build.properties
  47. 5 0
      project/plugins.sbt
  48. 69 0
      server/app/com/weEat/controllers/FoodController.scala
  49. 98 0
      server/app/com/weEat/controllers/USDAController.scala
  50. 160 0
      server/app/com/weEat/controllers/UserController.scala
  51. 32 0
      server/app/com/weEat/controllers/ViewController.scala
  52. 93 0
      server/app/com/weEat/migrations/InitDb.scala
  53. 9 0
      server/app/com/weEat/migrations/Metadata.scala
  54. 45 0
      server/app/com/weEat/migrations/Migration.scala
  55. 21 0
      server/app/com/weEat/migrations/RestoreFromFile.scala
  56. 43 0
      server/app/com/weEat/migrations/SeedNutrition.scala
  57. 5 0
      server/app/com/weEat/models/Collectable.scala
  58. 5 0
      server/app/com/weEat/models/FoodNode.scala
  59. 5 0
      server/app/com/weEat/models/Nutrient.scala
  60. 100 0
      server/app/com/weEat/models/User.scala
  61. 104 0
      server/app/com/weEat/services/MongoDBService.scala
  62. 175 0
      server/app/com/weEat/services/OAuth2Service.scala
  63. 30 0
      server/app/com/weEat/util/TryWith.scala
  64. 6 0
      server/app/views/index.scala.html
  65. 69 0
      server/app/views/main.scala.html
  66. 6 0
      server/app/views/recipieEdit.scala.html
  67. 9 0
      server/app/views/usdaImport.scala.html
  68. 46 0
      server/conf/application.conf
  69. 0 0
      server/conf/db-seeds/nutrients.json
  70. 43 0
      server/conf/logback.xml
  71. 1 0
      server/conf/messages
  72. 75 0
      server/conf/routes
  73. 7 0
      server/conf/testing.conf
  74. BIN
      server/public/images/favicon.png
  75. 0 0
      server/public/stylesheets/main.css
  76. 17 0
      server/public/views/login.html
  77. 27 0
      server/public/views/register.html
  78. 536 0
      server/test/ApplicationSpec.scala
  79. 371 0
      server/test/IntegrationSpec.scala
  80. 8 0
      shared/js/src/main/scala/com/weEat/shared/models/Identifier.scala
  81. 13 0
      shared/jvm/src/main/scala/com/weEat/shared/models/Identifier.scala
  82. 5 0
      shared/shared/src/main/scala/com/weEat/shared/exceptions/IncompleteDataException.scala
  83. 157 0
      shared/shared/src/main/scala/com/weEat/shared/models/FoodNode.scala
  84. 90 0
      shared/shared/src/main/scala/com/weEat/shared/models/GrantRequest.scala
  85. 231 0
      shared/shared/src/main/scala/com/weEat/shared/models/MeasureUnit.scala
  86. 26 0
      shared/shared/src/main/scala/com/weEat/shared/models/User.scala

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+target

+ 60 - 0
README.md

@@ -0,0 +1,60 @@
+# TODO List:
+
+January: (done)
+ * Basic infrastructure
+   * Mongodb (done)
+   * ORM layer (done)
+   * Scala server (done)
+   * Scala Client (done)
+   * Migration System (done)
+
+February:
+ * Scala API layer (done)
+ * Basic Web Framework (Rx) (done)
+ * DB Seed (done)
+
+March:
+ * Add Recipe
+ * Calculate nutritional information
+ * Attach photos
+
+April:
+ * User profile setup
+ * Recipe Version tracking
+
+May:
+ * Search Recipe
+ * Search by ingredient
+
+June:
+ * HTML Importer
+ * Change Log
+
+July:
+ * Calories Adjustments from cooking
+
+August:
+
+September:
+
+October:
+
+December:
+
+Goals:
+ * Add Users
+   * Users can upload recipes
+   * Users can bookmark recipes
+   * Users can store preferred units
+ * Recipes:
+   * Automatic nutrition information
+   * Version tracking
+   * Automatic Unit conversion
+   * Attach photos
+ * Import from popular web sites
+ * Search by ingredient
+
+Stretch Goals:
+ * Parse recipe from photo
+ * Seasonal food search
+ * substitute recipe in place of ingredient

+ 87 - 0
build.sbt

@@ -0,0 +1,87 @@
+def commonSettings = Seq(
+  scalaVersion := "2.13.3",
+  version := "0.1.0",
+)
+
+lazy val server: Project = (project in file("server"))
+  .settings(commonSettings)
+  .settings(
+    scalaJSProjects := Seq(client),
+    pipelineStages in Assets := Seq(scalaJSPipeline),
+    compile in Compile := ((compile in Compile) dependsOn scalaJSPipeline).value,
+    javaOptions in Test += "-Dconfig.file=conf/testing.conf",
+    libraryDependencies ++= Seq(
+      guice,
+      "codes.reactive" %% "scala-time" % "0.4.2",
+      // OAuth
+      "com.nulab-inc" %% "scala-oauth2-core" % "1.5.0",
+      "com.nulab-inc" %% "play2-oauth2-provider" % "1.5.0",
+      "com.github.t3hnar" %% "scala-bcrypt" % "4.3.0",
+      // Client Libraries
+      "com.vmunier" %% "scalajs-scripts" % "1.1.4",
+      "org.webjars" %% "webjars-play" % "2.8.0",
+      "org.webjars" % "jquery" % "3.4.1",
+      "org.webjars" % "bootstrap" % "4.4.1-1",
+      // Mogno + ORM
+      "org.mongodb.scala" %% "mongo-scala-driver" % "4.1.0",
+      // Testing
+      specs2 % Test,
+      "org.scalatestplus.play" %% "scalatestplus-play" % "5.0.0" % Test
+    )
+  )
+  .enablePlugins(PlayScala)
+  .dependsOn(sharedJvm)
+  .dependsOn(fdcJvm)
+
+import com.tflucke.webroutes.endpoints.PlayEndpointFile
+
+lazy val client = (project in file("client"))
+  .settings(commonSettings)
+  .settings(
+    scalaJSUseMainModuleInitializer := true,
+    Compile / apiDefinitions += PlayEndpointFile(server),
+    libraryDependencies += "org.querki" %%% "jquery-facade"  % "2.0",
+    libraryDependencies += "in.nvilla"  %%% "monadic-html" % "0.4.1",
+    libraryDependencies += "in.nvilla"  %%% "monadic-rx-cats" % "0.4.1",
+      // libraryDependencies += "com.thoughtworks.binding" %%% "binding" % "12.0.1+8-8f3c7f31",
+    // libraryDependencies += "org.lrng.binding" %%% "html" % "1.0.3",
+    // libraryDependencies += "org.scala-lang.modules" %%% "scala-xml" % "2.0.0-M2",
+    scalacOptions ++= {
+      import Ordering.Implicits._
+      if (VersionNumber(scalaVersion.value).numbers >= Seq(2L, 13L))
+        Seq("-Ymacro-annotations")
+      else Nil
+    }
+  )
+  .enablePlugins(ScalaJSPlugin, RestRPC, ScalaJSWeb)
+  .dependsOn(sharedJs)
+  .dependsOn(fdcJs)
+
+lazy val shared = crossProject(JSPlatform, JVMPlatform)
+  .in(file("shared"))
+  .settings(commonSettings)
+  .settings(
+    libraryDependencies += "com.typesafe.play" %%% "play-json" % "2.9.2",
+    // Temporary workaround due to mongodb limitations
+    libraryDependencies += "org.julienrf" %%% "play-json-derived-codecs" % "8.0.0"
+  )
+  .jsConfigure(_ enablePlugins ScalaJSWeb)
+lazy val sharedJvm = shared.jvm.dependsOn(fdcJvm).settings(
+  libraryDependencies += "org.mongodb.scala" %% "mongo-scala-bson" % "4.1.0"
+)
+lazy val sharedJs = shared.js.dependsOn(fdcJs)
+
+lazy val fdc = crossProject(JSPlatform, JVMPlatform)
+  .in(file("fdc"))
+  .settings(commonSettings)
+  .settings(
+    libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2",
+    libraryDependencies += "com.tflucke"       %%% "rest-rpc"  % "0.3.1"
+  )
+lazy val fdcJs = fdc.js
+lazy val fdcJvm = fdc.jvm
+
+// loads the server project at sbt startup
+onLoad in Global := (onLoad in Global).value.andThen(
+  state => "project server" :: state
+)

+ 39 - 0
client/src/main/scala/com/weEat/Cookie.scala

@@ -0,0 +1,39 @@
+package com.weEat
+
+import org.scalajs.dom.document
+import scala.concurrent.duration.Duration
+
+trait Cookies {
+  def set(key: String, value: String, options: CookieOptions = CookieOptions()): Cookies
+  def get(key: String): Option[String]
+  def apply(name: String) = get(name)
+}
+
+case class CookieOptions(
+  path: Option[String] = None,
+  domain: Option[String] = None,
+  expires: Option[Duration] = None,
+  secure: Option[Boolean] = None
+) {
+  def opStr(name: String, op: Option[Any]) = op.map(x => s"$name=$x;").getOrElse("")
+
+  expires.get
+
+  override def toString = opStr("path", path) + opStr("domain", domain) +
+    expires.map(x => s"expires=${x.toMillis};").getOrElse("") + opStr("secure", secure)
+}
+
+object Cookies extends Cookies {
+  def get(name: String) = document.cookie.split(";")
+    .find(_.trim.startsWith(s"$name="))
+    .map(x => x.substring(x.indexOf("=") + 1))
+
+  def set(
+    name: String,
+    value: String,
+    options: CookieOptions = CookieOptions()
+  ) = {
+    document.cookie = s"$name=$value;$options"
+    this
+  }
+}

+ 200 - 0
client/src/main/scala/com/weEat/Main.scala

@@ -0,0 +1,200 @@
+package com.weEat
+
+import com.tflucke.webroutes.Headers
+import org.scalajs.dom.document
+import org.scalajs.dom.raw.{Element,HTMLInputElement,MouseEvent,HTMLDivElement}
+import scala.scalajs.js
+import scala.scalajs.js.annotation.JSExportTopLevel
+import scala.scalajs.js.timers
+import scala.util.{Try,Success,Failure}
+import scala.concurrent.ExecutionContext.Implicits.global
+import scala.concurrent.Future
+import com.weEat.shared.models._
+import com.weEat.controllers.UserController
+import org.querki.jquery.{JQueryEventObject,JQuery,JQueryXHR,JQueryStatic => $}
+import com.tflucke.webroutes.{HTTPException,TimeoutException}
+import play.api.libs.json.Json
+
+object Main {
+  /* Get the CSRF token embedded in the HTML page.  This token will implicitly
+   * be passed to Rest RPC.
+   */
+  implicit def headers = Headers(Seq(csrfHeader, authHeader).flatten.toMap)
+
+  @JSExportTopLevel(name="asyncCount")
+  var asyncCount = 0
+
+  def track[T](future: Future[T]): Future[T] = {
+    asyncCount += 1
+    future andThen {
+      case _=> asyncCount -= 1
+    }
+  }
+
+  val csrfSelector = "input[name=\"csrfToken\"]"
+  def csrfHeader = Option(document.querySelector(csrfSelector)).map({ x =>
+    ("Csrf-Token" -> x.asInstanceOf[HTMLInputElement].value)
+  })
+
+  def authHeader = SessionStorage.get("access-token").map({ token =>
+    ("Authorization" -> token)
+  })
+
+  def main(args: Array[String]): Unit = {
+    refreshToken
+    $("#btn-login").click(promptLogin _)
+    $("#btn-signup").click(promptSignup _)
+    $("#btn-logout").click(logout _)
+  }
+
+  def promptLogin() = {
+    $("body").append(overlayWindow("/assets/views/login.html", login))
+  }
+
+  def promptSignup() = {
+    $("body").append(overlayWindow("/assets/views/register.html", signup))
+  }
+
+  def refreshToken: Unit = {
+    SessionStorage.remove("access-token")
+    SessionStorage.get("username").map({user =>
+      SessionStorage.get("refresh-token").map({refresh =>
+        track(UserController.accessToken()(RefreshRequest(user, refresh))
+          .map(loginComplete(user)).recover({
+            case _ => SessionStorage.remove("username").remove("refresh-token")
+          }))
+      })
+    }).flatten
+  }
+
+  def loginComplete(user: String)(auth: UserAuthorization) = {
+    SessionStorage.set("access-token",
+      "%s %s".format(auth.tokenType, auth.accessToken)
+    )
+    SessionStorage.set("username", user)
+    SessionStorage.set("refresh-token", auth.refreshToken)
+    timers.setTimeout(auth.expiresIn)(refreshToken)
+    $("#login-btns").hide
+    $("#logout-btns").show
+    //track()
+  }
+
+  def signup(div: JQuery): Future[Any] = {
+    import java.util.InputMismatchException
+
+    val email = div.find("#email").value.toString
+    val password = div.find("#password").value.toString
+    track((if (!password.equals(div.find("#password2").value.toString))
+      Future.failed(new InputMismatchException("Passwords do not match."))
+    else
+      UserController.registerUser()(UserRegistration(
+        div.find("#fname").value.toString,
+        div.find("#lname").value.toString,
+        email,
+        password
+      )) map(loginComplete(email))) andThen({
+        case Failure(e: InputMismatchException) => showError(div, e.getMessage)
+        case Failure(e: HTTPException) => showError(div, e.responseText)
+        case Failure(e: TimeoutException) => showError(div, e.getMessage)
+      }))
+  }
+
+  @js.native
+  @js.annotation.JSGlobal("btoa")
+  def base64Encode(str: String): String = js.native
+
+  def showError(div: JQuery, msg: String) = div.find(".alert-danger").html(
+    s"<strong>Error:</strong> $msg"
+  ).show
+
+  def parseString[T](implicit reader: play.api.libs.json.Reads[T]) = {
+    str: String => Json.parse(str).as[T]
+  }
+
+  def login(div: JQuery): Future[Any] = {
+    val email = div.find("#email").value.toString
+    implicit var headers = Main.headers +
+      ("Authorization" -> ("Basic " + base64Encode(
+        "%s:%s".format(email, div.find("#password").value)
+      )))
+    track(UserController.accessToken()(PasswordRequest()).andThen({
+      case Success(auth) => loginComplete(email)(auth)
+      case Failure(error: HTTPException) => Try(
+        error.responseObject(parseString[GrantError])
+      ) match {
+        case Success(gError) => showError(div, gError.errorDescription)
+        case Failure(_) => showError(div, error.getMessage)
+      }
+      case Failure(error: TimeoutException) => showError(div, error.getMessage)
+    }))
+  }
+
+  def logout: Future[Unit] = {
+    track(SessionStorage.get("username").map({user =>
+      SessionStorage.get("refresh-token").map({ refresh: String =>
+        UserController.revokeAccessToken()(RefreshRequest(user, refresh))
+      })
+    }).flatten.getOrElse(Future.failed(
+      new IllegalStateException("No login information.")
+    )).map({ _ =>
+      SessionStorage.remove("username").remove("refresh-token")
+      document.location.reload(false)
+    }))
+  }
+
+  def shadeWindow(cancelFn: Option[(() => Unit)]) = {
+    val shadeDiv = $("<div>").css(js.Dictionary[js.Any](
+      "z-index" -> 99,
+      "background-color" -> "rgba(0, 0, 0, 0.5)",
+      "position" -> "fixed",
+      "top" -> 0,
+      "bottom" -> 0,
+      "left" -> 0,
+      "right" -> 0
+    ))
+    shadeDiv.click((event: JQueryEventObject) => {
+      if (event.target == shadeDiv(0))
+      {
+        cancelFn match {
+          case Some(fn) => fn()
+          case None =>
+        }
+        $(event.target).detach
+      }
+    })
+  }
+
+  def overlayWindow[T](
+    contentUrl: String,
+    submitFn: (JQuery) => Future[T],
+    cancelFn: Option[() => Unit] = None
+  ) = {
+    val promptDiv = $("<div>").css(js.Dictionary[js.Any](
+      "margin" -> "auto",
+      "padding" -> "1em",
+      "background-color" -> "#ffffff",
+      "position" -> "relative",
+      "border-radius" -> "1em",
+      "top" -> "50%",
+      "transform" -> "translateY(-50%)"
+    )).addClass("w-50")
+    val shadeDiv = shadeWindow(cancelFn).append(promptDiv)
+    promptDiv.load(contentUrl, "",
+      (elm: Element, resp: String, status: String, xhr: JQueryXHR) => {
+        $(elm).find("*[data-cb='cancel']").click((event: JQueryEventObject) => {
+          cancelFn.map({fn => fn()})
+          shadeDiv.detach
+        })
+        val div =  $(elm).find("*[data-cb='success']").click(
+          (event: JQueryEventObject) => {
+            submitFn(promptDiv) onComplete {
+              case Success(_) => shadeDiv.detach
+              case Failure(err) => System.err.println(err)
+            }
+          }
+        )
+      }
+    )
+    shadeDiv.append(promptDiv)
+  }
+}

+ 45 - 0
client/src/main/scala/com/weEat/Storage.scala

@@ -0,0 +1,45 @@
+package com.weEat
+
+import org.scalajs.dom.document
+import scala.scalajs.js
+import scala.scalajs.js.annotation.{JSName,JSGlobal}
+import scala.concurrent.duration.Duration
+
+@js.native
+protected trait JsStorage extends js.Object {
+  def setItem(key: String, value: String): Unit = js.native
+  def getItem(key: String): String = js.native
+  def removeItem(key: String): Unit = js.native
+  def clear(): Unit = js.native
+}
+
+trait Storage {
+  protected val native: JsStorage
+
+  def set(key: String, value: String): Storage = {
+    native.setItem(key, value)
+    this
+  }
+  def get(key: String): Option[String] = Option(native.getItem(key))
+  def remove(key: String): Storage = {
+    native.removeItem(key)
+    this
+  }
+  def clear(): Storage = {
+    native.clear
+    this
+  }
+}
+
+object SessionStorage extends Storage {
+  @js.native
+  @JSGlobal("window.sessionStorage")
+  protected object native extends JsStorage {}
+}
+
+
+object LocalStorage extends Storage {
+  @js.native
+  @JSGlobal("window.localStorage")
+  protected object native extends JsStorage {}
+}

+ 11 - 0
client/src/main/scala/com/weEat/models/Nutrient.scala

@@ -0,0 +1,11 @@
+package com.weEat.models
+
+import com.weEat.controllers.USDAController
+import mhtml.future.syntax._
+
+object Nutrient {
+
+  implicit val ctx = scala.concurrent.ExecutionContext.global
+
+  lazy val nutrients = USDAController.getNutrients()().toRx
+}

+ 169 - 0
client/src/main/scala/com/weEat/modules/MHtmlHelpers.scala

@@ -0,0 +1,169 @@
+package com.weEat.modules
+
+import scala.xml._
+import mhtml.{Var,Rx}
+import org.scalajs.dom.raw._
+
+object MHtmlHelpers {
+  implicit class RxHelpers[T](rx: Rx[T]) {
+    def value = {
+      var _value: Option[T] = None
+      rx.impure.run({x => _value = Some(x)}).cancel
+      _value.get
+    }
+  }
+
+  implicit class UnprefixedAttributeEmbeddable[T](prev: UnprefixedAttribute[T])
+      extends XmlAttributeEmbeddable[T]
+  implicit class PrefixedAttributeEmbeddable[T](prev: PrefixedAttribute[T])
+      extends XmlAttributeEmbeddable[T]
+
+  implicit class ElemHelpers(elm: Elem) {
+    def addAttribute[T](attr: String, value: T)
+      (implicit ev: XmlAttributeEmbeddable[T]) = {
+      def replacePrevVal: PartialFunction[MetaData, MetaData] = {
+        case UnprefixedAttribute(key, e, next) if key == attr => (value, e) match {
+          case (newFn: Function1[Event, Unit], prevFn: Function1[Event, Unit]) =>
+            UnprefixedAttribute(key, { e: Event => prevFn(e); newFn(e) }, next)
+          case (newFn: Function0[Unit], prevFn: Function1[Event, Unit]) =>
+            UnprefixedAttribute(key, { e: Event => prevFn(e); newFn() }, next)
+          case (newFn: Function1[Event, Unit], prevFn: Function0[Unit]) =>
+            UnprefixedAttribute(key, { e: Event => prevFn(); newFn(e) }, next)
+          case (newFn: Function0[Unit], prevFn: Function0[Unit]) =>
+            UnprefixedAttribute(key, { () => prevFn(); newFn() }, next)
+          case (value, _) => UnprefixedAttribute(key, value, next)
+        }
+        case upa: UnprefixedAttribute[_] =>
+          upa.copy(next = replacePrevVal(upa.next))(upa)
+        case pa: PrefixedAttribute[_] =>
+          pa.copy(next = replacePrevVal(pa.next))(pa, pa)
+        case Null => UnprefixedAttribute(attr, value, Null)
+      }
+      new Elem(elm.prefix, elm.label, replacePrevVal(elm.attributes1), elm.scope,
+        elm.minimizeEmpty, elm.child:_*)
+    }
+
+    def getAttribute(attr: String) = {
+      def findVal: PartialFunction[MetaData, Option[String]] = {
+        case UnprefixedAttribute(key, e, _) if key == attr => Some(e.toString)
+        case upa: UnprefixedAttribute[_] => findVal(upa.next)
+        case pa: PrefixedAttribute[_] => findVal(pa.next)
+        case Null => None
+      }
+      findVal(elm.attributes1)
+    }
+
+    def addClass(clss: String) = {
+      def replacePrevClass: PartialFunction[MetaData, MetaData] = {
+        case UnprefixedAttribute("class", e: scala.xml.Text, next) =>
+          UnprefixedAttribute("class", s"${e.data} $clss", next)
+        case UnprefixedAttribute("class", e: String, next) =>
+          UnprefixedAttribute("class", s"$e $clss", next)
+        case UnprefixedAttribute("class", e: Rx[String], next) =>
+          UnprefixedAttribute("class", e.map({x => s"$x $clss"}), next)
+        //case UnprefixedAttribute("class", e: Rx[scala.xml.Text], next) =>
+        //  UnprefixedAttribute("class", e.map({x => s"$x.data $clss"}), next)
+        case upa: UnprefixedAttribute[_] =>
+          upa.copy(next = replacePrevClass(upa.next))(upa)
+        case pa: PrefixedAttribute[_] =>
+          pa.copy(next = replacePrevClass(pa.next))(pa, pa)
+        case Null => UnprefixedAttribute("class", clss, Null)
+      }
+      new Elem(elm.prefix, elm.label, replacePrevClass(elm.attributes1), elm.scope,
+        elm.minimizeEmpty, elm.child:_*)
+    }
+
+    def removeClass(clss: String) = {
+      def rmClass(str: String) = "\\s".r.replaceAllIn(str.replace(clss, ""), " ")
+
+      def replacePrevClass: PartialFunction[MetaData, MetaData] = {
+        case UnprefixedAttribute("class", e: scala.xml.Text, next) =>
+          UnprefixedAttribute("class", rmClass(e.data), next)
+        case UnprefixedAttribute("class", e: String, next) =>
+          UnprefixedAttribute("class", rmClass(e), next)
+        case UnprefixedAttribute("class", e: Rx[String], next) =>
+          UnprefixedAttribute("class", e.map(rmClass), next)
+        //case UnprefixedAttribute("class", e: Rx[scala.xml.Text], next) =>
+        //  UnprefixedAttribute("class", e.map({x => rmClass(x.data)}), next)
+        case upa: UnprefixedAttribute[_] =>
+          upa.copy(next = replacePrevClass(upa.next))(upa)
+        case pa: PrefixedAttribute[_] =>
+          pa.copy(next = replacePrevClass(pa.next))(pa, pa)
+        case Null => UnprefixedAttribute("class", clss, Null)
+      }
+      new Elem(elm.prefix, elm.label, replacePrevClass(elm.attributes1), elm.scope,
+        elm.minimizeEmpty, elm.child:_*)
+    }
+
+    def value(value: Rx[String]) = elm.label.toLowerCase match {
+      case "input" => addAttribute("mhtml-onmount", { e: Event =>
+        val input = e.target.asInstanceOf[HTMLInputElement]
+        value.map(input.value = _)
+        ()
+      })
+      case "select" => addAttribute("mhtml-onmount", { e: Event =>
+        val input = e.target.asInstanceOf[HTMLSelectElement]
+        value.map(input.value = _)
+        ()
+      })
+      case _ => ???
+    }
+
+    import scala.scalajs.js
+    private def inputObserver(value: Var[String])
+      (recs: js.Array[MutationRecord], obs: MutationObserver) = {
+      if (recs.length > 0)
+        value := recs(0).target.asInstanceOf[HTMLInputElement].value
+    }
+    private def selectObserver(value: Var[String])
+      (recs: js.Array[MutationRecord], obs: MutationObserver) = {
+      if (recs.length > 0)
+        value := recs(0).target.asInstanceOf[HTMLSelectElement].value
+    }
+    
+
+    def value() = elm.label.toLowerCase match {
+      case "input" => {
+        val value = Var[String](getAttribute("value").getOrElse(""))
+        val observer = new MutationObserver(inputObserver(value))
+        (addAttribute("oninput", { e: Event =>
+          val input = e.target.asInstanceOf[HTMLInputElement]
+          value := input.value
+          ()
+        }).addAttribute("mhtml-onmount", { input: HTMLInputElement =>
+          value := input.value
+          observer.observe(input, MutationObserverInit(
+            attributes = true,
+            attributeFilter = js.Array("value")
+          ))
+          ()
+        }).addAttribute("mhtml-onunmount", { input: HTMLSelectElement =>
+          observer.disconnect()
+          ()
+        }), value.impure.sharing)
+      }
+      case "select" => {
+        // TODO: Get a more robust default value for select elements
+        val value = Var[String]("")
+        val observer = new MutationObserver(selectObserver(value))
+        (addAttribute("onchange", { e: Event =>
+          val input = e.target.asInstanceOf[HTMLSelectElement]
+          value := input.value
+          ()
+        }).addAttribute("mhtml-onmount", { input: HTMLSelectElement =>
+          observer.observe(input, MutationObserverInit(
+            attributes = true,
+            attributeFilter = js.Array("value"),
+            childList = true,
+            subtree = true
+          ))
+          ()
+        }).addAttribute("mhtml-onunmount", { input: HTMLSelectElement =>
+          observer.disconnect()
+          ()
+        }), value.impure.sharing)
+      }
+      case _ => ???
+    }
+  }
+}

+ 102 - 0
client/src/main/scala/com/weEat/modules/Overlay.scala

@@ -0,0 +1,102 @@
+package com.weEat.modules
+
+import org.scalajs.dom.document
+import org.scalajs.dom.raw.{MouseEvent,Element,HTMLDivElement}
+import scala.concurrent.{Future,ExecutionContext}
+import mhtml.{mount,Rx}
+import scala.xml.Node
+
+case class Overlay(
+  content: Rx[Node],
+  cancelFn: Option[() => Unit] = None
+) {
+  private val backgroundShade = {
+    val div = document.createElement("div").asInstanceOf[HTMLDivElement]
+    div.addEventListener("click", { e: MouseEvent =>
+      if (e.target == div) {
+        cancelFn.map({fn => fn()})
+        selfDestruct()
+      }
+    })
+    div.style.zIndex = "99"
+    div.style.backgroundColor = "rgba(0, 0, 0, 0.5)"
+    div.style.position = "fixed"
+    div.style.top = "0"
+    div.style.left = "0"
+    div.style.right = "0"
+    div.style.bottom = "0"
+    div
+  }
+  
+  val render = {
+    <div class="container w-50" style="margin: auto; padding: 1em; background-color: #ffffff; position: relative; border-radius: 1em; top: 50%; transform: translateY(-50%); max-height: 80%; overflow-y: auto;">
+      {content}
+    </div>
+  }
+
+  def selfDestruct(): Unit = backgroundShade.parentNode.removeChild(backgroundShade)
+
+  document.body.appendChild(backgroundShade)
+  mount(backgroundShade, render)
+}
+
+object Overlay {
+  implicit val ec = scala.concurrent.ExecutionContext.global
+
+  def confirm(
+    content: Rx[Node],
+    cancelFn: Option[() => Unit] = None
+  )(confirmFn: () => Unit) = {
+    val overlayPromise = scala.concurrent.Promise[Overlay]()
+    val overlayFut = overlayPromise.future
+    val wrappedContent = <div>
+      {content}
+      <div style="width: 100%; text-align: right; padding: 0 1em 1em 0;">
+        <button type="button" class="btn btn-light" onclick={ e: MouseEvent =>
+          cancelFn.map({fn => fn()})
+          overlayFut.map(_.selfDestruct())
+          ()
+        }>Cancel</button>
+        <button type="button" class="btn btn-success" onclick={ e: MouseEvent =>
+          confirmFn()
+          overlayFut.map(_.selfDestruct())
+          ()
+        }>Submit</button>
+      </div>
+    </div>
+    overlayPromise.success(Overlay(Rx(wrappedContent), cancelFn))
+  }
+
+  def loading[T](fut: Future[T], cancelFn: Option[() => Unit] = None)
+  (implicit ctx: ExecutionContext) = {
+    val overlay = Overlay(Rx(<span>Loading...</span>), cancelFn)
+    fut.andThen({
+      case _ => overlay.selfDestruct()
+    })
+    overlay
+  }
+
+  def progress[T](
+    content: Rx[Node],
+    cancelFn: Option[() => Unit] = None
+  )(nextFn: () => Unit) = {
+    val overlayPromise = scala.concurrent.Promise[Overlay]()
+    val overlayFut = overlayPromise.future
+    val wrappedContent = <div>
+      {content}
+      <div style="width: 100%; text-align: right; padding: 0 1em 1em 0;">
+        <button type="button" class="btn btn-light" onclick={ e: MouseEvent =>
+          cancelFn.map({fn => fn()})
+          overlayFut.map(_.selfDestruct())
+          ()
+        }>Cancel</button>
+        <button type="button" class="btn btn-light" onclick={ e: MouseEvent =>
+          nextFn()
+          overlayFut.map(_.selfDestruct())
+          ()
+        }>Next</button>
+      </div>
+    </div>
+    overlayPromise.success(Overlay(Rx(wrappedContent), cancelFn))
+  }
+}

+ 59 - 0
client/src/main/scala/com/weEat/modules/PageSelect.scala

@@ -0,0 +1,59 @@
+package com.weEat.modules
+
+import mhtml.{Rx,Var}
+
+case class PageSelect(numPages: Rx[Int], revealedRadius: Rx[Int] = Rx(3)) {
+  private val _page = Var[Int](1)
+
+  // TODO: Add buttons for First/Last page
+
+  // TODO: Allow the user to click on `...` to temporarily expand the upper or
+  // lower reveal boundary
+  private val _minRevealed = _page.zip(revealedRadius).map({
+    case (p, r) => 1.max(p - r)
+  })
+  private val _maxRevealed = _page.zip(revealedRadius).zip(numPages).map({
+    case ((p, r), n) => n.min(p + r)
+  })
+
+  val page = _page.zip(numPages).map({ case (idx, pages) => idx.min(pages) - 1 })
+
+  private def idxToString(targIdx: Int, str: String) =
+    _page.map({page: Int => if (page == targIdx) str else ""})
+
+  val render = {
+    <ul class="pagination">
+      <li class={idxToString(1, " disabled").map("page-item"+_)}>
+        <a class="page-link" href="#" onclick={ () => _page.update(_ - 1)}>
+          <span>{"<"}</span>
+          <span class="sr-only">Previous</span>
+        </a>
+      </li>
+      <li class="page-item disabled" style={_minRevealed.map({ min =>
+        if (min <= 1) "display: none;" else ""
+      })}><a class="page-link" href="#">...</a></li>
+      {
+        _minRevealed.zip(_maxRevealed).map({
+          case (min, max) => for (i <- (min to max)) yield {
+            <li class={idxToString(i, " active").map("page-item"+_)}>
+              <a class="page-link" href="#" onclick={ () => _page.update(_ => i)}>
+                {i.toString}
+              </a>
+            </li>
+          }
+        })
+      }
+      <li class="page-item disabled" style={_maxRevealed.zip(numPages).map({
+        case (max, n) => if (n <= max) "display: none;" else ""
+      })}><a class="page-link" href="#">...</a></li>
+    <li class={numPages.flatMap({targIdx =>
+      _page.map({page: Int => if (page >= targIdx) " disabled" else ""})
+    }).map("page-item"+_)}>
+        <a class="page-link" href="#" onclick={ () => _page.update(_ + 1)}>
+          <span>{">"}</span>
+          <span class="sr-only">Next</span>
+        </a>
+      </li>
+    </ul>
+  }
+}

+ 43 - 0
client/src/main/scala/com/weEat/modules/PaginatedTable.scala

@@ -0,0 +1,43 @@
+package com.weEat.modules
+
+import scala.concurrent.duration._
+import scala.xml.Node
+import mhtml.Rx
+
+object PaginatedTable {
+  def apply[T](
+    structure: Seq[(String, Short, (T) => Rx[Node])],
+    tblData: Rx[Seq[T]],
+    page: Rx[Int] = Rx(0),
+    pageSize: Rx[Int] = Rx(10)
+  ) = {
+    <table class="table table-striped table-hover">
+      <thead>
+        <tr>
+          {
+            for (col <- structure) yield {
+              <th class={s"col-md-${col._2}"}>
+                <span>{col._1}</span>
+              </th>
+            }
+          }
+        </tr>
+      </thead>
+      <tbody>
+        {
+          tblData.zip(page).zip(pageSize).map({ case ((data, idx), size) =>
+            data.slice(idx*size, (idx+1)*size).map({datum =>
+              <tr>
+                {
+                  structure.map({col =>
+                    <td>{col._3(datum)}</td>
+                  })
+                }
+              </tr>
+            })
+          })
+        }
+      </tbody>
+    </table>
+  }
+}

+ 63 - 0
client/src/main/scala/com/weEat/modules/SearchBar.scala

@@ -0,0 +1,63 @@
+package com.weEat.modules
+
+import org.scalajs.dom.raw.Event
+import scala.concurrent.duration._
+import scala.concurrent.{ExecutionContext,Future,Promise}
+import scala.scalajs.js.timers.{clearTimeout, setTimeout}
+import mhtml.Rx
+import mhtml.future.syntax._
+import scala.util.Try
+import cats.implicits._
+import mhtml.implicits.cats._
+import cats.Traverse._
+import scala.xml.{Node,Elem,UnprefixedAttribute}
+import com.weEat.modules._
+import MHtmlHelpers._
+
+case class SearchBar[T](
+  searchFn: (String) => Future[T],
+  minLength: Int = 3,
+  delay: FiniteDuration = 500 milliseconds
+)(implicit val ec: ExecutionContext = ExecutionContext.global) {
+
+  // TODO: Refactor these out into utility classes
+  def setTimeoutResult[T](delay: FiniteDuration)(body: => T) = {
+    val promise = Promise[T]()
+    setTimeout(delay) {
+      promise.success(body)
+    }
+    promise.future
+  }
+  implicit class RxRxFlattener[T](outer: Rx[Rx[T]]) {
+    def flatten = outer.flatMap(identity)
+  }
+
+
+  private val (_render, _inputTerm) =
+    new ElemHelpers(<input type="text" class="form-control input-sm" />).value()
+  val render = _render
+
+  val searchTerm = _inputTerm.map[Option[String]](Some(_))
+    .keepIf(_.map(_.length >= minLength).getOrElse(false))(None)
+    .dropRepeats.impure.sharing
+
+  // Nested futures give us a hellish type web to unweave
+  val result: Rx[Option[Try[T]]] = searchTerm.flatMap({ term =>
+    term.map({ termAtStart =>
+      setTimeoutResult(delay) {
+        searchTerm.map({ termAfterDelay =>
+          if (Some(termAtStart) == termAfterDelay)
+            searchFn(termAtStart).toRx
+          else
+            Rx(None)
+        }).flatten
+      }.toRx.map(
+        _.map(
+          _.sequence[Rx, Option[Try[T]]].map(
+            _.flatSequence[Option, T]
+          )
+        )
+      ).map(_.flatSequence[Rx, Try[T]]).flatten
+    }).flatSequence[Rx, Try[T]]
+  }).impure.sharing
+}

+ 24 - 0
client/src/main/scala/com/weEat/modules/Select.scala

@@ -0,0 +1,24 @@
+package com.weEat.modules
+
+import org.scalajs.dom.raw.Event
+import mhtml.Rx
+import MHtmlHelpers._
+
+case class Select[T](values: Rx[Seq[T]], defaul: Option[T] = None) {
+  private val (_select, _value) =
+    new ElemHelpers(<select class="form-control input-sm">
+      {
+        values.map(_.zipWithIndex).map({ _.map({ case (value, idx) =>
+          <option selected={Some(value) == defaul} value={idx.toString}>
+            {value.toString}
+          </option>
+        }) })
+      }
+    </select>).value()
+
+  val value = _value.zip(values).map({ case (idx, v) =>
+    v(idx.toIntOption.getOrElse(0).max(0))
+  })
+
+  val render = <div class="form-group form-group-sm">{_select}</div>
+}

+ 270 - 0
client/src/main/scala/com/weEat/modules/USDAEditor.scala

@@ -0,0 +1,270 @@
+package com.weEat.modules
+
+import com.weEat.controllers.USDAController
+import com.weEat.shared.models.UnitType
+import com.weEat.shared.models.UnitType._
+import com.weEat.models.Nutrient
+import scala.util.{Try,Success,Failure}
+import com.weEat.shared.models.IdentifierHelper._
+import com.weEat.shared.models.USDANode
+import gov.usda.nal.fdc.models.{SearchResultFood,FullFoodItem,FoodPortion}
+import com.weEat.shared.models.{Count,MeasureUnit,USDANode}
+import mhtml.Rx
+import mhtml.future.syntax._
+import MHtmlHelpers._
+
+case class USDAEditor(usda: USDANode, defaultOverNone: Boolean) {
+  implicit val ctx = scala.concurrent.ExecutionContext.global
+
+  private val _mapPortionMeasure =
+    USDAController.getFood(usda.fdcId)().transform({
+      case Success(full: FullFoodItem) => Success(full.foodPortions.map({ port =>
+        val str = portionStr(port)
+        (port, portionToMeasure(port, str), str)
+      }
+      ))
+      case Success(_) => Success(Nil)
+      case Failure(e) => {
+        Console.err.println(e)
+        Failure(e)
+      }
+    }).toRx
+
+  private val _fdcId = usda.fdcId
+
+  private val (_nameIn, _name) =
+    <input type="text" style="text-align: center;" class="form-control"
+           value={usda.name} />.value()
+
+  private val (_calorieIn, _calorie) =
+    floatInput("calories", Some(usda.calories)).value()
+
+  val noneValue = "none"
+  val customValue = "custom"
+
+  val defaultVolPort = _mapPortionMeasure.map({
+    case Some(Success(seq)) => getDefaultPortion(seq, VOLUME, usda.density)
+    case _ => None
+  })
+  private val (_volumeIn, _volume) = <select class="custom-select">
+    <option selected={defaultVolPort.map(_ == None && usda.density == None)}
+            value={noneValue}>None</option>
+    <option selected={defaultVolPort.map(_ == None && usda.density != None)}
+            value={customValue}>Custom</option>
+    {
+      _mapPortionMeasure.map({
+        case Some(Success(seq)) => seq.filter(_._2.typ == VOLUME)
+        case _ => Nil
+      }).map(_.map({ case (p, m, str) =>
+        <option selected={defaultVolPort.map(_ == Some(p))}
+                value={calcConversion(p, m).toString}>{str}</option>
+      }))
+    }
+  </select>.value()
+
+  val defaultNumPort = _mapPortionMeasure.map({
+    case Some(Success(seq)) => getDefaultPortion(seq, NUMBER, usda.massPerUnit)
+    case _ => None
+  })
+  private val (_countIn, _count) = <select class="custom-select">
+    <option selected={defaultNumPort.map(_ == None && usda.massPerUnit == None)}
+            value={noneValue}>None</option>
+    <option selected={defaultNumPort.map(_ == None && usda.massPerUnit != None)}
+            value={customValue}>Custom</option>
+    {
+      _mapPortionMeasure.map({
+        case Some(Success(seq)) => seq.filter(_._2.typ == NUMBER)
+        case _ => Nil
+      }).map(_.map({ case (p, m, str) =>
+        <option selected={defaultNumPort.map(_ == Some(p))}
+                value={calcConversion(p, m).toString}>{str}</option>
+      }))
+    }
+  </select>.value()
+  //   Some((getIndexes(NUMBER), indexToNode))
+
+  private val (_densityIn, _density) =
+    <input class="form-control" type="number" step="any" min="0"
+           disabled={_volume.map(_ != customValue)}
+           value={_volume.map(_.toFloatOption)
+             .keepIf(_.nonEmpty)(Some(0))
+             .map(_.get.toString)} />.value()
+
+  private val (_massIn, _mass) =
+    <input class="form-control" type="number" step="any" min="0"
+           disabled={_count.map(_ != customValue)}
+           value={_count.map(_.toFloatOption)
+             .keepIf(_.nonEmpty)(Some(0))
+             .map(_.get.toString)} />.value()
+
+  private val (_unitTypeIn, _typ) = <select id="unit" class="form-control">
+  {
+    for (unitType <- UnitType.values.toSeq)
+    yield {<option value={unitType.toString}>{unitType.toString}</option>}
+  }
+  </select>.value()
+
+  private val _nutrientInputs = usda.nutrients.map({case (k, v) =>
+    (k, floatInput(k, Some(v)).value())
+  })
+
+  def floatInput(id: String, value: Option[Float]) = <input id={id}
+    class="form-control"
+    type="number"
+    step="any"
+    min="0"
+    value={value.getOrElse(0.0).toString} />
+
+  def getUSDANode(id: Option[Identifier]) = USDANode(
+    id,
+    _name.value,
+    _fdcId,
+    _volume.flatMap({
+      case `noneValue` => Rx(None)
+      case `customValue` => _density.map(str => Some(str.toFloat))
+      case value => Rx(Some(value.toFloat))
+    }).value,
+    _count.flatMap({
+      case `noneValue` => Rx(None)
+      case `customValue` => _mass.map(str => Some(str.toFloat))
+      case value => Rx(Some(value.toFloat))
+    }).value,
+    _typ.map(UnitType.withName).value,
+    _calorie.value.toFloat,
+    _nutrientInputs.map({case (k, v) => (k, v._2.value.toFloat)})
+  )
+
+  val undefinedMeasureUnitId = 9999
+
+  def portionStr(fp: FoodPortion) =
+    fp.portionDescription.getOrElse(fp.modifier.getOrElse("Unknown"))
+
+  def portionToMeasure(fp: FoodPortion, str: String): MeasureUnit =
+    if (fp.measureUnit.id != undefinedMeasureUnitId) Count
+    else MeasureUnit.guessUnit(str).getOrElse(Count)
+
+  val measureMatchThreshold = 0.01
+
+  /* Procedure to determine default:
+   * 1. Check if there is some value already:
+   * | yes:
+   * |- 2. Find the measure which most closely results in that value
+   * |  3. If closest match is not within the threshold of the value, use custom
+   * | no:
+   * |- 2. if defaultOverNone:
+   * |  | yes:
+   * |  |- 3. Use (portion, measure) which has the best matching between the two
+   * |  | no:
+   * |  |- 3. Use none
+   */
+  def getDefaultPortion(
+    portXmeas: Seq[(FoodPortion, MeasureUnit, String)],
+    typ: UnitType,
+    value: Option[Float]
+  ): Option[FoodPortion] = (value match {
+    case Some(dV) => portXmeas.filter(_._2.typ == typ).minByOption({
+      case (portion, measure, _) => {
+        val diff = (calcConversion(portion, measure) - dV).abs
+        if (diff < measureMatchThreshold) Some(diff)
+        else None
+      }
+    })
+    case None => if (defaultOverNone) MeasureUnit.closestPair(
+      portXmeas.filter(_._2.typ == typ)
+    )
+    else None
+  }).map(_._1)
+
+  def calcConversion(fp: FoodPortion, meas: MeasureUnit) =
+    fp.gramWeight/meas.conversionRatio
+
+  val render = {
+    <div class="form-group">
+      <div class="container">
+        <div class="row">
+          <div class="col-12">{ _nameIn }</div>
+        </div>
+        <div class="row">
+          <div class="col-6">
+            <label for="fdcid">USDA Id:</label>
+            <input id="fdcid"
+                   class="form-control"
+                   type="number"
+                   value={_fdcId.toString}
+                   readonly="readonly" />
+          </div>
+          <div class="col-6">
+            <label for="calories">{"Calories/100g:"}</label>
+            { _calorieIn }
+          </div>
+        </div>
+        <div class="row">
+          <div class="col-6">
+            <label for="density">Volume Unit:</label>
+           { _volumeIn }
+          </div>
+          <div class="col-6">
+           <label for="weight">Count Unit:</label>
+           { _countIn }
+          </div>
+        </div>
+        <div class="row">
+          <div class="col-6">
+            <label for="density">Density (g/ml):</label>
+           { _densityIn }
+          </div>
+          <div class="col-6">
+           <label for="weight">Mass (g/unit):</label>
+           { _massIn }
+          </div>
+        </div>
+        <div class="row">
+          <div class="col-6">
+            <label for="unit">Unit:</label>
+            { _unitTypeIn }
+          </div>
+        </div>
+      </div>
+      <div class="panel-group" style="margin: 1em;">
+        <div class="panel panel-default">
+          <div class="panel-heading">
+            <div class="panel-title">
+              <a href="#detailsDiv" data-toggle="collapse">Details</a>
+            </div>
+          </div>
+        </div>
+        <div id="detailsDiv" class="panel-collapse collapse">
+          <div class="panel-body">
+            <div class="container" style="width: 100%;">
+              <div class="row">
+                {
+                  (for ((id, (input, _)) <- _nutrientInputs)
+                  yield {
+                    <div class="col-md-6">
+                      <label for={id}>
+                        {Nutrient.nutrients.map({
+                          case Some(Success(nutrs)) => nutrs
+                              .find(x => x.number == id)
+                              .map(x => s"${x.name} (${x.unitName})")
+                              .getOrElse({
+                                id.toString
+                              })
+                          case Some(Failure(err)) => {
+                            Console.err.println(err)
+                            id.toString
+                          }
+                          case None => id.toString
+                        })}:
+                      </label>
+                      { input }
+                    </div>
+                  }).toSeq
+                }
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  }
+}

+ 86 - 0
client/src/main/scala/com/weEat/modules/UsdaImporter.scala

@@ -0,0 +1,86 @@
+package com.weEat.modules
+
+import scala.scalajs.js.annotation._
+import gov.usda.nal.fdc.models._
+import gov.usda.nal.fdc.models.DataType._
+import com.weEat.controllers.{FoodController,USDAController}
+import org.scalajs.dom.raw.Event
+import org.scalajs.dom.html.Element
+import com.weEat.shared.models.UnitType._
+import com.weEat.shared.models.{Count,MeasureUnit,USDANode}
+import scala.util.Success
+import mhtml.{mount,Rx}
+import mhtml.future.syntax._
+import MHtmlHelpers._
+
+@JSExportTopLevel("UsdaImporter")
+object UsdaImporter {
+  import com.weEat.Main.headers
+
+  implicit val ctx = scala.concurrent.ExecutionContext.global
+
+  def usdaImporterPane() = {
+    val searchBar = SearchBar(term => USDAController.getFoodsSearch(term, Seq(
+      Foundation, Survey, SRLegacy
+    ).map(_.toString))().map({
+      // TODO: Save on network calls by waiting until we're near the end of each
+      // page before loading the next one.
+      // Represent this as a Seq[Rx[(Boolean, Criteria)]] where each node depends
+      // on the one before it in the sequence.
+      case SearchResult(criteria, n, cur, tot, baseList) => (cur + 1 to tot).map({
+        c => USDAController.postFoodsSearch()(criteria.copy(pageNumber = Some(c)))
+          .map(_.foods).toRx.map({
+            case Some(Success(l)) => l
+            case _ => Nil
+          })
+      }).foldLeft(Rx(baseList))({ (soFar, cur) => soFar.zip(cur).map({
+        case (soFarList, curList) => soFarList :++ curList
+      }) })
+    }))
+    val searchResults = searchBar.result.flatMap({
+      case Some(Success(inner)) => inner
+      case _ => Rx(Nil)
+    })
+    val pageSizeSel = Select(Rx((10 to 40 by 10)))
+    val numPages = pageSizeSel.value.zip(searchResults).map({
+      case (size, results) => (results.length + size - 1) / size
+    })
+    val pageSel = PageSelect(numPages)
+
+    <div>
+    <h2>USDA Importer</h2>
+    <div class="form-group">
+      <label for="search">Search: </label>
+      { searchBar.render }
+    </div>
+    {
+      PaginatedTable[SearchResultFood](Seq(
+        ("", 1, {x => Rx(<button class="btn btn-light" onclick={e: Event =>
+          // defaultUnit = if (num().nonEmpty) NUMBER else MASS\
+          val editor = USDAEditor(USDANode.fromSearchResult(x), true)
+          Overlay.confirm(Rx(editor.render)) { () =>
+            import com.weEat.Main.headers
+            println(editor.getUSDANode(None))
+            FoodController.add()(editor.getUSDANode(None))
+          }
+          ()
+        }>Add</button>)}),
+        ("ID", 1, {x => Rx(<span>{x.fdcId.toString}</span>)}),
+        ("Name", 3, {x => Rx(<span>{x.description}</span>)}),
+        ("Desc", 4, {x => Rx(<span>
+            {x.additionalDescriptions.getOrElse("")}
+          </span>)}),
+        ("Brand", 3, {x => Rx(<span>{x.brandOwner.getOrElse("")}</span>)})
+      ),
+        searchResults,
+        pageSel.page,
+        pageSizeSel.value)
+    }
+    { new ElemHelpers(pageSel.render).addClass("col-3 float-left") }
+    { new ElemHelpers(pageSizeSel.render).addClass("col-1 float-right") }
+    </div>
+  }
+
+  @JSExport
+  def render(parent: Element) = mount(parent, usdaImporterPane())
+}

+ 31 - 0
fdc/js/src/main/scala/gov/usda/nal/fdc/DateHelper.scala

@@ -0,0 +1,31 @@
+package gov.usda.nal.fdc
+
+import play.api.libs.json._
+
+object DateHelper {
+  type Date = scala.scalajs.js.Date
+
+  implicit val dateReads = new Reads[Date] {
+    def reads(json: JsValue): JsResult[Date] =
+      json.validate[String].flatMap({ str: String =>
+        str.split("/") match {
+          case Array(month, day, year) => JsSuccess(new Date(
+            Integer.parseInt(year, 10),
+            Integer.parseInt(month, 10),
+            Integer.parseInt(day, 10)
+          ))
+          case _ => JsError(Seq(
+            (JsPath(),
+              Seq(new JsonValidationError(Seq(s"'$str' is not a valid date.")))
+            )
+          ))
+        }
+      })
+  }
+
+  implicit val dateWrites = new Writes[Date] {
+    def writes(d: Date): JsValue = JsString(
+      s"${d.getMonth.toInt + 1}/${d.getDate.toInt}/${d.getFullYear.toInt}"
+    )
+  }
+}

+ 8 - 0
fdc/jvm/src/main/scala/gov/usda/nal/fdc/DateHelper.scala

@@ -0,0 +1,8 @@
+package gov.usda.nal.fdc
+
+object DateHelper {
+  type Date = java.util.Date
+
+  implicit val dateReads = play.api.libs.json.Reads.dateReads("MM/dd/YYYY")
+  implicit val dateWrites = play.api.libs.json.Writes.dateWrites("MM/dd/YYYY")
+}

+ 260 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/controllers/FoodController.scala

@@ -0,0 +1,260 @@
+package gov.usda.nal.fdc.controllers
+
+import play.api.libs.json.Json
+import com.tflucke.webroutes.{APIRoute,APIRouteBody,Http}
+import gov.usda.nal.fdc.models._
+import gov.usda.nal.fdc.models.DataType.DataType
+import gov.usda.nal.fdc.models.SortCriteria.SortCriteria
+import gov.usda.nal.fdc.models.SortOrder.SortOrder
+import gov.usda.nal.fdc.models.FoodsCriteria.Format._
+
+class FoodController(apiKey: String) {
+  private val server = "https://api.nal.usda.gov/fdc"
+
+  /** Fetches details for one food item by FDC ID
+    * 
+    * Retrieves a single food item by an FDC ID. Optional format and nutrients
+    * can be specified.
+    * 
+    * @param nutrients List of up to 25 nutrient numbers. Only the nutrient
+    * information for the specified nutrients will be returned. If a food does not
+    * have any matching nutrients, the food will be returned with an empty
+    * foodNutrients element.
+    * 
+    * @return One food result.
+    */
+  def getFood(
+    fdcId: Long,
+    format: Format = Full,
+    nutrients: Seq[Short] = Nil
+  ) = new APIRoute[FoodItem](
+    "GET",
+    s"$server/v1/food/$fdcId?format=$format&api_key=$apiKey" + (
+      if (nutrients.length > 25) throw new IllegalArgumentException(
+        "nutrients must contain at most 25 elements."
+      )
+      else if (nutrients.nonEmpty) "&nutrients=" + nutrients.mkString(",")
+      else ""
+    ),
+    "application/json"
+  ) {
+    def convert(xhr: Http.Response) = Json.using[Json.WithDefaultValues].parse(xhr.responseText).as[FoodItem]
+    def acceptHeader = "application/json"
+  }
+
+  /** Fetches details for multiple food items using input FDC IDs
+    * 
+    * Retrieves a list of food items by a list of up to 20 FDC IDs. Optional
+    * format and nutrients can be specified. Invalid FDC ID's or ones that are
+    * not found are omitted and an empty set is returned if there are no matches.
+    * 
+    * @param List of multiple FDC ID's.
+    * 
+    * @param nutrients List of up to 25 nutrient numbers. Only the nutrient
+    * information for the specified nutrients will be returned. If a food does not
+    * have any matching nutrients, the food will be returned with an empty
+    * foodNutrients element.
+    * 
+    * @return List of Food details matching specified FDC ID's. Invalid FDC ID's or
+    * ones that are not found are omitted.
+    */
+  def getFoods(
+    fdcIds: Seq[String],
+    format: Format = Full,
+    nutrients: Seq[Short] = Nil
+  ) = new APIRoute[Seq[FoodItem]](
+    "GET",
+    s"$server/v1/foods?format=$format&api_key=$apiKey" + (
+      if (fdcIds.length > 20) throw new IllegalArgumentException(
+        "fdcIds must contain at most 20 elements."
+      )
+      else if (fdcIds.isEmpty) throw new IllegalArgumentException(
+        "fdcIds must contain at least 1 element."
+      )
+      else "&fdcIds=" + fdcIds.mkString(",")
+    ) + (
+      if (nutrients.length > 25) throw new IllegalArgumentException(
+        "nutrients must contain at most 25 elements."
+      )
+      else if (nutrients.nonEmpty) "&nutrients=" + nutrients.mkString(",")
+      else ""
+    ),
+    "application/json"
+  ) {
+    def convert(xhr: Http.Response) =
+      Json.using[Json.WithDefaultValues].parse(xhr.responseText).as[Seq[FoodItem]]
+    def acceptHeader = "application/json"
+  }
+
+  /** Fetches details for multiple food items using input FDC IDs
+    * 
+    * Retrieves a list of food items by a list of up to 20 FDC IDs. Optional
+    * format and nutrients can be specified. Invalid FDC ID's or ones that are
+    * not found are omitted and an empty set is returned if there are no matches.
+    * 
+    * @return List of Food details matching specified FDC ID's. Invalid FDC ID's or
+    * ones that are not found are omitted.
+    */
+  def postFoods = new APIRouteBody[FoodsCriteria, Seq[FoodItem]](
+    "POST",
+    s"$server/v1/foods?api_key=$apiKey",
+    "application/json"
+  ) {
+    def convertBody(body: FoodsCriteria) = Json.stringify(Json.toJson(body))
+    def convertResult(xhr: Http.Response) =
+      Json.using[Json.WithDefaultValues].parse(xhr.responseText).as[Seq[FoodItem]]
+    def contentTypeHeader = "application/json"
+    def acceptHeader = "application/json"
+  }
+
+  /** Returns a paged list of foods, in the 'abridged' format
+    * 
+    * Retrieves a paged list of foods. Use the pageNumber parameter to page 
+    * through the entire result set.
+    * 
+    * @param dataType Filter on a specific data type; specify one or more values
+    * in an array.
+    * 
+    * @param pageSize Maximum number of results to return for the current page.
+    * Default is 50.
+    * 
+    * @param pageNumber Page number to retrieve. The offset into the overall
+    * result set is expressed as (pageNumber * pageSize)
+    * 
+    * @param sortOrder Specify one of the possible values to sort by that field.
+    * Note, dataType.keyword will be dataType and lowercaseDescription.keyword will
+    * be description in future releases.
+    * 
+    * @return List of foods for the requested page
+    */
+  def getFoodsList(
+    dataType: Seq[DataType] = Nil,
+    pageSize: Option[Short] = None,
+    pageNumber: Option[Long] = None,
+    sortBy: Option[SortCriteria] = None,
+    sortOrder: Option[SortOrder] = None
+  ) = new APIRoute[Seq[AbridgedFoodItem]](
+    "GET",
+    s"$server/v1/foods/list?&api_key=$apiKey" + (
+      if (dataType.length > 4) throw new IllegalArgumentException(
+        "dataType must contain at most 4 elements."
+      )
+      else if (dataType.nonEmpty) "&dataType=" + dataType.mkString(",")
+      else ""
+    ) + (pageSize match {
+      case Some(size) if size > 200 => throw new IllegalArgumentException(
+        "pageSize must be less than or equal to 200."
+      )
+      case Some(size) if size < 1 => throw new IllegalArgumentException(
+        "pageSize must be greater than or equal to 1."
+      )
+      case Some(size) => s"&pageSize=$size"
+      case None => ""
+    }) + pageNumber.map(x => s"&pageNumber=$x").getOrElse("") +
+      sortBy.map(x => s"&sortBy=$x").getOrElse("") +
+      sortOrder.map(x => s"&sortOrder=$x").getOrElse(""),
+    "application/json"
+  ) {
+    def convert(xhr: Http.Response) =
+      Json.using[Json.WithDefaultValues].parse(xhr.responseText).as[Seq[AbridgedFoodItem]]
+    def acceptHeader = "application/json"
+  }
+
+  /** Returns a paged list of foods, in the 'abridged' format
+    * 
+    * Retrieves a paged list of foods. Use the pageNumber parameter to page 
+    * through the entire result set.
+    * 
+    * @return List of foods for the requested page
+    */
+  def postFoodsList() = new APIRouteBody[FoodListCriteria, Seq[AbridgedFoodItem]](
+    "POST",
+    s"$server/v1/foods/list?&api_key=$apiKey",
+    "application/json"
+  ) {
+    def convertBody(body: FoodListCriteria) = Json.stringify(Json.toJson(body))
+    def convertResult(xhr: Http.Response) =
+      Json.parse(xhr.responseText).as[Seq[AbridgedFoodItem]]
+    def contentTypeHeader = "application/json"
+    def acceptHeader = "application/json"
+  }
+
+  /** Returns a list of foods that matched search (query) keywords
+    * 
+    * Search for foods using keywords. Results can be filtered by dataType and
+    * there are options for result page sizes or sorting. 
+    * 
+    * @param query One or more search terms.  The string may include
+    * [search operators](https://fdc.nal.usda.gov/help.html#bkmk-2)
+    * 
+    * @param dataType Filter on a specific data type; specify one or more values
+    * in an array.
+    * 
+    * @param pageSize Maximum number of results to return for the current page.
+    * Default is 50.
+    * 
+    * @param pageNumber Page number to retrieve. The offset into the overall
+    * result set is expressed as (pageNumber * pageSize)
+    * 
+    * @param sortOrder Specify one of the possible values to sort by that field.
+    * Note, dataType.keyword will be dataType and lowercaseDescription.keyword will
+    * be description in future releases.
+    * 
+    * @return List of foods that matched search terms
+    */
+  def getFoodsSearch(
+    query: String,
+    dataType: Seq[DataType] = Nil,
+    pageSize: Option[Short] = None,
+    pageNumber: Option[Long] = None,
+    sortBy: Option[SortCriteria] = None,
+    sortOrder: Option[SortOrder] = None
+  ) = new APIRoute[SearchResult](
+    "GET",
+    s"$server/v1/foods/search?api_key=$apiKey&query=$query" + (
+      if (dataType.length > 4) throw new IllegalArgumentException(
+        "dataType must contain at most 4 elements."
+      )
+      else if (dataType.nonEmpty) "&dataType=" + dataType.mkString(",")
+      else ""
+    ) + (pageSize match {
+      case Some(size) if size > 200 => throw new IllegalArgumentException(
+        "pageSize must be less than or equal to 200."
+      )
+      case Some(size) if size < 1 => throw new IllegalArgumentException(
+        "pageSize must be greater than or equal to 1."
+      )
+      case Some(size) => s"&pageSize=$size"
+      case None => ""
+    }) + pageNumber.map(x => s"&pageNumber=$x").getOrElse("") +
+      sortBy.map(x => s"&sortBy=$x").getOrElse("") +
+      sortOrder.map(x => s"&sortOrder=$x").getOrElse(""),
+    "application/json"
+  ) {
+    def convert(xhr: Http.Response) =
+      Json.using[Json.WithDefaultValues].parse(xhr.responseText).as[SearchResult]
+    def acceptHeader = "application/json"
+  }
+
+  /** Returns a list of foods that matched search (query) keywords
+    * 
+    * Search for foods using keywords. Results can be filtered by dataType and
+    * there are options for result page sizes or sorting.
+    * 
+    * @param query The query string may also include standard
+    * [search operators](https://fdc.nal.usda.gov/help.html#bkmk-2) 
+    * 
+    * @return List of foods that matched search terms
+    */
+  def postFoodsSearch() = new APIRouteBody[FoodSearchCriteria, SearchResult](
+    "POST",
+    s"$server/v1/foods/search?api_key=$apiKey",
+    "application/json"
+  ) {
+    def convertBody(body: FoodSearchCriteria) = Json.stringify(Json.toJson(body))
+    def convertResult(xhr: Http.Response) =
+      Json.using[Json.WithDefaultValues].parse(xhr.responseText).as[SearchResult]
+    def contentTypeHeader = "application/json"
+    def acceptHeader = "application/json"
+  }
+}

+ 15 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/AbridgedFoodNutrient.scala

@@ -0,0 +1,15 @@
+package gov.usda.nal.fdc.models
+
+case class AbridgedFoodNutrient(
+  val nutrientId: Short,
+  val nutrientNumber: String,
+  val unitName: String,
+  val derivationDescription: Option[String] = None,
+  val name: Option[String] = None,
+  val value: Float = 0.0f,
+  val derivationCode: Option[String] = None
+)
+
+object AbridgedFoodNutrient {
+  implicit val fmt = play.api.libs.json.Json.format[AbridgedFoodNutrient]
+}

+ 30 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/DataType.scala

@@ -0,0 +1,30 @@
+package gov.usda.nal.fdc.models
+
+object DataType extends Enumeration {
+  type DataType = Value
+
+  val Branded = Value
+  val Foundation = Value
+  val Survey = Value("Survey (FNDDS)")
+  val SRLegacy = Value("SR Legacy")
+
+  import play.api.libs.json.{Reads,Writes}
+
+  implicit val rds = Reads.enumNameReads(DataType)
+  implicit val wts = Writes.enumNameWrites[this.type]
+}
+
+object FullDataType extends Enumeration {
+  type FullDataType = Value
+
+  val Branded = Value
+  val Foundation = Value
+  val Survey = Value("Survey (FNDDS)")
+  val SRLegacy = Value("SR Legacy")
+  val Sample = Value
+
+  import play.api.libs.json.{Reads,Writes}
+
+  implicit val rds = Reads.enumNameReads(FullDataType)
+  implicit val wts = Writes.enumNameWrites[this.type]
+}

+ 19 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodAttribute.scala

@@ -0,0 +1,19 @@
+package gov.usda.nal.fdc.models
+
+case class FoodAttribute (
+  val id: Long,
+  val sequenceNumber: Option[Long],
+  val value: String,
+  val foodAttributeType: FoodAttribute.FoodAttributeType
+)
+
+object FoodAttribute {
+  case class FoodAttributeType (
+    val id: Long,
+    val name: String,
+    val description: String
+  )
+
+  implicit val typeFmt = play.api.libs.json.Json.format[FoodAttributeType]
+  implicit val fmt = play.api.libs.json.Json.format[FoodAttribute]
+}

+ 11 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodCategory.scala

@@ -0,0 +1,11 @@
+package gov.usda.nal.fdc.models
+
+case class FoodCategory(
+  val id: Long,
+  val code: String,
+  val description: String
+)
+
+object FoodCategory {
+  implicit val fmt = play.api.libs.json.Json.format[FoodCategory]
+}

+ 15 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodComponent.scala

@@ -0,0 +1,15 @@
+package gov.usda.nal.fdc.models
+
+case class FoodComponent(
+  val id: Int,
+  val name: String,
+  val dataPoints: Int,
+  val gramWeight: Double,
+  val isRefuse: Boolean,
+  val minYearAcquired: Short,
+  val percentWeight: Double
+)
+
+object FoodComponent {
+  implicit val fmt = play.api.libs.json.Json.format[FoodComponent]
+}

+ 164 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodItem.scala

@@ -0,0 +1,164 @@
+package gov.usda.nal.fdc.models
+
+import gov.usda.nal.fdc.models.FullDataType._
+import play.api.libs.json.Json
+import gov.usda.nal.fdc.DateHelper._
+
+sealed trait FoodItem {
+  def fdcId: Long
+  def dataType: FullDataType
+  def description: String
+  def publicationDate: Option[Date]
+}
+
+case class AbridgedFoodItem(
+  val fdcId: Long,
+  val dataType: FullDataType,
+  val description: String,
+  val foodNutrients: Seq[AbridgedFoodNutrient] = Nil,
+  val publicationDate: Option[Date],
+  // Branded
+  val brandOwner: Option[String],
+  val gtinUpc: Option[String],
+  // Foundation + SRLegacy
+  val ndbNumber: Option[Long],
+  // Survey
+  val foodCode: Option[String]
+) extends FoodItem
+
+object AbridgedFoodItem {
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[AbridgedFoodItem]
+}
+
+case class BrandedFoodItem(
+  val fdcId: Long,
+  val dataType: FullDataType,
+  val description: String,
+  val labelNutrients: LabelNutrients = LabelNutrients(),
+  val availableDate: Option[Date],
+  val dataSource: Option[String],
+  val foodClass: Option[String],
+  val householdServingFullText: Option[String],
+  val ingredients: Option[String],
+  val modifiedDate: Option[Date],
+  val publicationDate: Option[Date],
+  val servingSize: Option[Float],
+  val servingSizeUnit: Option[String],
+  val brandedFoodCategory: Option[String],
+  val foodNutrients: Seq[FoodNutrient] = Nil,
+  val foodUpdateLog: Seq[FoodUpdateLog] = Nil,
+  val brandOwner: Option[String],
+  val gtinUpc: Option[String]
+) extends FoodItem
+
+object BrandedFoodItem {
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[BrandedFoodItem]
+}
+
+sealed trait FullFoodItem extends FoodItem {
+  def foodClass: String
+  def foodPortions: Seq[FoodPortion]
+  def inputFoods: Seq[InputFood]
+}
+
+case class FoundationFoodItem(
+  val fdcId: Long,
+  val dataType: FullDataType,
+  val description: String,
+  val isHistoricalReference: Boolean = false,
+  val foodClass: String,
+  val foodNote: Option[String],
+  val ndbNumber: Option[Long],
+  val publicationDate: Option[Date],
+  val scientificName: Option[String],
+  val foodCatagory: Option[FoodCategory],
+  val foodComponents: Seq[FoodComponent] = Nil,
+  val labelNutrients: LabelNutrients = LabelNutrients(),
+  val foodPortions: Seq[FoodPortion] = Nil,
+  val inputFoods: Seq[InputFoodFoundation] = Nil,
+  val nutrientConversionFactors: Seq[NutrientConversionFactor] = Nil
+) extends FullFoodItem
+
+object FoundationFoodItem {
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[FoundationFoodItem]
+}
+
+case class SampleFoodItem(
+  val fdcId: Long,
+  val dataType: FullDataType,
+  val description: String,
+  val foodClass: String,
+  val publicationDate: Option[Date],
+  val foodAttributes: Seq[FoodAttribute] = Nil
+) extends FoodItem
+
+object SampleFoodItem {
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[SampleFoodItem]
+}
+
+case class SRLegacyFoodItem(
+  val fdcId: Long,
+  val dataType: FullDataType,
+  val description: String,
+  val foodClass: String,
+  val isHistoricalReference: Boolean = false,
+  val ndbNumber: Option[Long],
+  val publicationDate: Option[Date],
+  val scientificName: Option[String],
+  val foodCategory: Option[FoodCategory],
+  val foodNutrients: Seq[FoodNutrient] = Nil,
+  val foodComponents: Seq[FoodComponent] = Nil,
+  val foodAttributes: Seq[FoodAttribute] = Nil,
+  val inputFoods: Seq[InputFoodSurvey] = Nil,
+  val foodPortions: Seq[FoodPortion] = Nil,
+  val nutrientConversionFactors: Seq[NutrientConversionFactor] = Nil
+) extends FullFoodItem
+
+object SRLegacyFoodItem {
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[SRLegacyFoodItem]
+}
+
+case class SurveyFoodItem(
+  val fdcId: Long,
+  val dataType: FullDataType,
+  val description: String,
+  val startDate: Option[Date],
+  val endDate: Option[Date],
+  val foodClass: String,
+  val foodCode: Option[String],
+  val publicationDate: Option[Date],
+  val foodAttributes: Seq[FoodAttribute] = Nil,
+  val foodPortions: Seq[FoodPortion] = Nil,
+  val inputFoods: Seq[InputFoodSurvey] = Nil,
+  val wweiaFoodCategory: Option[WweiaFoodCategory]
+) extends FullFoodItem
+
+object SurveyFoodItem {
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[SurveyFoodItem]
+}
+
+object FoodItem {
+  import play.api.libs.functional.syntax._
+  import play.api.libs.json.{Json,Reads,Writes}
+  import java.util.Calendar
+  import java.text.ParseException
+
+  implicit val requestWrites = Writes[FoodItem]({
+    case item: AbridgedFoodItem => Json.toJson(item)
+    case item: BrandedFoodItem => Json.toJson(item)
+    case item: FoundationFoodItem => Json.toJson(item)
+    case item: SRLegacyFoodItem => Json.toJson(item)
+    case item: SurveyFoodItem => Json.toJson(item)
+    case item: SampleFoodItem => Json.toJson(item)
+  })
+
+  implicit val requestReads = Reads[FoodItem](json =>
+    ((json \ "dataType").as[FullDataType] match {
+      case Branded => Json.fromJson[BrandedFoodItem](json)
+      case Foundation => Json.fromJson[FoundationFoodItem](json)
+      case Survey => Json.fromJson[SurveyFoodItem](json)
+      case SRLegacy => Json.fromJson[SRLegacyFoodItem](json)
+      case Sample => Json.fromJson[SampleFoodItem](json)
+    })
+  )
+}

+ 25 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodListCriteria.scala

@@ -0,0 +1,25 @@
+package gov.usda.nal.fdc.models
+
+import gov.usda.nal.fdc.models.DataType.DataType
+import gov.usda.nal.fdc.models.SortCriteria.SortCriteria
+import gov.usda.nal.fdc.models.SortOrder.SortOrder
+
+case class FoodListCriteria(
+  val dataType: Seq[DataType],
+  val pageSize: Option[Short],
+  val pageNumber: Option[Long],
+  val sortBy: Option[SortCriteria],
+  val sortOrder: Option[SortOrder]
+) {
+  if (dataType.length > 4) {
+    // TODO: Error
+  }
+  pageSize.map({
+    case size if size < 1 => // TODO: Error
+    case size if size > 100 => // TODO: Error
+  })
+}
+
+object FoodListCriteria {
+  implicit val fmt = play.api.libs.json.Json.format[FoodListCriteria]
+}

+ 21 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodNutrient.scala

@@ -0,0 +1,21 @@
+package gov.usda.nal.fdc.models
+
+case class FoodNutrient(
+  val id: Option[Int],
+  val amount: Option[Float],
+  val dataPoints: Option[Int],
+  val `type`: String,
+  val nutrient: Nutrient,
+  val foodNutrientDerivation: Option[FoodNutrientDerivation],
+  val unitName: Option[String],
+  val min: Option[Float],
+  val max: Option[Float],
+  val median: Option[Float],
+  val nutrientAnalysisDetails: Option[NutrientAnalysisDetails]
+) {
+  def typ = `type`
+}
+
+object FoodNutrient {
+  implicit val fmt = play.api.libs.json.Json.format[FoodNutrient]
+}

+ 12 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodNutrientDerivation.scala

@@ -0,0 +1,12 @@
+package gov.usda.nal.fdc.models
+
+case class FoodNutrientDerivation(
+  val id: Int,
+  val code: String,
+  val description: String,
+  val foodNutrientSource: Option[FoodNutrientSource]
+)
+
+object FoodNutrientDerivation {
+  implicit val fmt = play.api.libs.json.Json.format[FoodNutrientDerivation]
+}

+ 11 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodNutrientSource.scala

@@ -0,0 +1,11 @@
+package gov.usda.nal.fdc.models
+
+case class FoodNutrientSource(
+  val id: Int,
+  val code: String,
+  val description: String
+)
+
+object FoodNutrientSource {
+  implicit val fmt = play.api.libs.json.Json.format[FoodNutrientSource]
+}

+ 18 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodPortion.scala

@@ -0,0 +1,18 @@
+package gov.usda.nal.fdc.models
+
+case class FoodPortion(
+  val id: Int,
+  val dataPoints: Option[Int],
+  val gramWeight: Float,
+  val sequenceNumber: Long,
+  val amount: Option[Double],
+  val modifier: Option[String],
+  val measureUnit: MeasureUnit,
+  val minYearAcquired: Option[Short],
+  val portionDescription: Option[String]
+)
+
+object FoodPortion {
+  import play.api.libs.json.Json
+  implicit val fmt = Json.format[FoodPortion]
+}

+ 28 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodSearchCriteria.scala

@@ -0,0 +1,28 @@
+package gov.usda.nal.fdc.models
+
+import gov.usda.nal.fdc.models.DataType.DataType
+import gov.usda.nal.fdc.models.SortCriteria.SortCriteria
+import gov.usda.nal.fdc.models.SortOrder.SortOrder
+
+case class FoodSearchCriteria(
+  val query: String,
+  //val dataType: Seq[DataType] = Nil,
+  val brandOwner: Option[String] = None,
+  val pageSize: Option[Short] = None,
+  val pageNumber: Option[Long] = None,
+  val sortBy: Option[SortCriteria] = None,
+  val sortOrder: Option[SortOrder] = None
+) {
+  // if (dataType.length > 4) {
+  //   // TODO: Error
+  // }
+  pageSize.map({
+    case size if size < 1 => // TODO: Error
+    case size if size > 200 => // TODO: Error
+    case _ => // All Good
+  })
+}
+
+object FoodSearchCriteria {
+  implicit val fmt = play.api.libs.json.Json.format[FoodSearchCriteria]
+}

+ 28 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodUpdateLog.scala

@@ -0,0 +1,28 @@
+package gov.usda.nal.fdc.models
+
+import gov.usda.nal.fdc.models.DataType.DataType
+import gov.usda.nal.fdc.DateHelper._
+
+case class FoodUpdateLog (
+  val fdcId: Long,
+  val dataType: DataType,
+  val description: String,
+  val changes: Option[String],
+  val availableDate: Option[Date] = None,
+  val brandOwner: Option[String] = None,
+  val gtinUpc: Option[String] = None,
+  val dataSource: Option[String] = None,
+  val foodClass: Option[String] = None,
+  val householdServingFullText: Option[String] = None,
+  val ingredients: Option[String] = None,
+  val publicationDate: Option[Date] = None,
+  val servingSize: Option[Float] = None,
+  val servingSizeUnit: Option[String] = None,
+  val brandedFoodCategory: Option[String] = None,
+  val foodAttributes: Seq[FoodAttribute] = Nil
+)
+
+object FoodUpdateLog {
+  import play.api.libs.json.Json
+  implicit val fmt = Json.format[FoodUpdateLog]
+}

+ 29 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/FoodsCriteria.scala

@@ -0,0 +1,29 @@
+package gov.usda.nal.fdc.models
+
+case class FoodsCriteria(
+  val fdcIds: Seq[Long],
+  val format: Option[FoodsCriteria.Format.Format],
+  val nutrients: Seq[Long]
+) {
+  if (fdcIds.length > 20) {
+    // TODO: Error
+  }
+  else if (nutrients.length > 25) {
+    // TODO: Error
+  }
+}
+
+object FoodsCriteria {
+  object Format extends Enumeration {
+    type Format = Value
+
+    val Abridged = Value
+    val Full = Value
+
+    import play.api.libs.json.{Reads,Writes}
+    implicit val rds = Reads.enumNameReads(Format)
+    implicit val wts = Writes.enumNameWrites[this.type]
+  }
+
+  implicit val fmt = play.api.libs.json.Json.format[FoodsCriteria]
+}

+ 49 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/InputFood.scala

@@ -0,0 +1,49 @@
+package gov.usda.nal.fdc.models
+
+import gov.usda.nal.fdc.models.DataType.DataType
+
+sealed trait InputFood {
+  def id: Long
+  def foodDescription: Option[String] = None
+}
+
+case class InputFoodSurvey (
+  val id: Long,
+  val amount: Float,
+  // TODO: Causes infinite recursion
+  //val inputFood: SurveyFoodItem,
+  override val foodDescription: Option[String] = None,
+  val ingredientCode: Option[Int] = None,
+  val ingredientDescription: Option[String] = None,
+  val ingredientWeight: Option[Float] = None,
+  val portionCode: Option[String] = None,
+  val portionDescription: Option[String] = None,
+  val sequenceNumber: Option[Int] = None,
+  val surveyFlag: Option[Int] = None,
+  val unit: Option[String] = None,
+  val retentionFactor: Option[RetentionFactor] = None
+) extends InputFood
+
+object InputFoodSurvey {
+  implicit val fmt = play.api.libs.json.Json.format[InputFoodSurvey]
+}
+
+case class InputFoodFoundation (
+  val id: Long,
+  val inputFood: SampleFoodItem,
+  override val foodDescription: Option[String] = None
+) extends InputFood
+
+object InputFoodFoundation {
+  implicit val fmt = play.api.libs.json.Json.format[InputFoodFoundation]
+}
+
+object InputFood {
+  import play.api.libs.functional.syntax._
+  import play.api.libs.json.{Json,Writes}
+
+  implicit val writes = Writes[InputFood]({
+    case item: InputFoodSurvey => Json.toJson(item)
+    case item: InputFoodFoundation => Json.toJson(item)
+  })
+}

+ 23 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/LabelNutrients.scala

@@ -0,0 +1,23 @@
+package gov.usda.nal.fdc.models
+
+case class LabelNutrients(
+  val fat: Option[Float] = None,
+  val saturatedFat: Option[Float] = None,
+  val transFat: Option[Float] = None,
+  val cholesterol: Option[Float] = None,
+  val sodium: Option[Float] = None,
+  val carbohydrates: Option[Float] = None,
+  val fiber: Option[Float] = None,
+  val sugars: Option[Float] = None,
+  val protein: Option[Float] = None,
+  val calcium: Option[Float] = None,
+  val iron: Option[Float] = None,
+  val postassium: Option[Float] = None,
+  val calories: Option[Float] = None
+)
+
+object LabelNutrients {
+  import play.api.libs.json.Json
+  implicit val floatFmt = play.api.libs.json.Reads[Float](json => (json \ "value").validate[Float])
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[LabelNutrients]
+}

+ 11 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/MeasureUnit.scala

@@ -0,0 +1,11 @@
+package gov.usda.nal.fdc.models
+
+case class MeasureUnit(
+  val id: Int,
+  val abbreviation: String,
+  val name: String
+)
+
+object MeasureUnit {
+  implicit val fmt = play.api.libs.json.Json.format[MeasureUnit]
+}

+ 13 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/Nutrient.scala

@@ -0,0 +1,13 @@
+package gov.usda.nal.fdc.models
+
+case class Nutrient(
+  val id: Int,
+  val number: String,
+  val name: String,
+  val rank: Int,
+  val unitName: String
+)
+
+object Nutrient {
+  implicit val fmt = play.api.libs.json.Json.format[Nutrient]
+}

+ 14 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/NutrientAcquisitionDetails.scala

@@ -0,0 +1,14 @@
+package gov.usda.nal.fdc.models
+
+import gov.usda.nal.fdc.DateHelper._
+
+case class NutrientAcquisitionDetails(
+  val sampleUnitId: Int,
+  val purchaseDate: Date,
+  val storeCity: String,
+  val storeState: String
+)
+
+object NutrientAcquisitionDetails {
+  implicit val fmt = play.api.libs.json.Json.format[NutrientAcquisitionDetails]
+}

+ 16 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/NutrientAnalysisDetails.scala

@@ -0,0 +1,16 @@
+package gov.usda.nal.fdc.models
+
+case class NutrientAnalysisDetails(
+  val subSampleId: Int,
+  val amount: Float,
+  val nutrientId: Int,
+  val labMethodDescription: String,
+  val labMethodOriginalDescription: String,
+  val labMethodLink: String,
+  val labMethodTechnique: String,
+  val nutrientAcquisitionDetails: Seq[NutrientAcquisitionDetails]
+)
+
+object NutrientAnalysisDetails {
+  implicit val fmt = play.api.libs.json.Json.format[NutrientAnalysisDetails]
+}

+ 22 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/NutrientConversionFactor.scala

@@ -0,0 +1,22 @@
+package gov.usda.nal.fdc.models
+
+import gov.usda.nal.fdc.DateHelper._
+
+case class NutrientConversionFactor(
+  val id: Int,
+  val `type`: String,
+  val name: String,
+  val value: Option[Float],
+  val purchaseDate: Option[Date],
+  val storeCity: Option[String],
+  val storeState: Option[String],
+  val proteinValue: Option[Float],
+  val fatValue: Option[Float],
+  val carbohydrateValue: Option[Float]
+) {
+  def typ = `type`
+}
+
+object NutrientConversionFactor {
+  implicit val fmt = play.api.libs.json.Json.format[NutrientConversionFactor]
+}

+ 11 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/RetentionFactor.scala

@@ -0,0 +1,11 @@
+package gov.usda.nal.fdc.models
+
+case class RetentionFactor(
+  val id: Long,
+  val code: Int,
+  val description: String
+)
+
+object RetentionFactor {
+  implicit val fmt = play.api.libs.json.Json.format[RetentionFactor]
+}

+ 13 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SearchResult.scala

@@ -0,0 +1,13 @@
+package gov.usda.nal.fdc.models
+
+case class SearchResult(
+  val foodSearchCriteria: FoodSearchCriteria,
+  val totalHits: Long,
+  val currentPage: Long,
+  val totalPages: Long,
+  val foods: Seq[SearchResultFood]
+)
+
+object SearchResult {
+  implicit val fmt = play.api.libs.json.Json.format[SearchResult]
+}

+ 29 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SearchResultFood.scala

@@ -0,0 +1,29 @@
+package gov.usda.nal.fdc.models
+
+import gov.usda.nal.fdc.DateHelper._
+import gov.usda.nal.fdc.models.DataType.DataType
+
+case class SearchResultFood(
+  val fdcId: Long,
+  val dataType: DataType,
+  val description: String,
+  val lowercaseDescription: String,
+  val foodCode: Option[String],
+  val foodNutrients: Seq[AbridgedFoodNutrient],
+  val publicationDate: Option[Date],
+  val scientificName: Option[String],
+  val additionalDescriptions: Option[String],
+  val allHighlightFields: String,
+  val score: Float,
+  // Branded
+  val brandOwner: Option[String],
+  val gtinUpc: Option[String],
+  val ingredients: Option[String],
+  // Foundation SRLegacy
+  val ndbNumber: Option[String]
+)
+
+object SearchResultFood {
+  import play.api.libs.functional.syntax._
+  implicit val fmt = play.api.libs.json.Json.format[SearchResultFood]
+}

+ 15 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SortCriteria.scala

@@ -0,0 +1,15 @@
+package gov.usda.nal.fdc.models
+
+object SortCriteria extends Enumeration {
+  type SortCriteria = Value
+  
+  val FdcId = Value("fdcId")
+  val PublishedDate = Value("publishedDate")
+  val DataType = Value("dataType.keyword")
+  val LowercaseDescription = Value("lowercaseDescription.keyword")
+
+  import play.api.libs.json.{Reads,Writes}
+
+  implicit val rds = Reads.enumNameReads(SortCriteria)
+  implicit val wts = Writes.enumNameWrites[this.type]
+}

+ 13 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/SortOrder.scala

@@ -0,0 +1,13 @@
+package gov.usda.nal.fdc.models
+
+object SortOrder extends Enumeration {
+  type SortOrder = Value
+  
+  val Asc = Value("asc")
+  val Desc = Value("desc")
+
+  import play.api.libs.json.{Reads,Writes}
+
+  implicit val rds = Reads.enumNameReads(SortOrder)
+  implicit val wts = Writes.enumNameWrites[this.type]
+}

+ 10 - 0
fdc/shared/src/main/scala/gov/usda/nal/fdc/models/WweiaFoodCategory.scala

@@ -0,0 +1,10 @@
+package gov.usda.nal.fdc.models
+
+case class WweiaFoodCategory(
+  val wweiaFoodCategoryCode: Long,
+  val wweiaFoodCategoryDescription: String
+)
+
+object WweiaFoodCategory {
+  implicit val fmt = play.api.libs.json.Json.format[WweiaFoodCategory]
+}

+ 1 - 0
project/build.properties

@@ -0,0 +1 @@
+sbt.version=1.3.10

+ 5 - 0
project/plugins.sbt

@@ -0,0 +1,5 @@
+addSbtPlugin("org.scala-js"       % "sbt-scalajs"               % "1.3.0")
+addSbtPlugin("com.typesafe.play"  % "sbt-plugin"                % "2.8.2")
+addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject"  % "1.0.0")
+addSbtPlugin("com.vmunier"        % "sbt-web-scalajs"           % "1.0.11")
+addSbtPlugin("com.tflucke"       % "sbt-rest-rpc-play"          % "0.3.1")

+ 69 - 0
server/app/com/weEat/controllers/FoodController.scala

@@ -0,0 +1,69 @@
+package com.weEat.controllers
+
+import javax.inject.{Inject,Singleton}
+import play.api._
+import play.api.mvc._
+import play.api.libs.json._
+import scala.util.{Try,Success,Failure}
+import com.tflucke.webroutes.HTTPException
+import scala.concurrent.{ExecutionContext,Future}
+import com.weEat.models.{FoodNode => FoodNodeCollection}
+import com.weEat.shared.models.{UnitType,USDANode}
+import com.weEat.shared.models.UnitType._
+import com.weEat.services.MongoDBService
+import org.mongodb.scala.model.Filters._
+import org.mongodb.scala.result.UpdateResult
+import com.weEat.shared.models._
+import org.bson.types.ObjectId
+
+@Singleton
+class FoodController @Inject()(
+  val controllerComponents: ControllerComponents,
+  db: MongoDBService
+) extends BaseController {
+  implicit val ec = scala.concurrent.ExecutionContext.global
+
+  import db.withCollection
+
+  def get(id: String) = Action.async
+  { implicit request: Request[AnyContent] =>
+    withCollection(FoodNodeCollection) {collection =>
+      collection.find(equal("id", id))
+        .first()
+        .toFuture()
+        .transform({
+          case Success(x) => Success(Ok(Json.toJson(x)))
+          case Failure(x) => throw x
+        })
+    }.flatten
+  }
+
+  def query() = Action.async
+  { implicit request: Request[AnyContent] =>
+    withCollection(FoodNodeCollection) {collection =>
+      collection.find()
+        .toFuture()
+        .transform({
+          case Success(x) => Success(Ok(Json.toJson(x)))
+          case Failure(x) => throw x
+        })
+    }.flatten
+  }
+
+  def add() = Action.async(parse.json)
+  { implicit request: Request[JsValue] =>
+    try {
+      val food = request.body.as[FoodNode].withId(new ObjectId)
+      withCollection(FoodNodeCollection) {collection =>
+        collection.insertOne(food).map(res =>
+          Ok(Json.toJson(food.withId(res.getInsertedId().asObjectId().getValue())))
+        ).head()
+      }.flatten
+    }
+    catch {
+      case jsre: JsResultException => Future.successful(
+        BadRequest(s"Could not parse json into a Food node.")
+      )
+    }
+  }
+}

+ 98 - 0
server/app/com/weEat/controllers/USDAController.scala

@@ -0,0 +1,98 @@
+package com.weEat.controllers
+
+import javax.inject.{Inject,Singleton}
+import play.api._
+import play.api.mvc._
+import play.api.libs.json._
+import gov.usda.nal.fdc.{controllers => fdc}
+import gov.usda.nal.fdc.models._
+import gov.usda.nal.fdc.models.FoodsCriteria.Format
+import gov.usda.nal.fdc.models.DataType.DataType
+import gov.usda.nal.fdc.models.SortCriteria.SortCriteria
+import gov.usda.nal.fdc.models.SortOrder.SortOrder
+import scala.util.{Try,Success,Failure}
+import com.tflucke.webroutes.HTTPException
+import com.weEat.util.TryWith
+import scala.concurrent.{ExecutionContext,Future,blocking}
+import gov.usda.nal.fdc.{controllers => fdcControllers}
+import org.mongodb.scala.model.Filters._
+import com.weEat.services.MongoDBService
+
+@Singleton
+class USDAController @Inject()(
+  val controllerComponents: ControllerComponents,
+  config: Configuration,
+  db: MongoDBService
+) extends BaseController {
+
+  import db.withCollection
+
+  implicit val ec = scala.concurrent.ExecutionContext.global
+
+  val fdc = new fdcControllers.FoodController(config.get[String]("fdc.apikey"))
+
+  def getFood(id: Long, fmt: String = "Full") = Action.async
+  { implicit request: Request[Any] =>
+    fdc.getFood(id, Format.withName(fmt))().map(x => Ok(Json.toJson[FoodItem](x)))
+  }    
+
+  def getFoods(ids: String, fmt: String = "Full") = Action.async
+  { implicit request: Request[Any] =>
+    fdc.getFoods(ids.split(",").toSeq, Format.withName(fmt))().map(x =>
+      Ok(Json.toJson(x))
+    )
+  }
+
+  def getFoodsSearch(
+    query: String,
+    dataType: Seq[String] = Nil,
+    pageSize: Option[Short] = None,
+    pageNumber: Option[Long] = None,
+    sortBy: Option[String] = None,
+    sortOrder: Option[String] = None
+  ) = Action.async { implicit request: Request[Any] =>
+    fdc.getFoodsSearch(
+      query,
+      dataType.map(dt => DataType.withName(dt)),
+      pageSize,
+      pageNumber,
+      sortBy.map(x => SortCriteria.withName(x)),
+      sortOrder.map(x => SortOrder.withName(x.toLowerCase))
+    )().transform({
+      case Success(x) => Success(Ok(Json.toJson(x)))
+      case Failure(x: HTTPException) => Success((new Status(x.statusCode))(x.responseText))
+      case Failure(x) => Success(InternalServerError(x.toString))
+    })
+  }
+
+  def postFoodsSearch() = Action.async(parse.json) {
+    implicit request: Request[JsValue] =>
+    fdc.postFoodsSearch()(request.body.as[FoodSearchCriteria]).transform({
+      case Success(x) => Success(Ok(Json.toJson(x)))
+      case Failure(x: HTTPException) => Success((new Status(x.statusCode))(x.responseText))
+      case Failure(x) => Success(InternalServerError(x.toString))
+    })
+  }
+
+  def getNutrients() = Action.async { implicit request: Request[Any] =>
+    withCollection(com.weEat.models.Nutrient) { collection =>
+      collection.find()
+        .toFuture()
+        .transform({
+          case Success(x) => Success(Ok(Json.toJson(x)))
+          case Failure(x) => throw x
+      })
+    }.flatten
+  }
+
+  def getNutrient(id: Int) = Action.async { implicit request: Request[Any] =>
+    withCollection(com.weEat.models.Nutrient) { collection =>
+      collection.find(equal("number", id))
+        .toFuture()
+        .transform({
+          case Success(x) => Success(Ok(Json.toJson(x)))
+          case Failure(x) => throw x
+      })
+    }.flatten
+  }
+}

+ 160 - 0
server/app/com/weEat/controllers/UserController.scala

@@ -0,0 +1,160 @@
+package com.weEat.controllers
+
+import java.util.Base64
+import javax.inject.{Inject,Singleton}
+import play.api._
+import play.api.mvc._
+import play.api.libs.json._
+import com.weEat.services.OAuth2Service
+import com.weEat.models.{Authorization,User}
+import com.weEat.shared.models.UserRegistration
+import com.github.t3hnar.bcrypt._
+import scalaoauth2.provider._
+import scala.concurrent.duration._
+import scala.concurrent.{Await,Future,blocking}
+import scala.util.{Try,Success,Failure}
+import org.mongodb.scala.ObservableImplicits
+import com.weEat.services.MongoDBService
+import org.mongodb.scala.model.Filters._
+import org.bson.types.ObjectId
+import org.mongodb.scala.MongoCollection
+
+@Singleton
+class UserController @Inject()(
+  val controllerComponents: ControllerComponents,
+  oauth: OAuth2Service,
+  db: MongoDBService
+) extends BaseController
+    with OAuth2Provider
+    with ObservableImplicits
+    with OAuth2ProviderActionBuilders {
+
+  implicit val ec = scala.concurrent.ExecutionContext.global
+
+  import db.withCollection
+
+  override val tokenEndpoint = new TokenEndpoint {
+    override val handlers = Map(
+      //OAuthGrantType.AUTHORIZATION_CODE -> new AuthorizationCode(),
+      OAuthGrantType.REFRESH_TOKEN -> new RefreshToken(),
+      //OAuthGrantType.CLIENT_CREDENTIALS -> new ClientCredentials(),
+      //OAuthGrantType.IMPLICIT -> new Implicit(),
+      OAuthGrantType.PASSWORD -> new Password()
+    )
+  }
+
+  def encodeBasicAuth(email: String, pass: String) =
+    s"Basic " + Base64.getEncoder().encodeToString(s"$email:$pass".getBytes())
+
+  def decodeBasicAuth(auth: String): Option[(String, String)] = {
+    val format = raw"Basic ([\d\w+/=]*)".r
+    auth match {
+      case format(cred) => {
+        val split = new String(Base64.getDecoder().decode(cred)).split(":", 2)
+        Some((split(0), split(1)))
+      }
+      case _ => None
+    }
+  }
+
+  def accessToken = Action.async { implicit request: Request[Any] =>
+    issueAccessToken(oauth)
+  }
+
+  def revokeAccessToken() =
+    AuthorizedAction[Authorization](oauth).async(parse.json)
+  { implicit request: AuthInfoRequest[JsValue, Authorization] =>
+    val email = (request.body \ "client_id").as[String]
+    val refresh = (request.body \ "refresh_token").as[String]
+    oauth.revokeAccessToken(request.authInfo, email, refresh).transform {
+      case Success(_) => Success(
+        Ok("").withHeaders("Cache-Control" -> "no-store", "Pragma" -> "no-cache")
+      )
+      case Failure(e: OAuthError) => Success(
+        new Status(e.statusCode)(responseOAuthErrorJson(e))
+          .withHeaders(responseOAuthErrorHeader(e))
+      )
+      case Failure(e) => Failure(e)
+    }
+  }
+
+  private val emailRegex = """(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])"""
+  private def isEmail(email: String): Boolean = email.matches(emailRegex)
+
+  // private def checkEmail(conn: Connection, email: String): Future[Boolean] = {
+  //   val emailquery = "SELECT id FROM users WHERE email=? LIMIT 1"
+  //   Future {
+  //     TryWith(conn.prepareStatement(emailquery)) { st =>
+  //       st.setString(1, email)
+  //       st.executeQuery.next
+  //     }
+  //   }.transform(_.flatten)
+  // }
+
+  def validateRegisterRequest(body: UserRegistration) = 
+    if (!isEmail(body.email))
+      Some("Invalid email.")
+    else if (body.password.length < 8)
+      Some("Password too short (Minimum 8 characters).")
+    else
+      None
+
+  // TODO: Unit test API
+  def registerUser() = Action.async(parse.json)
+  { implicit request: Request[JsValue] =>
+    val body = request.body.as[UserRegistration]
+    validateRegisterRequest(body) match {
+      case Some(reason) => Future.successful(BadRequest(reason))
+      case None => {
+        val user = User(body)
+        withCollection(User) { collection =>
+          {
+          collection.insertOne(user).head().map(res =>
+            {
+            issueAccessToken(oauth)(Request[Map[String, Seq[String]]](
+              request.withHeaders(Headers(
+                "Authorization" -> encodeBasicAuth(user.email, body.password)
+              )),
+              Map("grant_type" -> Seq(OAuthGrantType.PASSWORD))
+            ), ec)}
+          ).flatten}
+        }.flatten
+      }
+    }
+  }
+
+  def getName() = AuthorizedAction[Authorization](oauth).async
+  { implicit request: AuthInfoRequest[AnyContent, Authorization] =>
+    getUser(request.authInfo.user.userId).map({
+      case Some(user) => Ok("%s %s".format(user.fname, user.lname))
+      case None =>
+        throw new IllegalStateException("Authorized user does not exist!")
+    })
+  }
+
+  // TODO: Unit test API
+  def getUsers() = Action.async
+  { implicit request: Request[AnyContent] =>
+    withCollection(User) { collection =>
+      collection.find().map(res => res.email)
+        .toFuture()
+        .transform({
+          case Success(x) => Success(Ok(Json.toJson(x)))
+          case Failure(x) => throw x
+      })
+    }.flatten
+  }
+
+  def getUser(id: ObjectId): Future[Option[User]] = withCollection(User) { users =>
+    users.find(equal("_id", id))
+      .first()
+      .toFutureOption()
+  }.flatten
+
+  def get(id: String) = Action.async { implicit request: Request[AnyContent] =>
+    getUser(new ObjectId(id)).map({
+      case Some(user) => Ok(Json.toJson(user.toShared()))
+      case None => NotFound("No such user with this id")
+    })
+  }
+}

+ 32 - 0
server/app/com/weEat/controllers/ViewController.scala

@@ -0,0 +1,32 @@
+package com.weEat.controllers
+
+import javax.inject._
+import play.api._
+import play.api.mvc._
+
+/**
+ * This controller creates an `Action` to handle HTTP requests to the
+ * application's home page.
+ */
+@Singleton
+class ViewController @Inject()(
+  val controllerComponents: ControllerComponents
+) (implicit
+  val webJarsUtil: org.webjars.play.WebJarsUtil
+) extends BaseController {
+
+  /**
+   * Create an Action to render an HTML page.
+   *
+   * The configuration in the `routes` file means that this method
+   * will be called when the application receives a `GET` request with
+   * a path of `/`.
+   */
+  def index() = Action { implicit request: Request[AnyContent] =>
+    Ok(views.html.index())
+  }
+
+  def usdaImport() = Action { implicit request: Request[AnyContent] =>
+    Ok(views.html.usdaImport())
+  }
+}

+ 93 - 0
server/app/com/weEat/migrations/InitDb.scala

@@ -0,0 +1,93 @@
+package com.weEat.migrations
+
+import org.mongodb.scala.MongoDatabase
+import com.weEat.models.{Authorization,User}
+import com.weEat.shared.models.FoodNode
+import com.mongodb.client.model._
+import org.mongodb.scala.model.Filters._
+import org.mongodb.scala.model.Indexes._
+import java.util.concurrent.TimeUnit._
+import org.bson.BsonType._
+import scala.concurrent.{ExecutionContext,Future}
+
+object InitDb extends Migration {
+  // Must be multiple of 256
+  val MAX_SIZE_META_DOCUMENT = 256*4
+
+  implicit val ec: ExecutionContext = ExecutionContext.global
+
+  private def typ = `type` _
+
+  def execute(db: MongoDatabase) = Future.sequence(Seq(
+    initMetadataCollection(db),
+    createFoodCollection(db),
+    createUserCollection(db),
+    createTokenCollection(db)
+  ))
+
+  def initMetadataCollection(db: MongoDatabase) = {
+    db.createCollection(Metadata.collectionName, new CreateCollectionOptions()
+      .capped(true)
+      .maxDocuments(1)
+      .sizeInBytes(MAX_SIZE_META_DOCUMENT)
+    ).head().map(res =>
+      db.getCollection[Metadata](Metadata.collectionName)
+        .insertOne(Metadata())
+        .head()
+    ).flatten
+  }
+
+  def createFoodCollection(db: MongoDatabase) =
+    db.createCollection(FoodNode.collectionName,
+      new CreateCollectionOptions().validationOptions(
+        new ValidationOptions().validator(
+          typ("defaultUnit", STRING)
+        )
+      )
+    ).head()
+
+  def createUserCollection(db: MongoDatabase) =
+    db.createCollection(User.collectionName,
+      new CreateCollectionOptions().validationOptions(
+        new ValidationOptions().validator(
+          and(
+            typ("email", STRING),
+            typ("password", STRING),
+            typ("created", DATE_TIME)
+          )
+        )
+      )
+    ).head().map(res => db.getCollection[User](User.collectionName)
+      // TODO: this would be better as hashed
+      // Currently ascending because hashed does not support unique
+      .createIndex(ascending("email"), new IndexOptions().unique(true)).head()
+    ).flatten
+
+  def createTokenCollection(db: MongoDatabase) =
+    db.createCollection(Authorization.collectionName,
+      new CreateCollectionOptions().validationOptions(
+        new ValidationOptions().validator(
+          and(
+            typ("email", STRING),
+            typ("userId", OBJECT_ID),
+            typ("accessToken", BINARY),
+            typ("refreshToken", BINARY),
+            typ("created", DATE_TIME)
+          )
+        )
+      )
+    ).head().map(res =>
+      db.getCollection[Authorization](Authorization.collectionName)
+        .createIndexes(Seq(
+          // TODO: These two would be better as hashed
+          // Currently ascending because hashed does not support unique
+          new IndexModel(ascending("accessToken"), new IndexOptions()
+            .unique(true)),
+          new IndexModel(ascending("refreshToken"), new IndexOptions()
+            .unique(true)),
+          new IndexModel(ascending("created"),new IndexOptions()
+            .expireAfter(Authorization.refreshFreshTime.toSeconds, SECONDS)
+          )
+        )).head()
+    ).flatten
+}

+ 9 - 0
server/app/com/weEat/migrations/Metadata.scala

@@ -0,0 +1,9 @@
+package com.weEat.migrations
+
+case class Metadata(
+  val migrationNumber: Int = -1
+)
+
+object Metadata {
+  val collectionName = "meta"
+}

+ 45 - 0
server/app/com/weEat/migrations/Migration.scala

@@ -0,0 +1,45 @@
+package com.weEat.migrations
+
+import org.mongodb.scala.MongoDatabase
+import scala.concurrent.{ExecutionContext,Future}
+import scala.util.{Success,Failure}
+import scala.io.Source
+import com.weEat.models._
+
+trait Migration {
+  def execute(db: MongoDatabase): Future[Any]
+}
+
+object Migration {
+  implicit val ec: ExecutionContext = ExecutionContext.global
+
+  val migrations = Seq(
+    InitDb,
+    SeedNutrition,
+    RestoreFromFile(Source.fromResource("db-seeds/nutrients.json"), Nutrient)
+    //RestoreFromFile(Source.fromResource("db-seeds/usda-foods.json"), FoodNode)
+  )
+
+  def executeAll(db: MongoDatabase) = executeFutures(db, migrations)
+
+  def updateToLatest(db: MongoDatabase) = {
+    val collection = db.getCollection[Metadata](Metadata.collectionName)
+    collection.find().first().headOption().map({
+      case Some(Metadata(last)) =>
+        executeFutures(db, migrations.slice(last + 1, migrations.size))
+      case None => executeAll(db)
+    }).flatten
+  }
+
+  private def executeFutures(db: MongoDatabase, seq: Seq[Migration]): Future[Any] =
+    seq match {
+      case head::tail => head.execute(db).transformWith({
+        case Success(_) => executeFutures(db, tail)
+        case Failure(e) => db.getCollection[Metadata](Metadata.collectionName)
+            .insertOne(Metadata(migrations.size - seq.size)).head()
+            .transform(_ => Failure(e))
+      })
+      case Nil => db.getCollection[Metadata](Metadata.collectionName)
+          .insertOne(Metadata(migrations.size)).head()
+    }
+}

+ 21 - 0
server/app/com/weEat/migrations/RestoreFromFile.scala

@@ -0,0 +1,21 @@
+package com.weEat.migrations
+
+import com.weEat.models.Collectable
+import org.mongodb.scala.MongoDatabase
+import scala.io.Source
+import scala.concurrent.Future
+import scala.reflect.ClassTag
+import play.api.libs.json.{Reads,Json}
+import scala.jdk.StreamConverters._
+
+case class RestoreFromFile[T](
+  f: Source,
+  col: Collectable[T]
+)(implicit
+  tag: ClassTag[T],
+  reads: Reads[Seq[T]]
+) extends Migration {
+  def execute(db: MongoDatabase) = db.getCollection[T](col.collectionName)
+    .insertMany(Json.parse(f.mkString).as[Seq[T]])
+    .toFuture()
+}

+ 43 - 0
server/app/com/weEat/migrations/SeedNutrition.scala

@@ -0,0 +1,43 @@
+package com.weEat.migrations
+
+import org.mongodb.scala.MongoDatabase
+import gov.usda.nal.fdc.models.Nutrient
+import com.weEat.shared.models.FoodNode
+import com.mongodb.client.model._
+import org.mongodb.scala.model.Filters._
+import org.mongodb.scala.model.Indexes._
+import java.util.concurrent.TimeUnit._
+import org.bson.BsonType._
+import scala.concurrent.{ExecutionContext,Future}
+import com.weEat.models.{Nutrient => NutrientCollection}
+
+object SeedNutrition extends Migration {
+  
+  implicit val ec: ExecutionContext = ExecutionContext.global
+
+  private def typ = `type` _
+
+  def execute(db: MongoDatabase) = Future.sequence(Seq(
+    createNutritionCollection(db)
+  ))
+
+  def createNutritionCollection(db: MongoDatabase) =
+    db.createCollection(NutrientCollection.collectionName,
+      new CreateCollectionOptions().validationOptions(
+        new ValidationOptions().validator(
+          and(
+            typ("number", STRING),
+            typ("unitName", STRING),
+            typ("name", STRING),
+            typ("id", INT32),
+            typ("rank", INT32)
+          )
+        )
+      )
+    ).head().map(res => {
+      val nutrs = db.getCollection[Nutrient](NutrientCollection.collectionName)
+      nutrs.createIndex(
+        ascending("number"), new IndexOptions().unique(true)
+      ).head()
+    }).flatten
+}

+ 5 - 0
server/app/com/weEat/models/Collectable.scala

@@ -0,0 +1,5 @@
+package com.weEat.models
+
+trait Collectable[T] {
+  val collectionName: String
+}

+ 5 - 0
server/app/com/weEat/models/FoodNode.scala

@@ -0,0 +1,5 @@
+package com.weEat.models
+
+object FoodNode extends Collectable[com.weEat.shared.models.FoodNode] {
+  val collectionName = "foods"
+}

+ 5 - 0
server/app/com/weEat/models/Nutrient.scala

@@ -0,0 +1,5 @@
+package com.weEat.models
+
+object Nutrient extends Collectable[gov.usda.nal.fdc.models.Nutrient] {
+  val collectionName = "nutrients"
+}

+ 100 - 0
server/app/com/weEat/models/User.scala

@@ -0,0 +1,100 @@
+package com.weEat.models
+
+import codes.reactive.scalatime._
+import org.bson.types.ObjectId
+import com.weEat.shared.models.UserAuthorization
+import java.security.SecureRandom
+import java.time.{Duration,Instant}
+import java.util.Base64
+import scala.concurrent.duration.FiniteDuration
+import scala.language.postfixOps
+import scalaoauth2.provider.AccessToken
+
+/* Basic User information */
+case class User (
+  val _id: ObjectId,
+  val fname: String,
+  val lname: String,
+  val email: String,
+  val password: String,
+  val created: Instant = Instant.now()
+) {
+  def toShared() = com.weEat.shared.models.User(
+    fname,
+    lname,
+    email
+  )
+}
+
+object User extends Collectable[User] {
+  import com.weEat.shared.models.UserRegistration
+  import com.github.t3hnar.bcrypt.BCryptStrOps
+
+  val collectionName = "users"
+
+  def apply(reg: UserRegistration): User = User(
+    new ObjectId(),
+    reg.fname,
+    reg.lname,
+    reg.email,
+    reg.password.boundedBcrypt
+  )
+}
+
+class Authorization (
+  val accessToken: Array[Byte],
+  val refreshToken: Array[Byte],
+  val created: Instant,
+  val email: String,
+  val userId: ObjectId
+) {
+  def accessExpiration() = created + Authorization.accessFreshTime
+  def refreshExpiration() = created + Authorization.refreshFreshTime
+
+  implicit def asFiniteDuration(d: java.time.Duration) =
+    scala.concurrent.duration.Duration.fromNanos(d.toNanos)
+
+  implicit def asDate(d: java.time.Instant) =
+    new java.util.Date(d.toEpochMilli())
+
+  def toToken() = new AccessToken(
+    Authorization.encodeToken(accessToken),
+    Some(Authorization.encodeToken(refreshToken)),
+    None,
+    Some(Duration.between(Instant.now(), accessExpiration()).getSeconds()),
+    created
+  )
+
+  def toUserAuth() = UserAuthorization(
+    Authorization.encodeToken(accessToken),
+    "Bearer",
+    Duration.between(Instant.now(), accessExpiration()),
+    Authorization.encodeToken(refreshToken)
+  )
+}
+
+object Authorization extends Collectable[Authorization] {
+  val accessFreshTime = 1 hour
+  val refreshFreshTime = 10 hour
+
+  val collectionName = "authorizations"
+
+  private val rand = new SecureRandom()
+
+  private def generateSecureBytes(n: Int = 32): Array[Byte] = {
+    val token = new Array[Byte](n);
+    rand.nextBytes(token)
+    token
+  }
+
+  def encodeToken(token: Array[Byte]) = Base64.getEncoder.encodeToString(token)
+  def decodeToken(token: String) = Base64.getDecoder.decode(token)
+
+  def apply(id: ObjectId, email: String) = new Authorization(
+    generateSecureBytes(),
+    generateSecureBytes(),
+    Instant.now(),
+    email,
+    id
+  )
+}

+ 104 - 0
server/app/com/weEat/services/MongoDBService.scala

@@ -0,0 +1,104 @@
+package com.weEat.services
+
+import play.api.Configuration
+import org.mongodb.scala.{MongoClient,MongoDatabase,MongoCollection}
+import org.mongodb.scala.bson.codecs.Macros._
+import org.bson.{BsonReader,BsonWriter}
+import org.bson.codecs.{Codec,DecoderContext,EncoderContext}
+import org.bson.codecs.configuration.{CodecProvider,CodecRegistry}
+import org.bson.codecs.configuration.CodecRegistries.{fromRegistries,fromProviders}
+import com.weEat.models._
+import com.weEat.shared.models.{USDANode,RecipeNode,FoodNode,UnitType}
+import javax.inject.{Inject,Singleton}
+import scala.reflect.ClassTag
+import com.weEat.migrations.{Migration,Metadata}
+import scala.concurrent.ExecutionContext
+import scala.util.{Try,Success,Failure}
+import gov.usda.nal.fdc.models.Nutrient
+
+@Singleton
+class MongoDBService @Inject()(config: Configuration) {
+
+  implicit val ec = scala.concurrent.ExecutionContext.global
+
+  private val prefix = "mongo"
+
+  val url = config.getOptional[String](s"$prefix.url").getOrElse("localhost")
+  val name = config.getOptional[String](s"$prefix.name").getOrElse("recipes")
+  val user = config.getOptional[String](s"$prefix.user").getOrElse("application")
+  val ssl = config.getOptional[Boolean](s"$prefix.ssl").getOrElse(true)
+  val port = config.getOptional[Int](s"$prefix.port").getOrElse(27017)
+
+  val codecRegistry = fromRegistries(
+    fromProviders(classOf[Metadata]),
+    fromProviders(classOf[FoodNode]),
+    fromProviders(classOf[User]),
+    fromProviders(classOf[Authorization]),
+    fromProviders(classOf[Nutrient]),
+    //fromProviders(classOf[UnitType.Value]),
+    fromProviders(UnitTypeEnumCodecProvider),
+    MongoClient.DEFAULT_CODEC_REGISTRY
+  )
+
+  val con = {
+    import org.mongodb.scala._
+    import org.mongodb.scala.connection.{SslSettings,ClusterSettings}
+    import scala.jdk.CollectionConverters._
+
+    implicit val ec: ExecutionContext = ExecutionContext.global
+    
+    val password = Option(config.get[String](s"$prefix.password"))
+      .getOrElse("").toCharArray()
+    val credential = MongoCredential.createCredential(user, name, password)
+
+    val db = MongoClient(
+      MongoClientSettings.builder()
+        .applyToSslSettings((builder: SslSettings.Builder) =>
+          builder.enabled(ssl)
+        ).applyToClusterSettings((builder: ClusterSettings.Builder) =>
+          builder.hosts(List(new ServerAddress(url, port)).asJava)
+        ).credential(credential)
+        .build()
+    ).getDatabase(name)
+      .withCodecRegistry(codecRegistry)
+    Migration.updateToLatest(db).map(_ => db)
+  }
+
+  def apply[T](coll: Collectable[T])(implicit ct: ClassTag[T]) =
+    con.map(_.getCollection[T](coll.collectionName))
+
+  def withCollection[T, U](coll: Collectable[U])(fn: (MongoCollection[U] => T))
+    (implicit ct: ClassTag[U]) =
+    apply(coll).transform({
+      case Success(collection) => Success(fn(collection))
+      case Failure(e) => Failure(e)
+    })
+}
+
+object UnitTypeEnumCodecProvider extends CodecProvider {
+
+  def isCaseObjectEnum[T](clazz: Class[T]): Boolean =
+    clazz.isInstance(UnitType.MASS) ||
+    clazz.isInstance(UnitType.VOLUME) ||
+    clazz.isInstance(UnitType.NUMBER)
+
+  override def get[T](clazz: Class[T], registry: CodecRegistry): Codec[T] =
+    if (isCaseObjectEnum(clazz)) UnitTypeEnumCodec.asInstanceOf[Codec[T]]
+    else null
+
+  object UnitTypeEnumCodec extends Codec[UnitType.UnitType] {
+    override def decode(
+      reader: BsonReader,
+      decoderContext: DecoderContext
+    ): UnitType.UnitType = UnitType.withName(reader.readString())
+
+    override def encode(
+      writer: BsonWriter,
+      value: UnitType.UnitType,
+      encoderContext: EncoderContext
+    ): Unit = writer.writeString(value.name)
+
+    override def getEncoderClass: Class[UnitType.UnitType] =
+      UnitType.getClass.asInstanceOf[Class[UnitType.UnitType]]
+  }
+}

+ 175 - 0
server/app/com/weEat/services/OAuth2Service.scala

@@ -0,0 +1,175 @@
+package com.weEat.services
+
+import codes.reactive.scalatime._
+import com.github.t3hnar.bcrypt._
+import com.weEat.models.{User,Authorization}
+import com.weEat.shared.models.RefreshRequest
+import java.time.Instant
+import javax.inject.{Inject,Singleton}
+import scala.concurrent.duration._
+import scala.concurrent.{ExecutionContext,Future}
+import scala.language.postfixOps
+import scala.util.{Success,Failure,Try}
+import scalaoauth2.provider._
+import org.mongodb.scala.model.Updates._
+import org.mongodb.scala.model.Filters._
+import org.mongodb.scala.bson.{BsonArray,BsonDocument,BsonValue}
+import org.mongodb.scala.bson.DefaultBsonTransformers
+import java.util.Date
+
+@Singleton
+class OAuth2Service @Inject()(db: MongoDBService)
+    extends AuthorizationHandler[User]
+    with ProtectedResourceHandler[Authorization]
+    with DefaultBsonTransformers {
+
+  implicit val ec: ExecutionContext = ExecutionContext.global
+
+  import db.withCollection
+
+  override def createAccessToken(auth: AuthInfo[User]) = {
+    println("Create token")
+    val newAuth = Authorization(auth.user._id, auth.user.email)
+    withCollection(Authorization) {auths =>
+      auths.insertOne(newAuth).head().map(_ => newAuth.toToken())
+    }.flatten
+  }
+
+  override def validateClient(
+    cred: Option[ClientCredential],
+    requ: AuthorizationRequest
+  ): Future[Boolean] = {
+    val cc = cred.getOrElse(throw new UnauthorizedClient("username required"))
+    requ.grantType match {
+      case OAuthGrantType.PASSWORD => validateUsernamePassword(
+        cc.clientId,
+        cc.clientSecret.getOrElse(throw new AccessDenied("password required"))
+      )
+      case OAuthGrantType.REFRESH_TOKEN => validateUsernameRefresh(
+        cc.clientId,
+        Authorization.decodeToken(RefreshTokenRequest(requ).refreshToken)
+      )
+      case OAuthGrantType.AUTHORIZATION_CODE => throw new UnsupportedGrantType()
+      case OAuthGrantType.CLIENT_CREDENTIALS => throw new UnsupportedGrantType()
+      case OAuthGrantType.IMPLICIT => throw new UnsupportedGrantType()
+    }
+  }.map({x => {println(s"validateClient: $x");x}})
+
+  private def validateUsernamePassword(
+    username: String,
+    pass: String
+  ): Future[Boolean] = withCollection(User) {users =>
+    users.find(equal("email", username))
+      .first()
+      .toFutureOption()
+      .map(res => res.map { x => pass.isBcryptedSafeBounded(x.password)}
+        .getOrElse(Success(false))
+      ).transform ({ _.flatten })
+  }.flatten
+
+  private def validateUsernameRefresh(username: String, refresh: Array[Byte]) = 
+  withCollection(Authorization) {auths =>
+    auths.find(and(
+      equal("email", username),
+      equal("refreshToken", refresh)
+    )).first().toFutureOption().map { _.nonEmpty }
+  }.flatten
+
+  override def findUser(
+    cred: Option[ClientCredential],
+    requ: AuthorizationRequest
+  ): Future[Option[User]] = withCollection(User) {users =>
+    users.find(equal("email", cred.getOrElse(
+      throw new UnsupportedGrantType("client_id required")
+    ).clientId)).first().toFutureOption()
+  }.flatten.map({x => {println(s"findUser: $x"); x}})
+  
+  /* 2020-07-25: Never re-issue the same authorization token. Always generate a
+   * new one.
+   */
+  override def getStoredAccessToken(auth: AuthInfo[User]) = {
+    Future.successful(None)
+  }
+
+  private def freshAccessToken(token: Array[Byte]) = and(
+    gt("created", Instant.now() - Authorization.accessFreshTime),
+    equal("accessToken", token)
+  )
+
+  override def findAccessToken(token: String): Future[Option[AccessToken]] = 
+    withCollection(Authorization) {auths =>
+      auths.find(
+        freshAccessToken(Authorization.decodeToken(token))
+      ).first().toFutureOption()
+        .map(_.map(_.toToken()))
+    }.flatten.map({x => {println(s"findAccessToken: $x"); x}})    
+
+  override def findAuthInfoByAccessToken(token: AccessToken) =
+    withCollection(Authorization) {collection =>
+      collection.find(freshAccessToken(Authorization.decodeToken(token.token)))
+        .first()
+        .toFutureOption()
+        .map(_.map(auth => AuthInfo(auth, Some(auth.email), None, None)))
+    }.flatten
+
+  implicit def optFut2FutOpt[A](
+    x: Option[Future[A]]
+  )(implicit ec: ExecutionContext): Future[Option[A]] = x match {
+    case Some(f) => f.map(Some(_))
+    case None    => Future.successful(None)
+  }
+
+  override def findAuthInfoByRefreshToken(token: String) =
+    withCollection(Authorization) {auths =>
+      auths.find(equal("refreshToken", Authorization.decodeToken(token)))
+        .first()
+        .toFutureOption()
+    }.flatten
+      .map(authOpt => withCollection(User) {users =>
+        optFut2FutOpt(authOpt.map({ auth => users.find(equal("_id", auth.userId))
+          .first()
+          .toFuture()
+          .map({user => AuthInfo(user, Some(user.email), None, None)})
+        }))
+      }.flatten).flatten
+    
+  override def refreshAccessToken(
+    auth: AuthInfo[User],
+    refreshToken: String
+  ): Future[AccessToken] = {
+    val newAuth = Authorization(auth.user._id, auth.user.email)
+    withCollection(Authorization) {auths =>
+      auths.replaceOne(
+        and(
+          equal("userId", auth.user._id),
+          equal("refreshToken", Authorization.decodeToken(refreshToken))
+        ),
+        newAuth
+      ).head().map(_ => newAuth.toToken())
+    }.flatten
+  }
+
+  def revokeAccessToken(
+    auth: AuthInfo[Authorization],
+    user: String,
+    refresh: String
+  ): Future[Unit] = 
+    if (user != auth.user.email)
+      Future.failed(
+        new InvalidClient("Invalid client or client is not authorized")
+      )
+    else withCollection(Authorization) {auths =>
+      auths.deleteOne(and(
+        equal("userId", auth.user.userId),
+        equal("refreshToken", Authorization.decodeToken(refresh))
+      )).head().map { _ => {} }
+    }.flatten
+
+  override def deleteAuthCode(token: String): Future[Unit] =
+    throw new UnsupportedGrantType("Code grant authorizations are not supported.")
+
+  override def findAuthInfoByCode(
+    code: String
+  ): Future[Option[AuthInfo[User]]] =
+    throw new UnsupportedGrantType("Code grant authorizations are not supported.")
+}

+ 30 - 0
server/app/com/weEat/util/TryWith.scala

@@ -0,0 +1,30 @@
+package com.weEat.util
+
+import scala.util.control.NonFatal
+import scala.util.{Failure, Try}
+
+/* Adopted with minor changes from:
+ * https://codereview.stackexchange.com/questions/79267/scala-trywith-that-closes-resources-automatically
+ * Written by Stack Overflow User Morgan
+ */
+object TryWith {
+  def apply[C <: AutoCloseable, R](resource: => C)(f: C => R): Try[R] =
+    Try(resource).flatMap(resourceInstance => {
+      try {
+        val returnValue = f(resourceInstance)
+        Try(resourceInstance.close()).map(_ => returnValue)
+      }
+      catch {
+        case NonFatal(exceptionInFunction) =>
+          try {
+            resourceInstance.close()
+            Failure(exceptionInFunction)
+          }
+          catch {
+            case NonFatal(exceptionInClose) =>
+              exceptionInFunction.addSuppressed(exceptionInClose)
+              Failure(exceptionInFunction)
+          }
+      }
+    })
+}

+ 6 - 0
server/app/views/index.scala.html

@@ -0,0 +1,6 @@
+@()(implicit webJarsUtil: org.webjars.play.WebJarsUtil, request: RequestHeader)
+
+@main("Home") {
+<h1>Hello!</h1>
+<h2>Welcome to my recipe book!</h2>
+}

+ 69 - 0
server/app/views/main.scala.html

@@ -0,0 +1,69 @@
+@(title: String)(content: Html, lastly: Html = Html(""))(implicit webJarsUtil: org.webjars.play.WebJarsUtil, request: RequestHeader)
+<!DOCTYPE html>
+<html>
+<head>
+  <title>Open Sauce - @title</title>
+  <link rel="shortcut icon" type="image/png"
+	    href="@routes.Assets.versioned("images/favicon.png")">
+  @webJarsUtil.locate("jquery.min.js").script()
+  @webJarsUtil.locate("bootstrap.min.css").css()
+  @webJarsUtil.locate("bootstrap.min.js").script()
+</head>
+<body>
+  <header class="page-header">
+    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
+      <a class="navbar-brand" href="#">Sample Web Page</a>
+      <button class="navbar-toggler" type="button" data-toggle="collapse"
+              data-target="#navbarNav" aria-controls="navbarNav"
+              aria-expanded="false" aria-label="Toggle navigation">
+        <span class="navbar-toggler-icon"></span>
+      </button>
+      <div class="collapse navbar-collapse" id="navbarNav">
+        <a href="#" class="navbar-brand"></a>
+        <ul class="navbar-nav mr-auto mt-2 mt-lg-0">
+          <li class="nav-item active">
+            <a class="nav-link" href="#">Home</a>
+          </li>
+          <li class="nav-item">
+            <a class="nav-link" href="#">Basic Foods</a>
+          </li>
+          <li class="nav-item">
+            <a class="nav-link" href="#">Recipes</a>
+          </li>
+        </ul>
+        <ul id="login-btns" class="navbar-nav">
+          <li class="nav-item">
+            <a class="nav-link" href="#" id="btn-login">Login</a>
+          </li>
+          <li class="nav-item">
+            <a class="nav-link" href="#" id="btn-signup">Signup</a>
+          </li>
+        </ul>
+        <ul id="logout-btns" class="navbar-nav" style="display: none;">
+          <li class="nav-item">
+            <a class="nav-link" href="#" id="btn-logout">Logout</a>
+          </li>
+        </ul>
+      </div>
+    </nav>
+  </header>
+  <div class="section container">
+    @content
+  </div>
+  <!-- Creates a CSRF token, required by Play for non-GET requests.  Token is
+       embedded in a hidden input tag. -->
+  @helper.CSRF.formField
+  <!-- Load the script.  Must come after CSRF token is defined because the script
+       will read it upon startup. -->
+  @scalajs.html.scripts(
+    "client",
+    routes.Assets.versioned(_).toString,
+    name => getClass.getResource(s"/public/$name") != null
+  )
+  <script>
+    // Selenium can access vars, but not lets
+    var selAsyncCount = asyncCount;
+  </script>
+  @lastly
+</body>
+</html>

+ 6 - 0
server/app/views/recipieEdit.scala.html

@@ -0,0 +1,6 @@
+@()(implicit webJarsUtil: org.webjars.play.WebJarsUtil, request: RequestHeader)
+
+@main("Home") {
+<h1>Hello!</h1>
+<h2>Welcome to my recipe book!</h2>
+}

+ 9 - 0
server/app/views/usdaImport.scala.html

@@ -0,0 +1,9 @@
+@()(implicit webJarsUtil: org.webjars.play.WebJarsUtil, request: RequestHeader)
+
+@main("USDA Import") {
+  <h2>USDA Importer</h2>
+  <div id="importPane"></div>
+}
+
+<script>UsdaImporter.render(document.getElementById("importPane"))</script>
+

+ 46 - 0
server/conf/application.conf

@@ -0,0 +1,46 @@
+# https://www.playframework.com/documentation/latest/Configuration
+
+# Secret key
+# ~~~~~
+# The secret key is used to secure cryptographics functions.
+# If you deploy your application to several instances be sure to use the same key!
+#play.crypto.secret="CHANGEME!"
+
+# The application languages
+# ~~~~~
+play.i18n.langs=["en"]
+
+# Database configuration
+# ~~~~~ 
+# You can declare as many datasources as you want.
+# By convention, the default datasource is named `default`
+#
+db.default.driver=org.h2.Driver
+db.default.url="jdbc:h2:mem:users"
+
+mongo.user="application"
+mongo.password="test"
+mongo.ssl=false
+
+fdc.apikey="0Ky5r8VEcd0v9z4eJsNZoDNguKWzGZ0iCo4RpOJN"
+
+# Filters
+# ~~~~~
+play.filters.enabled=[
+    "play.filters.cors.CORSFilter",
+    "play.filters.headers.SecurityHeadersFilter",
+#    "play.filters.hosts.AllowedHostsFilter",
+    "play.filters.csrf.CSRFFilter"
+]
+play.filters.cors {
+  pathPrefixes = ["/"]
+  #allowedOrigins = ["http://localhost:9000"]
+  allowedOrigins = null
+}
+play.filters.csrf {
+  bypassCorsTrustedOrigins = true
+}
+
+# Modules
+# ~~~~~
+#play.modules.enabled += "play.modules.swagger.SwaggerModule"

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
server/conf/db-seeds/nutrients.json


+ 43 - 0
server/conf/logback.xml

@@ -0,0 +1,43 @@
+<!-- https://www.playframework.com/documentation/latest/SettingsLogger -->
+<!-- Docs: http://logback.qos.ch/ -->
+<configuration>
+
+  <conversionRule conversionWord="coloredLevel" converterClass="play.api.libs.logback.ColoredLevel" />
+
+  <appender name="FILE" class="ch.qos.logback.core.FileAppender">
+    <file>${application.home:-.}/logs/application.log</file>
+    <encoder>
+      <charset>UTF-8</charset>
+      <pattern>
+        %d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{akkaSource}) %msg%n
+      </pattern>
+    </encoder>
+  </appender>
+
+  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+    <withJansi>true</withJansi>
+    <encoder>
+      <charset>UTF-8</charset>
+      <pattern>
+        %d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{akkaSource}) %msg%n
+      </pattern>
+    </encoder>
+  </appender>
+
+  <appender name="ASYNCFILE" class="ch.qos.logback.classic.AsyncAppender">
+    <appender-ref ref="FILE" />
+  </appender>
+
+  <appender name="ASYNCSTDOUT" class="ch.qos.logback.classic.AsyncAppender">
+    <appender-ref ref="STDOUT" />
+  </appender>
+
+  <logger name="play" level="INFO" />
+  <logger name="application" level="DEBUG" />
+
+  <root level="WARN">
+    <!--<appender-ref ref="ASYNCFILE" />-->
+    <appender-ref ref="ASYNCSTDOUT" />
+  </root>
+
+</configuration>

+ 1 - 0
server/conf/messages

@@ -0,0 +1 @@
+# https://www.playframework.com/documentation/latest/ScalaI18N

+ 75 - 0
server/conf/routes

@@ -0,0 +1,75 @@
+# Routes
+# This file defines all application routes (Higher priority routes first)
+# https://www.playframework.com/documentation/latest/ScalaRouting
+# ~~~~
+
+GET   /               com.weEat.controllers.ViewController.index()
+
+GET   /views/import   com.weEat.controllers.ViewController.usdaImport()
+
+# Shared Route
+# body: com.weEat.shared.models.UserRegistration
+# type: com.weEat.shared.models.UserAuthorization
+PUT   /user/          com.weEat.controllers.UserController.registerUser()
+
+# Shared Route
+# type: Seq[String]
+GET   /user/           com.weEat.controllers.UserController.getUsers()
+
+# Shared Route
+# body: com.weEat.shared.models.GrantRequest
+# type: com.weEat.shared.models.UserAuthorization
+POST  /authorize/     com.weEat.controllers.UserController.accessToken()
+
+# Shared Route
+# body: com.weEat.shared.models.RefreshRequest
+DELETE /authorize/   com.weEat.controllers.UserController.revokeAccessToken()
+
+# Shared Route
+# mime: text/plain
+# type: String
+GET   /user/self/name/ com.weEat.controllers.UserController.getName()
+
+# Shared Route
+# body: com.weEat.shared.models.FoodNode
+# type: com.weEat.shared.models.FoodNode
+PUT  /food/     com.weEat.controllers.FoodController.add()
+
+# Shared Route
+# type: Seq[com.weEat.shared.models.FoodNode]
+GET  /food/     com.weEat.controllers.FoodController.query()
+
+# Shared Route
+# type: com.weEat.shared.models.FoodNode
+GET  /food/:id  com.weEat.controllers.FoodController.get(id: String)
+
+# Shared Route
+# type: gov.usda.nal.fdc.models.FoodItem
+GET   /fdc/food   com.weEat.controllers.USDAController.getFoods(id: String, fmt: String ?= "Full")
+
+# Shared Route
+# type: gov.usda.nal.fdc.models.SearchResult
+GET   /fdc/food/search   com.weEat.controllers.USDAController.getFoodsSearch(q: String, dataType: Seq[String] ?= Nil, pageSize: Option[Short] ?= None, pageNumber: Option[Long] ?= None, sortBy: Option[String] ?= None, sortOrder: Option[String] ?= None)
+
+# Shared Route
+# body: gov.usda.nal.fdc.models.FoodSearchCriteria
+# type: gov.usda.nal.fdc.models.SearchResult
+POST   /fdc/food/search   com.weEat.controllers.USDAController.postFoodsSearch()
+
+# Shared Route
+# type: Seq[gov.usda.nal.fdc.models.Nutrient]
+GET   /fdc/nutrient      com.weEat.controllers.USDAController.getNutrients()
+
+# Shared Route
+# type: gov.usda.nal.fdc.models.Nutrient
+GET   /fdc/nutrient/:id      com.weEat.controllers.USDAController.getNutrient(id: Int)
+
+# Shared Route
+# type: gov.usda.nal.fdc.models.FoodItem
+GET   /fdc/food/:id   com.weEat.controllers.USDAController.getFood(id: Long, fmt: String ?= "Full")
+
+# Map static resources from the /public folder to the /assets URL path
+GET   /assets/*file   controllers.Assets.versioned(path="/public", file: Asset)
+
+# Forward Webjar requests to the webjar routes
+->      /webjars                            webjars.Routes

+ 7 - 0
server/conf/testing.conf

@@ -0,0 +1,7 @@
+include "application.conf"
+
+# TODO: Fix CSRF in tests
+#play.filters.disabled += "play.filters.csrf.CSRFFilter"
+play.filters.csrf.header.bypassHeaders {
+  Csrf-Token = "nocheck"
+}

BIN
server/public/images/favicon.png


+ 0 - 0
server/public/stylesheets/main.css


+ 17 - 0
server/public/views/login.html

@@ -0,0 +1,17 @@
+<form class="text-right" id="view-login">
+  <div class="alert alert-danger" style="display: none; text-align: left;"></div>
+  <div class="input-group">
+    <input id="email" type="text" class="form-control" name="email"
+           placeholder="Email">
+  </div>
+  <div class="input-group mb-3">
+    <input id="password" type="password" class="form-control" name="password"
+           placeholder="Password">
+  </div>
+  <div class="btn-group btn-group-justified">
+    <button type="button" class="btn" data-cb="cancel">Cancel</button>
+    <button type="button" class="btn btn-success" data-cb="success">
+      Login
+    </button>
+  </div>
+</form>

+ 27 - 0
server/public/views/register.html

@@ -0,0 +1,27 @@
+<form class="text-right" id="view-register">
+  <div class="alert alert-danger" style="display: none; text-align: left;"></div>
+  <div class="input-group mb-3">
+    <input id="email" type="text" class="form-control" name="email"
+           placeholder="Email">
+  </div>
+  <div class="input-group">
+    <input id="password" type="password" class="form-control" name="password"
+           placeholder="Password">
+  </div>
+  <div class="input-group mb-3">
+    <input id="password2" type="password" class="form-control" name="password2"
+           placeholder="Repeat Password">
+  </div>
+  <div class="input-group mb-3">
+    <input id="fname" type="text" class="form-control" name="fname"
+           placeholder="First Name">
+    <input id="lname" type="text" class="form-control" name="lname"
+           placeholder="Last Name">
+  </div>
+  <div class="btn-group btn-group-justified">
+    <button type="button" class="btn" data-cb="cancel">Cancel</button>
+    <button type="button" class="btn btn-success" data-cb="success">
+      Register
+    </button>
+  </div>
+</form>

+ 536 - 0
server/test/ApplicationSpec.scala

@@ -0,0 +1,536 @@
+import play.api.test.Helpers._
+import play.api.test.{FakeRequest,WithApplication}
+import play.api.mvc.{Results,Headers}
+import play.api.libs.json.{JsObject,Json}
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.tagobjects.Slow
+import org.scalatestplus.play._
+import org.scalatestplus.play.guice.GuiceOneServerPerSuite
+import com.weEat.shared.models.{User,UserAuthorization}
+import scala.concurrent.duration._
+import java.util.Base64
+
+class OAuthSpec extends PlaySpec
+    with BeforeAndAfterAll
+    with Results
+    with GuiceOneServerPerSuite {
+
+  val email = "tuser@sample.org"
+  val password = "password"
+  val fname = "test"
+  val lname = "user"
+
+  val users = Seq(
+    (User("test", "user", "tuser@sample.org"), "password"),
+    (User("another", "user", "usert@sample.org"), "password")
+  )
+
+  implicit class CSRFWrapper[T](requ: FakeRequest[T]) {
+    def withCSRFToken() = requ.withHeaders(
+      requ.headers.add(("Csrf-Token", "nocheck"))
+    )
+  }
+
+  def makeAuthHeader(username: String, password: String) =
+    Headers(("Authorization" -> s"Basic ${new String(Base64.getEncoder.encode(s"$username:$password".getBytes))}"))
+
+  def makeAuthHeader(auth: UserAuthorization) =
+    Headers(("Authorization" -> s"${auth.tokenType} ${auth.accessToken}"))  
+
+  override def beforeAll() = {
+    for ((User(fname, lname, email), password) <- users) {
+      val Some(resp) = route(app, FakeRequest(PUT, "/user/").withJsonBody(
+        Json.parse(s"""|{
+                       |  "fname": "$fname",
+                       |  "lname": "$lname",
+                       |  "email": "$email",
+                       |  "password": "$password"
+                       |}""".stripMargin)
+      ).withCSRFToken())
+      status(resp) mustEqual OK
+    }
+  }
+
+  "the token endpoint" should {
+    "reject an empty request" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual BAD_REQUEST
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_request\", "+
+          "error_description=\"required parameter: grant_type\"")
+      )
+    }
+
+    "reject an improperly formatted request" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withBody("""{"grant_type" "password"}""")
+        .withHeaders(
+          makeAuthHeader(email, password).add(("Content-Type", "application/json"))
+        ).withCSRFToken()
+      )
+      status(resp) mustEqual BAD_REQUEST
+      // Won't get an OAuth response header.  Will fail before processing.
+    }
+
+    "reject a missing grant_type" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("{}"))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual BAD_REQUEST
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_request\", "+
+          "error_description=\"required parameter: grant_type\"")
+      )
+    }
+  }
+
+  "the token endpoint for password grants" should {
+    "accept a valid login" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual OK
+      contentAsJson(resp).as[UserAuthorization] must matchPattern {
+        case UserAuthorization(_, _, _, _) =>
+      }
+    }
+
+    "issue unique access tokens for successive logins" in {
+      val requ = FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      val Some(resp1) = route(app, requ)
+      val Some(resp2) = route(app, requ)
+      status(resp2) mustEqual OK
+      val resp1Auth = contentAsJson(resp1).as[UserAuthorization]
+      val resp2Auth = contentAsJson(resp2).as[UserAuthorization]
+      resp1Auth must matchPattern {
+        case UserAuthorization(_, _, _, _) =>
+      }
+      resp2Auth must matchPattern {
+        case UserAuthorization(_, _, _, _) =>
+      }
+      resp1Auth.accessToken mustNot equal(resp2Auth.accessToken)
+      resp1Auth.refreshToken mustNot equal(resp2Auth.refreshToken)
+    }
+
+    "reject an unregistered user" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(
+          "_"+users(0)._1.email,
+          "_"+users(0)._2
+        ))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\""))
+    }
+
+    "reject an incorrect password" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, "_"+users(0)._2))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+
+    "reject an incorrect username" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader("_"+users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+
+    "reject an requests without Authorization headers" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""{"grant_type": "password"}""")
+      ).withCSRFToken())
+      status(resp) mustEqual BAD_REQUEST
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_request\", "+
+          "error_description=\"Client credential is not found\"")
+      )
+    }
+  }
+
+  "the token endpoint for refresh grants" should {
+    "accept a given refresh token" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      ).get).as[UserAuthorization]
+
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual OK
+      val newAuth = contentAsJson(resp).as[UserAuthorization]
+      newAuth must matchPattern {
+        case UserAuthorization(_, _, _, _) =>
+      }
+      newAuth.accessToken mustNot equal(auth.accessToken)
+      newAuth.refreshToken mustNot equal(auth.refreshToken)
+    }
+
+    "reject a given refresh token on the second attempt" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      ).get).as[UserAuthorization]
+
+      val Some(resp1) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken()
+      )
+      status(resp1) mustEqual OK
+      contentAsJson(resp1).as[UserAuthorization] must matchPattern {
+        case UserAuthorization(_, _, _, _) =>
+      }
+      val Some(resp2) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken()
+      )
+      status(resp2) mustEqual UNAUTHORIZED
+      headers(resp2) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+
+    "reject a revoked refresh token" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      ).get).as[UserAuthorization]
+      val Some(resp1) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken()
+      )
+      status(resp1) mustEqual OK
+      val Some(resp2) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken()
+      )
+      status(resp2) mustEqual UNAUTHORIZED
+      headers(resp2) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+
+    "reject a given refresh token with an incorrect username" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      ).get).as[UserAuthorization]
+
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "_${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+
+    "reject an unprovided refresh token" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      ).get).as[UserAuthorization]
+      val fakeToken = (auth.refreshToken.charAt(0)+1) +
+        auth.refreshToken.substring(1);
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "$fakeToken"
+                                     |}""".stripMargin))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+  }
+
+  "the secured endpoint" should {
+    "accept an authorized request" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(email, password))
+        .withCSRFToken()
+      ).get).as[UserAuthorization]
+      val Some(resp) = route(app, FakeRequest(GET, "/user/self/name/")
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual OK
+      contentAsString(resp) must
+        equal(s"${users(0)._1.fname} ${users(0)._1.lname}")
+    }
+
+    "reject an unauthorized request" in {
+      val Some(resp) = route(app, FakeRequest(GET, "/user/self/name/")
+        .withCSRFToken()
+      )
+      status(resp) mustEqual BAD_REQUEST
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_request\", "+
+          "error_description=\"Access token is not found\"")
+      )
+    }
+
+    "reject an forged authorized request" in {
+      val fakeToken = "7pKNy790TV5lKVjQw3k/pwJmMS8XBhHaLTVaI6ftd5M="
+      val Some(resp) = route(app, FakeRequest(GET, "/user/self/name/")
+        .withHeaders(("Authorization" -> s"Bearer $fakeToken"))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_token\", "+
+          "error_description=\"The access token is not found\"")
+      )
+    }
+
+    "reject a refreshed access token" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      ).get).as[UserAuthorization]
+      val Some(resp1) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken()
+      )
+      status(resp1) mustEqual OK
+      val Some(resp2) = route(app, FakeRequest(GET, "/user/self/name/")
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken()
+      )
+      status(resp2) mustEqual UNAUTHORIZED
+      headers(resp2) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_token\", "+
+          "error_description=\"The access token is invalid\"")
+      )
+    }
+
+    "reject a revoked access token" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      ).get).as[UserAuthorization]
+      val  Some(resp1) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken()
+      )
+      status(resp1) mustEqual OK
+      val Some(resp2) = route(app, FakeRequest(GET, "/user/self/name/")
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken()
+      )
+      status(resp2) mustEqual UNAUTHORIZED
+      headers(resp2) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_token\", "+
+          "error_description=\"The access token is invalid\"")
+      )
+    }
+
+    /*
+    "reject an expired access token" taggedAs(Slow) in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(email, password))
+      ).get).as[UserAuthorization]
+      Thread.sleep((1 hour).toMillis)
+      val Some(resp) = route(app, FakeRequest(GET, "/user/self/name/")
+        .withHeaders(makeAuthHeader(auth)))
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_token\", "+
+          "error_description=\"The access token is invalid\"")
+      )
+    }
+    */
+  }
+
+  "the token endpoint for revokation requests" should {
+    "accept a valid request" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      ).get).as[UserAuthorization]
+      val Some(resp) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual OK
+      contentAsString(resp) mustEqual("")
+    }
+
+    "reject a request with an invalid authorization header" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      ).get).as[UserAuthorization]
+      val fakeToken = (auth.accessToken.charAt(0)+1) +
+        auth.accessToken.substring(1);
+      val Some(resp) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withHeaders("Authorization" -> s"Bearer $fakeToken")
+        .withCSRFToken()
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_token\", "+
+          "error_description=\"The access token is not found\"")
+      )
+    }
+
+    "reject a request without an authorization header" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      ).get).as[UserAuthorization]
+      val Some(resp) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual BAD_REQUEST
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_request\", "+
+          "error_description=\"Access token is not found\"")
+      )
+    }
+
+    "reject a request with an unissued refresh token" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      ).get).as[UserAuthorization]
+      val fakeToken = (auth.refreshToken.charAt(0)+1) +
+        auth.refreshToken.substring(1);
+      val Some(resp) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${fakeToken}"
+                                     |}""".stripMargin))
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_token\", "+
+          "error_description=\"The access token is invalid\"")
+      )
+    }
+
+    "reject a request with an incorrect username" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken()
+      ).get).as[UserAuthorization]
+      val Some(resp) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "_${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken()
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+  }
+}

+ 371 - 0
server/test/IntegrationSpec.scala

@@ -0,0 +1,371 @@
+import play.api.test.Helpers._
+import play.api.test.{FakeRequest}
+import play.api.libs.json.{JsObject,Json}
+import org.openqa.selenium.By
+import org.scalatest.{Assertion,BeforeAndAfterAll,BeforeAndAfterEach}
+import org.scalatestplus.play._
+import org.scalatestplus.play.guice.GuiceOneServerPerSuite
+import com.weEat.shared.models.User
+import scala.concurrent.{ExecutionContext,Future,blocking}
+import scala.concurrent.duration._
+import scala.language.postfixOps
+import java.util.concurrent.TimeoutException
+
+class IntegrationSpec extends PlaySpec
+    with BeforeAndAfterAll
+    with BeforeAndAfterEach
+    with GuiceOneServerPerSuite
+    with AllBrowsersPerTest {
+
+  val users = Seq(
+    (User("test", "user", "tuser@sample.org"), "password"),
+    (User("another", "user", "usert@sample.org"), "password")
+  )
+
+  override def beforeAll() = {
+    val Some(resp) = route(app, FakeRequest(PUT, "/user/").withJsonBody(
+      Json.parse(s"""|{
+                     |  "fname": "${users(0)._1.fname}",
+                     |  "lname": "${users(0)._1.lname}",
+                     |  "email": "${users(0)._1.email}",
+                     |  "password": "${users(0)._2}"
+                     |}""".stripMargin)
+    ))
+    status(resp) mustEqual OK
+  }
+
+  override lazy val browsers = Vector(
+    FirefoxInfo(firefoxProfile),
+    //ChromeInfo
+  )
+
+  val address = s"http://localhost:$port"
+
+  /* Will repeatedly attempt to test until a result is returned.
+   * A None value implies the test is not yet ready to run and will be attempted
+   * again after the frequency has passed..
+   * 
+   * A Some value means the test has completed with the contained assertion
+   * result.
+   */
+  def poll(
+    test: () => Option[Assertion],
+    frequency: Duration = 100.millisecond
+  ): Future[Assertion] = {
+    test() match {
+      case Some(result) => Future.successful(result)
+      case None => {
+        blocking {
+          Thread.sleep(frequency.toMillis)
+        }
+        poll(test, frequency)
+      }
+    }
+  }
+
+  def waitForReady[T](
+    timeout: Duration = 1 minute,
+    period: Duration = 100 millisecond
+  )(fn: => T): Future[T] =
+    if (executeScript("return jQuery.active == 0 && selAsyncCount == 0;").asInstanceOf[Boolean])
+      Future.successful(fn)
+    else if (timeout < 0.second)
+      Future.failed(new TimeoutException())
+    else {
+      blocking {
+        Thread.sleep(period.toMillis)
+      }
+      waitForReady(timeout - period, period)(fn)
+    }
+
+  def sharedTests(browser: BrowserInfo) = {
+    "The register menu" must {
+      "display when register button is clicked " + browser.name in {
+        go to address
+        click on id("btn-signup")
+        waitForReady() {
+          click on id("view-register").webElement.findElement(By.xpath("./.."))
+          //id("content").webElement.getText mustEqual ("")
+        }
+      }
+
+      def testUserList() = {
+        val Some(resp) = route(app, FakeRequest(GET, "/user/"))
+        status(resp) mustEqual OK
+        contentAsJson(resp).as[Seq[String]] must matchPattern {
+          case Seq(user1, user2) =>
+        }
+      }
+
+      "accept a new user " + browser.name in {
+        go to address
+        click on id("btn-signup")
+        waitForReady() {
+          textField("email").value = users(1)._1.email
+          name("password").webElement.sendKeys(users(1)._2)
+          name("password2").webElement.sendKeys(users(1)._2)
+          textField("fname").value = users(1)._1.fname
+          textField("lname").value = users(1)._1.lname
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            find("#view-register") must be (None)
+            id("btn-logout").webElement must be ("displayed")
+            // id("content").webElement.getText
+            //   .mustEqual(s"Hello, ${users(1)._1.fname} ${users(1)._1.lname}!")
+            // testUserList()
+          }
+        }
+      }
+
+      "reject a used email " + browser.name in {
+        go to address
+        click on id("btn-signup")
+        waitForReady() {
+          textField("email").value = users(0)._1.email
+          name("password").webElement.sendKeys("_"+users(0)._2)
+          name("password2").webElement.sendKeys("_"+users(0)._2)
+          textField("fname").value = "_"+users(0)._1.fname
+          textField("lname").value = "_"+users(0)._1.lname
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: This email address is already registered.")
+            id("btn-logout").webElement mustNot be ("displayed")
+            //id("content").webElement.getText mustEqual("")
+            testUserList()
+          }
+        }
+      }
+
+      "reject an invalid email " + browser.name in {
+        go to address
+        click on id("btn-signup")
+        waitForReady() {
+          textField("email").value = "hello World!"
+          name("password").webElement.sendKeys("_"+users(0)._2)
+          name("password2").webElement.sendKeys("_"+users(0)._2)
+          textField("fname").value = "_"+users(0)._1.fname
+          textField("lname").value = "_"+users(0)._1.lname
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: Invalid email.")
+            id("btn-logout").webElement mustNot be ("displayed")
+            //id("content").webElement.getText mustEqual("")
+            testUserList()
+          }
+        }
+      }
+
+      "reject a password below 8 characters " + browser.name in {
+        go to address
+        click on id("btn-signup")
+        waitForReady() {
+          textField("email").value = "_"+users(0)._1.email
+          name("password").webElement.sendKeys("1234567")
+          name("password2").webElement.sendKeys("1234567")
+          textField("fname").value = "_"+users(0)._1.fname
+          textField("lname").value = "_"+users(0)._1.lname
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: Password too short (Minimum 8 characters).")
+            id("btn-logout").webElement mustNot be ("displayed")
+            //id("content").webElement.getText mustEqual("")
+            testUserList()
+          }
+        }
+      }
+
+      "reject a non-matching passwords " + browser.name in {
+        go to address
+        click on id("btn-signup")
+        waitForReady() {
+          textField("email").value = "_"+users(0)._1.email
+          name("password").webElement.sendKeys(users(0)._2)
+          name("password2").webElement.sendKeys("_"+users(0)._2)
+          textField("fname").value = "_"+users(0)._1.fname
+          textField("lname").value = "_"+users(0)._1.lname
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: Passwords do not match.")
+            id("btn-logout").webElement mustNot be ("displayed")
+            //id("content").webElement.getText mustEqual("")
+            testUserList()
+          }
+        }
+      }
+    }
+
+    "The login menu" must {
+      "display when login button is clicked " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          click on id("view-login").webElement.findElement(By.xpath("./.."))
+          id("btn-logout").webElement mustNot be ("displayed")
+          //id("content").webElement.getText mustEqual("")
+        }
+      }
+
+      "accept user created from API " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(0)._1.email
+          name("password").webElement.sendKeys(users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            find("#view-login") must be (None)
+            id("btn-logout").webElement must be ("displayed")
+            // id("content").webElement.getText
+            //   .mustEqual(s"Hello, ${users(0)._1.fname} ${users(0)._1.lname}!")
+          }
+        }
+      }
+
+      "accept user created from UI " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(1)._1.email
+          name("password").webElement.sendKeys(users(1)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            find("#view-login") must be (None)
+            id("btn-logout").webElement must be ("displayed")
+            // id("content").webElement.getText
+            //   .mustEqual(s"Hello, ${users(1)._1.fname} ${users(1)._1.lname}!")
+          }
+        }
+      }
+
+      "reject an incorrect email " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = "_"+users(1)._1.email
+          name("password").webElement.sendKeys(users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: Invalid client or client is not authorized")
+            id("btn-logout").webElement mustNot be ("displayed")
+            //id("content").webElement.getText mustEqual("")
+          }
+        }
+      }
+
+      "reject an incorrect password " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(1)._1.email
+          name("password").webElement.sendKeys("_"+users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: Invalid client or client is not authorized")
+            id("btn-logout").webElement mustNot be ("displayed")
+            //id("content").webElement.getText mustEqual("")
+          }
+        }
+      }
+
+      "reject an incorrect email and password " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = "_"+users(1)._1.email
+          name("password").webElement.sendKeys("_"+users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: Invalid client or client is not authorized")
+            id("btn-logout").webElement mustNot be ("displayed")
+            //id("content").webElement.getText mustEqual("")
+          }
+        }
+      }
+
+      "maintain login info across refresh " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(1)._1.email
+          name("password").webElement.sendKeys(users(1)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            go to address
+            waitForReady() {
+              find("#view-login") must be (None)
+                id("btn-logout").webElement must be ("displayed")
+              // id("content").webElement.getText
+              //   .mustEqual(s"Hello, ${users(1)._1.fname} ${users(1)._1.lname}!")
+              id("btn-logout").webElement must be ("displayed")
+            }
+          }
+        }
+      }
+    }
+
+    "The logout button" must {
+      "not display when not logged in " + browser.name in {
+        go to address
+        waitForReady() {
+          id("btn-logout").webElement mustNot be ("displayed")
+          //id("content").webElement.getText mustEqual("")
+        }
+      }
+
+      "display when logged in" + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(0)._1.email
+          name("password").webElement.sendKeys(users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            id("btn-logout").webElement must be ("displayed")
+          }
+        }
+      }
+
+      "remove user information when clicked " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(0)._1.email
+          name("password").webElement.sendKeys(users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            click on id("btn-logout")
+            waitForReady() {
+              id("btn-logout").webElement mustNot be ("displayed")
+              //id("content").webElement.getText mustEqual("")
+            }
+          }
+        }
+      }
+
+      "remove stored user information when clicked " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(0)._1.email
+          name("password").webElement.sendKeys(users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            click on id("btn-logout")
+            waitForReady() {
+              go to address
+              id("btn-logout").webElement mustNot be ("displayed")
+              //id("content").webElement.getText mustEqual("")
+            }
+          }
+        }
+      }
+    }
+  }
+}

+ 8 - 0
shared/js/src/main/scala/com/weEat/shared/models/Identifier.scala

@@ -0,0 +1,8 @@
+package com.weEat.shared.models
+
+object IdentifierHelper {
+  type Identifier = String
+
+  import play.api.libs.json.Format
+  implicit val fmt = Format.of[String]
+}

+ 13 - 0
shared/jvm/src/main/scala/com/weEat/shared/models/Identifier.scala

@@ -0,0 +1,13 @@
+package com.weEat.shared.models
+
+import org.bson.types.ObjectId
+
+object IdentifierHelper {
+  type Identifier = ObjectId
+
+  import play.api.libs.functional.syntax._
+  import play.api.libs.json.Format
+
+  implicit val fmt = Format.of[String]
+    .inmap[ObjectId](new ObjectId(_), _.toHexString())
+}

+ 5 - 0
shared/shared/src/main/scala/com/weEat/shared/exceptions/IncompleteDataException.scala

@@ -0,0 +1,5 @@
+package com.weEat.shared.exceptions
+
+class IncompleteDataException(val field: String) extends RuntimeException {
+  override def getMessage = s"Could not preform action do to missing $field information."
+}

+ 157 - 0
shared/shared/src/main/scala/com/weEat/shared/models/FoodNode.scala

@@ -0,0 +1,157 @@
+package com.weEat.shared.models
+
+import UnitType._
+import scala.util.{Try,Success,Failure}
+import scala.concurrent.Future
+import com.weEat.shared.exceptions._
+import gov.usda.nal.fdc.models.{SearchResultFood,FullFoodItem,FoodPortion}
+import gov.usda.nal.fdc.controllers.FoodController
+import gov.usda.nal.fdc.models.DataType._
+import play.api.libs.json.Json
+import com.weEat.shared.models.IdentifierHelper._
+
+sealed trait FoodNode {
+  import FoodNode.NodeType._
+
+  def _id: Option[Identifier]
+  def nodeType: NodeType
+  def defaultUnit: UnitType = MASS
+  def density: Option[Float] = None
+  def massPerUnit: Option[Float] = None
+  def withId(id: Identifier): FoodNode
+}
+
+case class RecipeNode(
+  val _id: Option[Identifier],
+  val name: String,
+  val ingredients: Seq[RecipeNode.Ingredient],
+  val steps: Seq[String]
+) extends FoodNode {
+  val nodeType = FoodNode.NodeType.RECIPE
+
+  def withId(id: Identifier) = RecipeNode(Some(id), name, ingredients, steps)
+}
+
+object RecipeNode {
+
+  implicit val idFmt = com.weEat.shared.models.IdentifierHelper.fmt
+
+  case class Ingredient(val id: Identifier, val amount: Float) {
+    lazy val food: FoodNode = ???
+
+    private def convert(
+      amount: Double,
+      typ: UnitType,
+      to: MeasureUnit
+    ): Try[Double] = (typ, to.typ) match {
+      case (fTyp, tTyp) if fTyp == tTyp => Success(amount * to.conversionRatio)
+      case (MASS, VOLUME) => food.density match {
+        case Some(density) => convert(amount / density, VOLUME, to)
+        case None => Failure(new IncompleteDataException("density"))
+      }
+      case (MASS, NUMBER) => food.massPerUnit match {
+        case Some(massPerUnit) => convert(amount / massPerUnit, NUMBER, to)
+        case None => Failure(new IncompleteDataException("mass/unit"))
+      }
+      case (VOLUME, _) => food.density match {
+        case Some(density) => convert(amount * density, MASS, to)
+        case None => Failure(new IncompleteDataException("density"))
+      }
+      case (NUMBER, _) => food.density match {
+        case Some(massPerUnit) => convert(amount * massPerUnit, MASS, to)
+        case None => Failure(new IncompleteDataException("mass/unit"))
+      }
+    }
+
+    def to(unit: MeasureUnit) = convert(amount, food.defaultUnit, unit)
+  }
+
+  object Ingredient {
+    implicit val ingredFmt = Json.using[Json.WithDefaultValues].format[Ingredient]
+  }
+
+  import play.api.libs.json.{JsonConfiguration,JsonNaming}
+
+  implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[RecipeNode]
+}
+
+case class USDANode(
+  val _id: Option[Identifier],
+  val name: String,
+  val fdcId: Long,
+  override val density: Option[Float],
+  override val massPerUnit: Option[Float],
+  override val defaultUnit: UnitType,
+  val calories: Float,
+  val nutrients: Map[String, Float]
+) extends FoodNode {
+  val nodeType = FoodNode.NodeType.USDA
+
+  def withId(id: Identifier) = USDANode(
+    Some(id),
+    name,
+    fdcId,
+    density,
+    massPerUnit,
+    defaultUnit,
+    calories,
+    nutrients
+  )
+}
+
+object USDANode {
+  val kcalNutrientId = 1008
+
+  def fromSearchResult(usda: SearchResultFood) = USDANode(
+    None,
+    usda.description,
+    usda.fdcId,
+    None,
+    None,
+    MASS,
+    usda.foodNutrients.find(_.nutrientId == kcalNutrientId).map(_.value)
+          .getOrElse(0.0f),
+    usda.foodNutrients.map(x => (x.nutrientNumber, x.value)).toMap
+  )
+
+  import play.api.libs.functional.syntax._
+  import play.api.libs.json.{Format,JsonConfiguration,JsonNaming}
+
+  implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
+  implicit val idFmt = com.weEat.shared.models.IdentifierHelper.fmt
+  implicit val fmt = Json.using[Json.WithDefaultValues].format[USDANode]
+}
+
+object FoodNode {
+  val collectionName = "foods"
+
+  object NodeType extends Enumeration {
+    type NodeType = Value
+    val USDA, RECIPE = Value
+
+    import play.api.libs.json.{Reads,Writes}
+    implicit val rds = Reads.enumNameReads(NodeType)
+    implicit val wts = Writes.enumNameWrites[this.type]
+  }
+
+  import NodeType._
+  import play.api.libs.functional.syntax._
+  import play.api.libs.json._
+  import java.text.ParseException
+
+  implicit val requestWrites = Writes[FoodNode](requ =>
+    (requ match {
+      case node: USDANode => Json.toJson(node)
+      case node: RecipeNode => Json.toJson(node)
+    }).asInstanceOf[JsObject] + ("node_type", Json.toJson(requ.nodeType))
+  )
+
+  implicit val requestReads = Reads[FoodNode](json =>
+    (json \ "node_type").as[NodeType] match {
+      case USDA => Json.fromJson[USDANode](json)
+      case RECIPE => Json.fromJson[RecipeNode](json)
+      case _ => ???
+    }
+  )
+}

+ 90 - 0
shared/shared/src/main/scala/com/weEat/shared/models/GrantRequest.scala

@@ -0,0 +1,90 @@
+package com.weEat.shared.models
+
+import play.api.libs.json.{Format,Json}
+
+/* Information to receive an access token */
+sealed trait GrantRequest {
+  def grantType: String
+  def scope: Option[String]
+}
+
+case class GrantError (
+  val error: String,
+  val errorDescription: String
+)
+
+case class PasswordRequest (
+  val scope: Option[String] = None
+) extends GrantRequest {
+  val grantType = "password"
+}
+
+/* Information to receive an access token */
+case class RefreshRequest (
+  val clientId: String,
+  val refreshToken: String,
+  val scope: Option[String] = None
+) extends GrantRequest {
+  val grantType = "refresh_token"
+}
+
+/* Access token response */
+case class UserAuthorization (
+  //val scope: String,
+  val accessToken: String,
+  val tokenType: String,
+  val expiresIn: scala.concurrent.duration.FiniteDuration,
+  val refreshToken: String
+)
+
+object GrantError {
+  import play.api.libs.json.{JsonConfiguration,JsonNaming}
+
+  implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
+  implicit val errorormat = Json.format[GrantError]
+}
+
+object PasswordRequest {
+  import play.api.libs.json.{JsonConfiguration,JsonNaming}
+
+  implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
+  implicit val requestFormat = Json.format[PasswordRequest]
+}
+
+object RefreshRequest {
+  import play.api.libs.json.{JsonConfiguration,JsonNaming}
+
+  implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
+  implicit val requestFormat = Json.format[RefreshRequest]
+}
+
+object GrantRequest {
+  import play.api.libs.functional.syntax._
+  import play.api.libs.json.{JsObject,Reads,Writes}
+
+  implicit val requestWrites = Writes[GrantRequest](requ =>
+    (requ match {
+      case pass: PasswordRequest => Json.toJson(pass)
+      case refr: RefreshRequest => Json.toJson(refr)
+    }).asInstanceOf[JsObject] + ("grant_type", Json.toJson(requ.grantType))
+  )
+
+  implicit val requestReads = Reads[GrantRequest](json =>
+    (json \ "grant_type").as[String] match {
+      case "password" => Json.fromJson[PasswordRequest](json)
+      case "refresh_token" => Json.fromJson[RefreshRequest](json)
+      case _ => ???
+    }
+  )
+}
+
+object UserAuthorization {
+  import scala.concurrent.duration._
+  import play.api.libs.functional.syntax._
+  import play.api.libs.json.{JsonConfiguration,JsonNaming}
+
+  implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
+  implicit val durationFormat = Format.of[Long]
+    .inmap[FiniteDuration](_.seconds, _.toSeconds)
+  implicit val authorizationFormat = Json.format[UserAuthorization]
+}

+ 231 - 0
shared/shared/src/main/scala/com/weEat/shared/models/MeasureUnit.scala

@@ -0,0 +1,231 @@
+package com.weEat.shared.models
+
+sealed trait MeasureUnit {
+  def typ: UnitType.UnitType
+  /* defaultUnit/thisUnit = conversionRatio */
+  def conversionRatio: Double
+  def name: String
+  def abr = name
+  def altNames: Seq[String] = Nil
+
+  def names = (name +: abr +: altNames).filterNot(_.length == 0)
+  def lNames = names.map(_.toLowerCase)
+}
+
+object MeasureUnit {
+  val units = Seq(
+    Count,
+    Gram,
+    Kilogram,
+    OunceUS,
+    PoundUS,
+    Milliliter,
+    Liter,
+    FluidOunceUS,
+    TeaspoonUS,
+    TablespoonUS,
+    CupUS,
+    // LiquidPintUS,
+    // LiquidQuartUS,
+    GallonUS,
+    // DryPintUS,
+    // DryQuartUS,
+    Dozen
+  )
+
+  private def matchDegree(haystack: String)(needle: String) = {
+    val startsWithNeedle = s"$needle[^\\w]".r
+    val containsNeedle = s"[^\\w]$needle[^\\w]".r
+    if (needle == haystack || haystack.startsWith(s"1 $needle")) Some(100000)
+    else if (startsWithNeedle.findFirstIn(haystack).nonEmpty) Some(10000)
+    else if (containsNeedle.findFirstIn(haystack).nonEmpty)
+      Some(haystack.length - needle.length)
+    else None
+  }
+
+  def guessUnit(str: String) = {
+    val matchFn = matchDegree(str.toLowerCase)(_)
+    units.maxByOption(u => u.lNames.maxByOption(matchFn).flatMap(matchFn))
+  }
+
+  def closestPair[T](seq: Seq[(T, MeasureUnit, String)]) = seq.maxByOption(p =>
+    p._2.lNames.flatMap(matchDegree(p._3.toLowerCase)(_)).maxOption
+  )
+}
+
+// object UnitType extends Enumeration {
+//   type UnitType = Value
+
+//   val MASS, VOLUME, NUMBER = Value
+
+//   def defaultUnit(typ: UnitType) = typ match {
+//     case MASS => Gram
+//     case VOLUME => Milliliter
+//     case NUMBER => Count
+//   }
+
+//   import play.api.libs.json.{Reads,Writes}
+//   implicit val rds = Reads.enumNameReads(UnitType)
+//   implicit val wts = Writes.enumNameWrites[this.type]
+// }
+
+object UnitType {
+  sealed abstract class UnitType(val id: Int, val name: String) {
+    override def toString() = name
+  }
+
+  case object MASS extends UnitType(1, "MASS")
+  case object VOLUME extends UnitType(2, "VOLUME")
+  case object NUMBER extends UnitType(3, "NUMBER")
+
+  def defaultUnit(typ: UnitType) = typ match {
+    case MASS => Gram
+    case VOLUME => Milliliter
+    case NUMBER => Count
+  }
+
+  import play.api.libs.json.{Json,Reads,Writes}
+  import julienrf.json.derived
+
+  implicit val fmt = derived.oformat[UnitType.UnitType]()
+
+  /* Below are substitutes for the provided Enumeration features */
+
+  final def maxId = 3
+
+  final def apply(x: Int) = x match {
+    case 0 => MASS
+    case 1 => VOLUME
+    case 2 => NUMBER
+    case _ => ???
+  }
+
+  def values = Set(MASS, VOLUME, NUMBER)
+
+  final def withName(s: String) = s match {
+    case MASS.name => MASS
+    case VOLUME.name => VOLUME
+    case NUMBER.name => NUMBER
+    case _ => ???
+  }
+}
+
+import UnitType._
+
+object Gram extends MeasureUnit {
+  override val typ = MASS
+  override val conversionRatio = 1.0
+  override val name = "gram"
+  override val abr = "g"
+}
+
+object Kilogram extends MeasureUnit {
+  override val typ = MASS
+  override val conversionRatio = 1000.0
+  override val name = "Kilogram"
+  override val abr = "kg"
+}
+
+object OunceUS extends MeasureUnit {
+  override val typ = MASS
+  override val conversionRatio = 28.3495
+  override val name = "Ounce"
+  override val abr = "oz"
+}
+
+object PoundUS extends MeasureUnit {
+  override val typ = MASS
+  override val conversionRatio = 453.5924
+  override val name = "Pound"
+  override val abr = "lb"
+}
+
+object Milliliter extends MeasureUnit {
+  override val typ = VOLUME
+  override val conversionRatio = 1.0
+  override val name = "milliliter"
+  override val abr = "mL"
+}
+
+object Liter extends MeasureUnit {
+  override val typ = VOLUME
+  override val conversionRatio = 1000.0
+  override val name = "Liter"
+  override val abr = "L"
+}
+
+object FluidOunceUS extends MeasureUnit {
+  override val typ = VOLUME
+  override val conversionRatio = 29.57353
+  override val name = "fluid ounce"
+  override val abr = "fl oz"
+}
+
+object TeaspoonUS extends MeasureUnit {
+  override val typ = VOLUME
+  override val conversionRatio = 4.92892
+  override val name = "teaspoon"
+  override val abr = "tsp"
+}
+
+object TablespoonUS extends MeasureUnit {
+  override val typ = VOLUME
+  override val conversionRatio = 14.78672
+  override val name = "Tablespoon"
+  override val abr = "Tbsp"
+}
+
+object CupUS extends MeasureUnit {
+  override val typ = VOLUME
+  override val conversionRatio = 236.5875
+  override val name = "cups"
+  override val abr = "cup"
+}
+
+object LiquidPintUS extends MeasureUnit {
+  override val typ = VOLUME
+  override val conversionRatio = 473.1765
+  override val name = "Pint"
+  override val abr = "pt"
+}
+
+object LiquidQuartUS extends MeasureUnit {
+  override val typ = VOLUME
+  override val conversionRatio = 946.3529
+  override val name = "Quart"
+  override val abr = "qt"
+}
+
+object GallonUS extends MeasureUnit {
+  override val typ = VOLUME
+  override val conversionRatio = 3785.412
+  override val name = "Gallon"
+  override val abr = "gl"
+}
+
+object DryPintUS extends MeasureUnit {
+  override val typ = VOLUME
+  override val conversionRatio = 550.6105
+  override val name = "Pint"
+  override val abr = "pt"
+}
+
+object DryQuartUS extends MeasureUnit {
+  override val typ = VOLUME
+  override val conversionRatio = 1101.221
+  override val name = "Quart"
+  override val abr = "qt"
+}
+
+object Count extends MeasureUnit {
+  override val typ = NUMBER
+  override val conversionRatio = 1.0
+  override val name = ""
+  override val altNames: Seq[String] = Seq("whole", "individual")
+}
+
+object Dozen extends MeasureUnit {
+  override val typ = NUMBER
+  override val conversionRatio = 12.0
+  override val name = "dozen"
+}

+ 26 - 0
shared/shared/src/main/scala/com/weEat/shared/models/User.scala

@@ -0,0 +1,26 @@
+package com.weEat.shared.models
+
+import play.api.libs.json.{Format,Json}
+
+/* Basic User information */
+case class User (
+  val fname: String,
+  val lname: String,
+  val email: String
+)
+
+/* Information to register a new user */
+case class UserRegistration (
+  val fname: String,
+  val lname: String,
+  val email: String,
+  val password: String
+)
+
+object User {
+  implicit val userFormat = Json.format[User]
+}
+
+object UserRegistration {
+  implicit val registerFormat = Json.format[UserRegistration]
+}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác