JSON form submission and validation for Playframework
Switch branches/tags
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Failed to load latest commit information.
.circleci
example-project
generator/src/main/scala/givers/form/generator
project
src
.gitignore
LICENSE
README.md
build.sbt

README.md

JSON Form for Playframework

CircleCI codecov Gitter chat

This is a replacement of Play's form submission and validation.

The library is inherently compatible with JSON, as in the conversion between JsValue and a case class is symmetric. In contrast, The standard Play's form library doesn't hold the symmetry property when converting nested objects and arrays.

But why is JSON compatibility important?

At GIVE.asia, we serialize play.api.data.Form to json and pass it to a Vue component.

Since play.api.data.Form didn't support converting to JSON, we converted its member data: Map[String, String] to JSON instead.

With Map[String, String], a JSON { "images": ["test.png"] } would eventually be converted to { "images[0]": "test.png" }. And it became tricky to modify our Vue components to handle this kind of array encoding.

Please note that converting JSON into case class (using bindFromRequest) works fine. In a rare occasion that you might have a field that contains [..], that might cause an issue.

Please see this blog post, which also explains how we build a form, for more context: https://give.engineering/2018/09/15/form-submission-and-validation-in-playframework.html

Why can't we modify play.api.data.Form to be fully compatible with JSON?

Map[String, String] isn't powerful enough to support JsObject, and it is defined in many critical places. For example, Mapping.unbind returns Map[String, String].

Since JsObject is powerful enough to support Map[String, String], one good way to improve Play's form with backward compatibility is to make Mapping.unbind return JsObject and provides a thin layer that converts JsObject to Map[String, String].

Important compatibility notes

Since we aim to facilitate the migration from Play's Form, there are certain counter-intuitive behaviours that should be highlighted.

The below are the behaviours that you need to enable explicitly:

  • Set translateNoneToEmpty to true in order to make seq accept the absence of the value as Seq.empty ref.
  • Set translateEmptyStringToNone to true in order to make opt(text) translate an empty string to None ref.
  • Set translateAbsenceToFalse to true in order to make boolean translate the absence of the key as false ref.

When migrating from Play's Form, you should enable all of these flags to avoid surprises.

The below behaviours are enabled automatically because they are sensible. Here they are:

  • number and longNumber accept both JsString and JsNumber.
  • boolean accepts both JsString and JsBoolean.

Most of these behaviours stem from the fact that JsObject has more complex types while Map[String, String] doesn't.

Usage

Add the below line to your build.sbt:

resolvers += Resolver.bintrayRepo("givers", "maven")

addSbtPlugin("givers.form" %% "play-json-form" % "0.3.2")

Example

You can see a fully working example in the folder example-project.

Making a form:

import givers.form.Form
import givers.form.Mappings._

case class Obj(a: String, b: Int)

val form = Form(
  mapping(
    "a" -> text(allowEmpty = false),
    "b" -> number()
  )(TestObj.apply)(TestObj.unapply)
)

// We also have a slightly shorter API:
val form2 = Form(
  TestObj.apply,
  TestObj.unapply,
  "a" -> text(allowEmpty = false),
  "b" -> number()
)

form.bindFromRequest()(req)

Building a Mapping based on another Mapping:

import givers.form.Mappings

object Currency extends Enumeration {
  val SGD, USD, EUR = Value
}

val currency = Mappings.text(allowEmpty = false).transform[Currency.Value](
  bind = { s =>
    try {
      Success(Currency.withName(s.toUpperCase))
    } catch {
      case _: Exception => Failure(Mapping.error("error.invalid", s))
    }
  },
  unbind = _.toString
)

Extend a Mapping with an additional validation:

import givers.form.Mappings

val email = Mappings.text.validate("error.email") { s => s.nonEmpty && s.contains("@") }

Please see all predefined mappings in givers.form.Mappings.

Develop

  1. Run sbt generate/run in order to generate the classes in givers.form.generated.
  2. Run sbt test to run all tests