OpenStack Keystone API用户更新接口Encoder实现难题
I've run into exactly this kind of problem with partial PATCH updates before—needing to distinguish between "leave as-is", "set to a new value", and "clear to null" is surprisingly tricky with standard optional types. Your Updatable ADT is the right way to model this, and we can fix the encoding without relying on risky special values or messy post-processing.
Here's a clean, maintainable solution that directly encodes your Updatable state into the correct JSON structure:
Step 1: Keep Your Updatable ADT (It’s the Right Foundation)
sealed trait Updatable[+T] case object KeepExistingValue extends Updatable[Nothing] case object Unset extends Updatable[Nothing] case class ChangeTo[T](value: T) extends Updatable[T]
Step 2: Update the Update Class to Use Updatable for All Fields
Let’s align all fields with your future-proof goal of using Updatable everywhere:
case class Update( name: Updatable[String] = KeepExistingValue, password: Updatable[String] = KeepExistingValue, defaultProjectId: Updatable[String] = KeepExistingValue, enabled: Updatable[Boolean] = KeepExistingValue )
Step 3: Build a Custom Encoder with Precise Field Control
Instead of relying on auto-derivation with workarounds, we’ll write an encoder that explicitly handles each Updatable field’s state. First, add a helper to convert an Updatable value into an optional JSON field entry:
import io.circe.{Encoder, Json, JsonObject} import io.circe.syntax._ // Helper to convert an Updatable value to a (field name, Json) pair (or None for keep-as-is) implicit class UpdatableFieldEncoder[T](val updatable: Updatable[T]) extends AnyVal { def toJsonField(fieldName: String)(implicit valueEncoder: Encoder[T]): Option[(String, Json)] = updatable match { case KeepExistingValue => None // Don't include the field at all case Unset => Some(fieldName -> Json.Null) // Include field with null value case ChangeTo(value) => Some(fieldName -> valueEncoder(value)) // Include field with new value } }
Then, write the encoder for your Update class by combining these field entries:
implicit val updateEncoder: Encoder[Update] = Encoder.instance { update => // Collect all non-keep-as-is fields val fields = List( update.name.toJsonField("name"), update.password.toJsonField("password"), update.defaultProjectId.toJsonField("default_project_id"), // Match Keystone's snake_case update.enabled.toJsonField("enabled") ).flatten // Convert to JsonObject and then to Json JsonObject.fromIterable(fields).asJson }
Why This Is Better Than Your Previous Solutions
- No risky special values: We don’t use arbitrary strings to mark states, so there’s zero chance of conflicting with valid user input.
- Single-pass processing: We build the JSON object directly without needing to filter or modify it after encoding.
- No dependency on global
dropNullValues: This encoder works independently of any global printer settings, so you won’t break other parts of your code that might rely on different JSON behavior. - Clear and maintainable: Each field’s behavior is explicitly defined, making it easy to extend or modify later (e.g., adding new fields to
Update).
Testing the Three Required Scenarios
Let’s verify this handles all your use cases perfectly:
- Keep existing
defaultProjectId:val update = Update(defaultProjectId = KeepExistingValue) updateEncoder(update) // Result: {} (no default_project_id field) - Set
defaultProjectIdto a new value:val update = Update(defaultProjectId = ChangeTo("an-id")) updateEncoder(update) // Result: {"default_project_id": "an-id"} - Clear
defaultProjectId(set to null):val update = Update(defaultProjectId = Unset) updateEncoder(update) // Result: {"default_project_id": null}
All three cases align exactly with what the Keystone API expects.
内容的提问来源于stack exchange,提问作者Simão Martins

