How to: custom schema for "amended" types

So I’m trying a weird hack (doesn’t matter why, assume some legacy tech debt that binds my hands).
Consider the following wrapper:

import zio.json.{ jsonField, JsonDecoder, JsonEncoder }
import zio.json.ast.Json

case class WithExtras[+T <: HugeLegacySumType](
  datatype: T,
  @jsonField(WithExtras.extrasField) extras: Option[Json]
)

object WithExtras {

  val extrasField: String = "extras"

  implicit def jsonEncoder[T <: HugeLegacySumType: JsonEncoder]: JsonEncoder[WithExtras[T]] = {
    val encoder = implicitly[JsonEncoder[T]]
    Json.Obj.encoder.contramap[WithExtras[T]] { case WithExtras(wrapped, extras) =>
      encoder.toJsonAST(wrapped) match {
        case Left(encodingError) => throw new RuntimeException("Unreachable code path[encode!]: " + encodingError)
        case Right(jo: Json.Obj) => extras.fold(jo)(jo.add(extrasField, _))
        case Right(anyOtherJson) => throw new RuntimeException("Unreachable code path[non-obj]: " + anyOtherJson.toJson)
      }
    }
  }

  implicit def jsonDecoder[T <: HugeLegacySumType: JsonDecoder]: JsonDecoder[WithExtras[T]] = {
    val decoder = implicitly[JsonDecoder[T]]
    Json.Obj.decoder.mapOrFail[WithExtras[T]] { obj =>
      decoder.fromJsonAST(obj).map(WithExtras(_, obj.get(extrasField)))
    }
  }
}

TL;DR:
I can’t change the API, but need to add a custom free “extras” JSON field.
The custom encoder/decoder just adds this optional field to the object.
disregard the fact that this is not “safe” (all subtypes of HugeLegacySumType are case classes, and thus serialized as JSON objects, and no subtype contains a field named “extras”).

How would I provide tapir Schema[WithExtras[HugeLegacySumType]] that also adds the optional “extras” field to each subtype of HugeLegacySumType?

I think that SchemaType.SOpenProduct is what you’ll be looking for? Though I think you’ll need to define the schema by hand.

Thanks @adamw, I feared that might be the case.
Is there a way to somehow reuse automatic schema derivation for HugeLegacySumType?
It would not make sense to go full manual.

perhaps something like:

val schema = Schema.derive[HugeLegacySumType]
val schemaType = schema.schemaType match {
  case SCoproduct(allSubtypes) => ???
  case _ => throw new RuntimeException("unreachable code path")
}
schema.copy(schemaType = schemaType)

I think this depends on … if you want to add the “extras” field to each member of the coproduct? Then, you’d need to transform the schema dervied for HugeLegacySumType and replace each SchemaType.SProduce with SchemaType.SOpenProduct. I think this could be done generically.

Or, do you want to create a schema representing a wrapper type, where you have an “extras” field and a “data” field?

I need the “extras” field flattened in every subtype next to its fields.
So that means the first option.

Are there any examples I can look at?

I think this might be what you’re after. Given:

sealed trait Test
case class X(a: Int) extends Test
case class Y(b: String, c: Double) extends Test

and the schema translation:

    val s = Schema.derived[Test]
    val s2 = s.copy(schemaType = s.schemaType match {
      case co@SchemaType.SCoproduct(subtypes, discriminator) =>
        val subtypes2 = subtypes.map {
          case s@Schema(SchemaType.SProduct(fields), _, _, _, _, _, _, _, _, _, _) =>
            // we're not doing any validation on the "extra" fields, hence using Map.empty as the value of the extracted fields
            s.copy(schemaType = SchemaType.SOpenProduct(fields, Schema.anyObject)(_ => Map.empty))
          case s => s // unchanged
        }

        SchemaType.SCoproduct(subtypes2, discriminator)(co.subtypeSchema)

      case _ => throw new IllegalStateException("Expected SCoproduct")
    })

if you then use s2 as the schema for your value, you’ll get the following in openapi:

components:
  schemas:
    Test:
      oneOf:
      - $ref: '#/components/schemas/X'
      - $ref: '#/components/schemas/Y'
    X:
      required:
      - a
      type: object
      properties:
        a:
          type: integer
          format: int32
      additionalProperties:
        type: object
    Y:
      required:
      - b
      - c
      type: object
      properties:
        b:
          type: string
        c:
          type: number
          format: double
      additionalProperties:
        type: object
1 Like

This seems to do exactly what I need!
I will try this.
Thank you!!! :pray:

@adamw your example led me in the right path. Thank you :pray:
It wasn’t exactly what I meant, but close enough for me to fill in the gap.

For any future poor soul who may need to use ugly hacks like this, here’s what I came up with, based on Adam’s snippet:

implicit def amendedSchemaHugeLegacySumType[T <: HugeLegacySumType: Schema]: Schema[WithExtras[T]] = {
  val s = implicitly[Schema[T]]
  s.copy(
    schemaType = s.schemaType match {
      case SchemaType.SProduct(fields) =>

        val sProductField = SchemaType.SProductField[WithExtras[T], Option[Json]](
          FieldName("extras", "extras"),
          Schema.any[Json].asOption,
          _ => None)

        SchemaType.SProduct(fields.foldRight(List(sProductField)){ case (originalProductField, tailWithExtras) =>
          SchemaType.SProductField(
            originalProductField.name,
            originalProductField.schema,
            originalProductField.get.compose[WithExtras[T]](_.datatype)
          ) :: tailWithExtras
        })
      case _ => throw new IllegalStateException("Expected SProduct")
    },
    default = None,
    validator = Validator.pass
  )
}
  • This will keep all subtypes of HugeLegacySumType a closed product
  • each has an optional “extras” field added (drop the .asOption if you need this as a required field)
  • also works if the HugeLegacySumType is nested under other types

@adamw this is the result of mostly trial & error. AFAICT, it works. but if there’s any “gotchas” I might have missed, LMK :slight_smile:

And thanks for all the help!

1 Like