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.
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
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]
.
At GIVE.asia, we have more than 20 forms, each of which has several fields. It's tedious to ensure every error message is translated. Previously, what we did is to writing tests on controllers where we send requests with invalid input. It was odd to test the whole path of HTTP request in order to verify that our error messages are translated. So, we've come up with a new way of ensuring every error message is translated.
Form.getAllErrors()
conveniently generates all possible validation errors. However, when building a Mapping
, we need to properly code all the possible validation errors. For example:
new Mapping[String] {
addError("error.invalid")
def bind(value: JsLookupResult, context: BindContext): Try[String] = {
// Do something
Failure(Mapping.error("error.invalid"))
}
def unbind(value: String, context: UnbindContext): JsValue = {
// Do something
}
}
The work is still tedious but much less tedious than without one. We are open to hear about a better design.
At GIVE.asia, we have the use case where we want to convert an amount from String
(as in 1,000.53
or 1,000
) to cents, which is Long
. However, we can't convert it unless we know the currency first. Because, for a two-decimal currency, we will multiply the value by 100. But, for a zero-decimal currency, we won't. It follows the guideline by Stripe.
One simple solution is to make 2 forms. The first form processes the currency. Then, we use the currency to create the second form. That's clunky.
Our solution is that we offer BindContext
which allows Mapping
to access its context, or, in other words, the values of its peers. For example:
new Mapping[String] {
def bind(value: JsLookupResult, context: BindContext): Try[Long] = {
context.get("currency") match {
case Some(currency: Currency) =>
if (currency.isZeroDecimal) {
Success(convertAmountWithZeroDecimal(value))
} else {
Success(convertAmountWithTwoDecimal(value))
}
// The currency field might fail to be parsed. In this case, this mapping is not applicable.
case _ => Failure(NotApplicationException)
}
}
def unbind(value: String, context: UnbindContext): JsValue = {
// Do something
}
}
The design is a little awkward. But it hides complexity from the user. We are open to hear about a better design.
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
coerceToString
totrue
in order to maketext
convert any type (e.g.JsNumber
) toString
. - Set
translateNoneToEmpty
totrue
in order to makeseq
accept the absence of the value asSeq.empty
ref. - Set
translateEmptyStringToNone
totrue
in order to makeopt(text)
translate an empty string toNone
ref. - Set
translateAbsenceToFalse
totrue
in order to makeboolean
translate the absence of the key asfalse
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
andlongNumber
accept bothJsString
andJsNumber
.boolean
accepts bothJsString
andJsBoolean
.
Most of these behaviours stem from the fact that JsObject
has more complex types while Map[String, String]
doesn't.
Add the below line to your build.sbt
:
libraryDependencies += "io.github.tanin47" %% "play-json-form" % "1.0.0"
The artifacts are hosted here: https://bintray.com/givers/maven/play-json-form
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
.
- Run
sbt generator/run
in order to generate the classes ingivers.form.generated
. - Run
sbt test
to run all tests - Run
sbt clean publishSigned
to publish