Relaxed (partial) case class update with json.
Sometime, especially dealing with http/rest services, we want to have an ability to receive and update only certain fields of our resource/entity. Let me provide some short example (spray used).
Suppose we are working on some Profile API
case class Profile(id: String, name: String, password: String)
So we have already created these scenarios
GET /profiles
get & path("profiles")
GET /profiles/:id
get & path("profiles" / Segment)
POST /profiles
post & path("profiles" / Segment) & entity(as[Profile])
DELETE /profiles/:id
delete & path("profiles" / Segment)
And now we are about to implement
PUT /profiles/:id
For simplicity of examples let's assume we use sync API here, but of course you should think twice, how this feet your needs. Almost always you should use async approach.
The very first idea that come to our mind is to reuse Profile
instance like this:
(put & path("profiles" / Segment) & entity(as[Profile])) { (id, update) =>
rejectEmptyResponse {
complete {
val entity: Option[Profile] = db get id
for {
entity <- entity
updated = entity.copy(
name = update.name,
password = update.password)
} yield {
db.update(id, updated)
updated
}
}
}
}
Pros & Cons
id
field, which was defined asString
has to be specified in payload, otherwise unmarshalling will fail.- We still can't update only certain fields.
Make id
optional
case class Profile(id: Option[String], name: String, password: String)
Pros & Cons
From one side it resolves issue with necessity to specify id
twice in uri
and in payload.
From another
side it brings us to an optional id hell as now we have to deal with it in our services handling it
every time by getOrElse { throw new IllegalStateException }
or something.
And we still can't update partially.
Make Form case class where all fields are optional and id
field is absent.
case class Profile(id: String, name: String, password: String)
case class ProfileUpdate(name: Option[String], password: Option[String])
Pros & Cons
- Now you we can omit
id
in payload. - You can update only necessary fields.
But
- you have to have another one class. Just try to imagine how could it look like if you have a rich class with many fields.
- you still have to handle all that fields manually. For example:
case class ProfileUpdate(name: Option[String], password: Option[String]) {
def apply(profile: Profile): Profile = {
val _name = name getOrElse profile.name
val _password = password getOrElse profile.password
profile.copy(
name = _name,
password = _password)
}
}
(put & path("profiles" / Segment) & entity(as[ProfileUpdate])) { (id, update) =>
rejectEmptyResponse {
complete {
val entity: Option[Profile] = db get id
for {
entity <- entity
updated = update apply entity
} yield {
db.update(id, updated)
updated
}
}
}
}
This solution is much better but have one significant drawback. You have to write lot of boilerplate code. Again. Just think about necessity to support this solution having rich class structure. It may become a nightmare.
What if we try to solve it without additional classes.
(put & path("profiles" / Segment) & entity(as[JsValue])) { (id, json) =>
rejectEmptyResponse {
complete {
val entity: Option[Profile] = db get id
for {entity <- entity} yield {
for {
name <- (json \ "name").validateOpt[String]
password <- (json \ "password").validateOpt[String]
} yield {
val updated = entity.copy(
name = name,
password = password)
db.update(id, updated)
updated
} toOption
}
}
}
}
Pros & Cons
Event better as we can skip creating additional infrastructure (Form classes, Marshallers).
So what exactly this solution does is automate the approach we invented on step 3 by involving scala macros.
- You don't need to write special
*Update
classes. - You don't need to write
copy
boilerplate.
How your code could look like by using this solution:
import com.github.andyglow.relaxed._
import com.github.andyglow.relaxed.PlayJsonSupport._
(put & path("profiles" / Segment) & entity(as[JsValue])) { (id, update) =>
rejectEmptyResponse {
complete {
val entity: Option[Profile] = db get id
for {
entity <- entity
updated = Relaxed(entity) updated update
} yield {
db.update(id, updated)
updated
}
}
}
}
The same will work for akka-http
as well.
It is also possible to mark certain fields as not participating in update.
case class Profile(@skip id: String, name: String, password: String)
libraryDependencies += "com.github.andyglow" %% "relaxed-json-update-api" % "${LATEST_VERSION}"
// and one of
libraryDependencies += "com.github.andyglow" %% "relaxed-json-update-play-json" % "${LATEST_VERSION}"
libraryDependencies += "com.github.andyglow" %% "relaxed-json-update-spray-json" % "${LATEST_VERSION}"
libraryDependencies += "com.github.andyglow" %% "relaxed-json-update-jackson" % "${LATEST_VERSION}"
libraryDependencies += "com.github.andyglow" %% "relaxed-json-update-circe" % "${LATEST_VERSION}"
libraryDependencies += "com.github.andyglow" %% "relaxed-json-update-upickle" % "${LATEST_VERSION}"
libraryDependencies += "com.github.andyglow" %% "relaxed-json-update-argonaut" % "${LATEST_VERSION}"
libraryDependencies += "com.github.andyglow" %% "relaxed-json-update-json4s" % "${LATEST_VERSION}"