Tiny, opinionated helpers for building pure, composable JSON transformations on top of Play JSON.
- Two ways to build pipelines: fluent or direct composition
- Safe short-circuiting: failures stop the pipeline with a
JsError - Low-level helpers (move/copy/set/merge/prune/mapAt) with clear semantics
(See the Scaladoc on each helper—this README keeps things high level.)
// build.sbt
libraryDependencies += "io.github.mghmay" %% "play-json-shaper" % "<version>"import io.github.mghmay.transformer._
import io.github.mghmay.transformer.syntax._ // <- brings move, set, mergeAt, ...
import play.api.libs.json._A Transformer is:
type Transformer = JsObject => Either[JsError, JsObject]This base type either returns a transformed JsObject or a JsError explaining the problem. Transformers are then chained together to create composable pipelines.
Use JsonTransform.start and chain steps. Build once, run many.
val pipeline =
JsonTransform
.move(__ \ "old", __ \ "new") // default Aggressive source cleanup
.mapAt(__ \ "new")(v => v.validate[String].map(s => JsString(s.toUpperCase)))
.mergeAt(__ \ "ctx", Json.obj("version" -> 7))
.pruneGentle(__ \ "keep" \ "delete")
.pruneAggressive(__ \ "delete" \ "delete")
.copy(__ \ "ctx", __ \ "copied")
.build
val in = Json.obj("old" -> "hello",
"ctx" -> Json.obj("env" -> "dev"),
"keep" -> Json.obj("delete" -> "me"),
"delete" -> Json.obj("delete" -> "me"))
val out = pipeline(in)
/* Right({"ctx":{"env":"dev","version":7},
"keep":{},"new":"HELLO",
"copied":{"env":"dev","version":7}}) */You can also compose pipelines:
val p1 = JsonTransform.set(__ \ "a", JsNumber(1))
val p2 = JsonTransform.set(__ \ "b", JsNumber(2))
val p = p1 |> p2 // or: p1 ++ p2(There is also an .andThen extension, but because it can be confused with Function1#andThen, prefer |> in docs and code.)
Compose plain functions when that reads nicer (for-comprehensions, lists, etc.):
val in = Json.obj("a" -> Json.obj("name" -> "Ada"))
val viaFor = for {
j1 <- in |> move(__ \ "a" \ "name", __ \ "person" \ "name")
j2 <- j1 |> mapAt(__ \ "person" \ "name")
(v => v.validate[String].map(n => JsString(n.reverse)))
j3 <- j2 |> mergeAt(__ \ "meta", Json.obj("ok" -> true))
} yield j3
// Right({"person":{"name":"adA"},"meta":{"ok":true}})Or use JsonTransform.apply/compose to fold a sequence of steps:
val f: Transformer = JsonTransform(
set(__ \ "x", JsNumber(2)),
mergeAt(__ \ "ctx", Json.obj("v" -> 3))
)You can also compose transformers directly with |>
val t: Transformer =
set(__ \ "x", JsNumber(2)) |> mergeAt(__ \ "ctx", Json.obj("v" -> 3))The move, set, copy etc. functions are all imported through the syntax object:
val t: Transformer =
when(_.keys.contains("admin"))(
set(__ \ "ctx" \ "isAdmin", JsBoolean(true))
)move(from, to)uses Aggressive cleanup by default. This aggressively prunes the parent tree if it is empty after the move. To override:
val tombstoneMove = move(__ \ "src", __ \ "dst", SourceCleanup.Tombstone)
// This leaves a null JsValue at the moved from source node.json |> transformer- apply a transformer to a JsObject(either |> transformer)- chain the next transformer if the previous step succeededtransformer |> transformer- compose transformersbuilder |> transformer/builder |> builder/builder ++ builder- compose fluent pipelines
These are just combinators returning new Transformers:
when(_.keys.exists(_ == "n"))(set(__ \ "flag", JsBoolean(true)))
ifExists(__ \ "user")(mergeAt(__ \ "seen", Json.obj("user" -> true)))
ifMissing(__ \ "ctx" \ "version")(mergeAt(__ \ "ctx", Json.obj("version" -> 1)))They compose cleanly both in the fluent builder and direct style.
A predicate is:
type Predicate = JsObject => BooleanPredicates determine the execution of conditionals.
val isAdmin: Predicate = j => (j \ "user" \ "level").asOpt[Int].exists(_ > 2)
val hasEmail: Predicate = j => (j \ "user" \ "email").asOpt[String].exists(_.nonEmpty)
val adminWithoutEmail = isAdmin and hasEmail.not // or: isAdmin && !hasEmail or any combination
val dontContact = move(__ \ "user", __ \ "noContact")
val out = when(adminWithoutEmail)(dontContact)(json)If a step fails, the entire pipeline stops, and you get that JsError back:
val failing: Transformer = _ => Left(JsError(__ \ "oops", JsonValidationError("boom")))
val wouldSet = set(__ \ "shouldNotExist", JsBoolean(true))
val out = JsonTransform(failing, wouldSet)(Json.obj())
// => Left(JsError(...)) ; second step is not appliedThis is documented in JsonHelpers and syntax. The public operations (move, copy, set, mergeAt, pruneAggressive, pruneGentle, rename, mapAt).
- Separation of concerns
JsonHelpers: precise, low-level json operations.syntax: tiny, user-facing import with nice defaults and names.JsonTransform: builder + composition utilities.
- No hidden state. All pieces are pure functions over
JsObject. - Default cleanup for
moveis Aggressive (tombstones opt-in). - Pipelines are highly composable and reusable.
- This design is based on my work with HMRC where I had to design a small json pipeline and some helper functions.
When writing to a nested path, parents are created as objects. If a parent already exists but isn’t an object, it’s replaced so the write can proceed.
// setNestedPath
set(__ \ "a" \ "b", JsNumber(2))(Json.obj("a" -> 1))
// -> Right({ "a": { "b": 2 } })
// deepMergeAt
mergeAt(__ \ "a" \ "b", Json.obj("x" -> 1))(Json.obj("a" -> 1))
// -> Right({ "a": { "b": { "x": 1 } } })
Also note:
- Setting {} at a leaf keeps the key (it does not delete it).
- Array path segments (e.g., __ \ "arr" \ 0) are not supported by these helpers and return a JsError.
Rename by moving:
JsonTransform
.rename(__ \ "firstName", __ \ "name")
.buildCompute & set:
val incCount =
mapAt(__ \ "count") {
case JsNumber(n) => JsSuccess(JsNumber(n + 1))
case _ => JsError("Expected number")
}Guarded write:
val ensureVersion =
ifMissing(__ \ "ctx" \ "version")(
mergeAt(__ \ "ctx", Json.obj("version" -> 1))
)import io.github.mghmay.transformer.Transformer
import play.api.libs.json._
import io.github.mghmay.transformer.syntax._
/*
Our input json, the aim is to transform it in the following ways:
- if the level of the user is higher than 2 they are an admin and "user" key should be
transformed to "admin"
- user level should be removed
- name value should be transformed from a full name to an object: {"first", "last"}
In order to do this we will make our own transformers using the transformers provided in
ops as building blocks.
*/
val inJson = Json.parse(
"""
|{
| "user": {
| "level": 4,
| "name": "Ada Lovelace"
| }
|}
|""".stripMargin).as[JsObject]
/*
The nice part about transformers is that the Either[JsError, JsObject] can be taken out
into its own reusable function, in this example, splitName could be deployed wherever
we are given a fullName
*/
def splitName(full: String): Either[JsError, JsObject] =
full.split(" ") match {
case Array(first, last) => Right(Json.obj("first" -> first, "last" -> last))
case _ => Left(JsError("Expected exactly two name parts"))
}
/*
Now we can build a specialised transformer which gets the user name at a specific key in
the json.
*/
def userNameTransformer: Transformer = {
json =>
(json \ "user" \ "name").validate[String] match {
case JsSuccess(name, _) =>
/*
We can use the building blocks of the split name function and the set method to
build a result
*/
for {
sName <- splitName(name)
result <- set(__ \ "user" \ "name", sName)(json)
} yield result
case JsError(e) => Left(JsError(e))
}
}
/*
We also want an easy way to transform a user into an admin if their level is higher than 2.
Here is the transformer we can supply into a when transformer
*/
def adminTransformer: Transformer = {
json =>
for {
// First remove the level
j1 <- pruneGentle(__ \ "user" \ "level")(json)
// Then move all the rest to admin key
j2 <- move(__ \ "user", __ \ "admin")(j1)
} yield j2
}
/*
In order to find out if a user is an admin or not we need a way to check the level, we build
a Predicate which takes json and checks the user's level.
*/
def isAdmin: Predicate = j => (j \ "user" \ "level").asOpt[Int].exists(_ > 2)
/*
Our final transformation is nice and simple, firstly we transform the user's name. We need to
ensure that it is changed before we transform the user key into admin key. Then we can safely
check if the user is an admin and if they are, transform the "user" key to "admin"
*/
val transformed = for {
j1 <- userNameTransformer(inJson)
j2 <- when(isAdmin)(adminTransformer)(j1)
} yield j2
"""
| transformed:
| {
| "admin": {
| "name": {
| "first": "Ada",
| "last": "Lovelace"
| }
| }
| }
|"""
- Uses
either.toOption.getOrElse(fail("..."))for success paths, andeither.swap.toOption.getOrElse(fail("..."))for error paths. - Asserts failures by checking
JsErroranchors (paths) and messages. - Builder output equality: both fluent and direct compositions create equal outputs.
To run test coverage:
sbt clean coverage test coverageReportTest coverage is enforced at 100%, both branch and statement. PR's can't be merged if less than 100% code coverage.
Anything that can't be covered in a test should be marked with $COVERAGE-OFF$ and then $COVERAGE-ON$, a reason as to why should be provided.
Example:
// $COVERAGE-OFF$
// Unreachable: IdxPathNode is filtered by the `hasArraySeg` pre-check above.
// This is just a defensive fallback.
current
// $COVERAGE-ON$- Filter out non-object intermediary values (enforce stricter parent types)
- Add support for array paths (
IdxPathNode) - Add transformation combinators (e.g.
copyPath,renamePath, batch moves) - Add a way to deal with enums and perhaps map them
- For very large pipelines, the current foldLeft approach creates intermediate functions. For extreme performance, it could be compiled to a single function
- Transformer could return non-blocking errors that get collected in a sequence similar to cats
Validated - Add further conditionals,
unless,whenAll,whenAny,chooseetc.
MIT © 2025 Mathew May