Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ trait JsonEncoder[A] {

def secret: JsonEncoder.Secret[A] = JsonEncoder.Secret.fromJsonEncoder(this)

def omit: JsonEncoder.Omit[A] = JsonEncoder.Omit.fromJsonEncoder(this)

}
object JsonEncoder extends Derivable[JsonEncoder.ObjectEncoder], JsonEncoderLowPriority.LowPriority1 {

Expand Down Expand Up @@ -373,6 +375,40 @@ object JsonEncoder extends Derivable[JsonEncoder.ObjectEncoder], JsonEncoderLowP
}
}

//////////////////////////////////////////////////////////////////////////////////////////////////////
// Omit
//////////////////////////////////////////////////////////////////////////////////////////////////////

/**
* An encoder which unconditionally omits its field from an enclosing JSON object (`addToObject = false`).
*
* Unlike [[Secret]] (which redirects a field into the "secret" channel), an omitted field never appears
* on the wire at all. When used as a top-level/non-object encoder, it still encodes normally via the
* underlying encoder; the omission only takes effect at the object-field level.
*/
sealed trait Omit[A] extends JsonEncoder[A] {
override final def addToObject(value: A): Boolean = false
override def contramap[B](f: B => A): JsonEncoder.Omit[B] = JsonEncoder.Omit.Contramapped(this, f)
override def omit: JsonEncoder.Omit[A] = this
}
object Omit {

def fromJsonEncoder[A](underlying: JsonEncoder[A]): JsonEncoder.Omit[A] = underlying match
case underlying: JsonEncoder.Omit[A] => underlying
case underlying => JsonEncoder.Omit.OmitEncoder(underlying)

final case class OmitEncoder[A] private[Omit] (underlying: JsonEncoder[A]) extends JsonEncoder.Omit[A] {
override def encodeJsonAST(value: A): Json = underlying.encodeJsonAST(value)
override def encodeSplitJsonAST(value: A): Ior[PlainTextJson, SecretJson] = underlying.encodeSplitJsonAST(value)
}

final case class Contramapped[A, B](encoder: JsonEncoder.Omit[A], f: B => A) extends JsonEncoder.Omit[B] {
override def encodeJsonAST(value: B): Json = encoder.encodeJsonAST(f(value))
override def encodeSplitJsonAST(value: B): Ior[PlainTextJson, SecretJson] = encoder.encodeSplitJsonAST(f(value))
}

}

//////////////////////////////////////////////////////////////////////////////////////////////////////
// Generic
//////////////////////////////////////////////////////////////////////////////////////////////////////
Expand All @@ -390,8 +426,10 @@ object JsonEncoder extends Derivable[JsonEncoder.ObjectEncoder], JsonEncoderLowP
valType = ValDef.ValType.LazyVal,
) { [b] => (_, _) ?=> (field: generic.Field[b]) =>
val baseInstance: Expr[JsonEncoder[b]] = field.summonTypeClass[JsonEncoder]
val isOmitted: Boolean = field.annotations.optionalOf[jsonOmit].nonEmpty
val isPlain: Boolean = field.annotations.optionalOf[jsonSecret].isEmpty
if isPlain then baseInstance
if isOmitted then '{ $baseInstance.omit }
else if isPlain then baseInstance
else '{ $baseInstance.secret }
}
} { DeriveProductJsonEncoder[A](_) }
Expand Down
28 changes: 28 additions & 0 deletions modules/general/json/src/main/scala/oxygen/json/OmitValue.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package oxygen.json

/**
* Wrapper type carrying "omit from JSON" semantics, parallel to [[SecretValue]].
*
* A field typed `OmitValue[A]` is never written to an enclosing JSON object (the derived
* [[JsonEncoder]] uses `addToObject = false`). On decode the field is reconstructed from its
* constructor default (or, for types like `Option`/`Specified`, from their `onMissingFromObject`).
*
* Example:
* {{{
* final case class User(
* name: String,
* cachedHash: OmitValue[String] = OmitValue(""),
* ) derives JsonCodec
* }}}
*/
opaque type OmitValue[A] <: A = A
object OmitValue {

def apply[A](value: A): OmitValue[A] = value

extension [A](self: OmitValue[A]) def value: A = self

given encoder: [A: JsonEncoder as enc] => JsonEncoder[OmitValue[A]] = enc.omit
given decoder: [A: JsonDecoder as dec] => JsonDecoder[OmitValue[A]] = dec

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ final case class defaultJsonDiscriminator(name: String) extends Annotation deriv
final case class jsonStrict() extends Annotation derives ToExprT, FromExprT

final case class jsonSecret() extends Annotation derives ToExprT, FromExprT

final case class jsonOmit() extends Annotation derives ToExprT, FromExprT
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ final class DeriveProductJsonDecoder[A](
val tmp1: Growable[Expr[Iterable[String]]] =
generic.mapChildren.mapExpr[Iterable[String]] { [a] => (_, _) ?=> (field: generic.Field[a]) =>
val isFlattened: Boolean = field.annotations.optionalOf[jsonFlatten].nonEmpty
val isOmitted: Boolean = field.annotations.optionalOf[jsonOmit].nonEmpty

if isFlattened then
if isOmitted then
// Omitted fields are not part of the wire schema, so they contribute no keys.
'{ Nil }
else if isFlattened then
'{
${ field.getExpr(instances) }.toObjectDecoderOrThrow.keys
}
Expand All @@ -35,8 +39,16 @@ final class DeriveProductJsonDecoder[A](
val fieldNameExpr: Expr[String] = Expr(field.annotations.optionalOfValue[jsonField].fold(field.name)(_.name))
val instanceExpr: Expr[JsonDecoder[a]] = field.getExpr(instances)
val isFlattened: Boolean = field.annotations.optionalOf[jsonFlatten].nonEmpty
val isOmitted: Boolean = field.annotations.optionalOf[jsonOmit].nonEmpty

if isFlattened then
if isOmitted then
// `@jsonOmit`: field is never on the wire. Any incoming value is ignored; reconstruct from
// the decoder's `onMissingFromObject` (constructor default, `None`, `WasNotSpecified`, ...).
'{
$instanceExpr.onMissingFromObject
.toRight(JsonError(JsonError.Path.Field($fieldNameExpr) :: Nil, JsonError.Cause.MissingRequired))
}
else if isFlattened then
// TODO (KR) : is there a more type-safe & compile-time way to do this?
'{
$instanceExpr.toObjectDecoderOrThrow.decodeJsonObjectAST($obj, $map)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,16 @@ final class DeriveProductJsonEncoder[A](
val instanceExpr: Expr[JsonEncoder[a]] = field.getExpr(instances)
val fieldExpr: Expr[a] = field.fromParent(value)
val isFlattened: Boolean = field.annotations.optionalOf[jsonFlatten].nonEmpty
val isOmitted: Boolean = field.annotations.optionalOf[jsonOmit].nonEmpty

if isFlattened then
if isOmitted then
// `@jsonOmit` unconditionally suppresses the field (takes precedence over `@jsonFlatten`).
// Reference the instance so its cached val is not flagged unused by `-Wunused`.
'{
val _ = $instanceExpr
Growable.empty[(String, Json)]
}
else if isFlattened then
// TODO (KR) : is there a more type-safe & compile-time way to do this?
'{
$instanceExpr.toObjectEncoderOrThrow.encodeJsonObjectFields($fieldExpr)
Expand Down Expand Up @@ -48,8 +56,16 @@ final class DeriveProductJsonEncoder[A](
val instanceExpr: Expr[JsonEncoder[a]] = field.getExpr(instances)
val fieldExpr: Expr[a] = field.fromParent(value)
val isFlattened: Boolean = field.annotations.optionalOf[jsonFlatten].nonEmpty
val isOmitted: Boolean = field.annotations.optionalOf[jsonOmit].nonEmpty

if isFlattened then
if isOmitted then
// `@jsonOmit` unconditionally suppresses the field (takes precedence over `@jsonFlatten`).
// Reference the instance so its cached val is not flagged unused by `-Wunused`.
'{
val _ = $instanceExpr
Growable.empty[(String, Ior[PlainTextJson, SecretJson])]
}
else if isFlattened then
// TODO (KR) : is there a more type-safe & compile-time way to do this?
'{
$instanceExpr.toObjectEncoderOrThrow.encodeSplitJsonObjectFields($fieldExpr)
Expand Down
4 changes: 2 additions & 2 deletions modules/general/json/src/main/scala/oxygen/predef/json.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package oxygen.predef

object json {
export oxygen.json.{Json, JsonCodec, JsonDecoder, JsonEncoder, JsonError, KeyedMapDecoder}
export oxygen.json.{jsonDiscriminator, jsonField, jsonFlatten, jsonType}
export oxygen.json.{Json, JsonCodec, JsonDecoder, JsonEncoder, JsonError, KeyedMapDecoder, OmitValue}
export oxygen.json.{jsonDiscriminator, jsonField, jsonFlatten, jsonOmit, jsonType}
export oxygen.json.instances.given
export oxygen.json.syntax.build.*
export oxygen.json.syntax.json.*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ object JsonSchema extends Derivable[JsonSchema.ObjectLike], JsonSchemaLowPriorit
val fieldDoc: Option[String] = field.annotations.optionalOfValue[doc].map(_.value)
val instanceExpr: Expr[JsonSchema[a]] = field.getExpr(instances)
val isFlattened: Boolean = field.annotations.optionalOf[jsonFlatten].nonEmpty
val isOmitted: Boolean = field.annotations.optionalOf[jsonOmit].nonEmpty

def flattenSumErrorString(underlyingType: String): String =
s"""Not Supprted : JsonSchema only supports @jsonFlatten on product schema, but got $underlyingType.
Expand All @@ -568,7 +569,11 @@ object JsonSchema extends Derivable[JsonSchema.ObjectLike], JsonSchemaLowPriorit
| field-type: ${field.typeRepr.showCode}
|""".stripMargin

if isFlattened then
if isOmitted then
// `@jsonOmit` fields are not part of the wire representation, so hide them from the schema.
// The instance is still referenced via the reused encoder/decoder derivation.
'{ Growable.empty[ProductField[?]] }
else if isFlattened then
'{
$instanceExpr.toProductLikeOrThrow match {
case fieldInstance: JsonSchema.ProductSchema[?] => Growable.many(fieldInstance.fields)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,37 @@ object JsonSpec extends OxygenSpecDefault {
field: Json,
) derives JsonCodec

final case class OmitAnnotated(
s: String,
@jsonOmit hidden: String = "default",
) derives JsonCodec

final case class OmitAnnotatedOption(
s: String,
@jsonOmit hidden: Option[String],
) derives JsonCodec

final case class OmitWrapped(
s: String,
hidden: OmitValue[String] = OmitValue("default"),
) derives JsonCodec

final case class OmitWrappedOption(
s: String,
hidden: OmitValue[Option[String]] = OmitValue(None),
) derives JsonCodec

final case class OmitFlatten(
outer1: Int,
@jsonOmit @jsonFlatten inner: FlattenInner = FlattenInner(0, None),
) derives JsonCodec

@jsonStrict
final case class OmitStrict(
i: Int,
@jsonOmit hidden: String = "default",
) derives JsonCodec

override def testSpec: TestSpec =
suite("JsonSpec")(
suite("provided instances")(
Expand Down Expand Up @@ -289,6 +320,48 @@ object JsonSpec extends OxygenSpecDefault {
directRoundTripTest[NonStrict2]("""{"b":true,"i":1}""")(NonStrict2(true, Strict1(1, None))),
successfulDecodeTest[NonStrict2]("""{"b":true,"i":1,"s":"here","extra":true}""")(NonStrict2(true, Strict1(1, "here".some))),
),
suite("omit")(
suite("@jsonOmit annotation")(
// encodes without the omitted field; decodes without the key using the default
directRoundTripTest[OmitAnnotated]("""{"s":"a"}""")(OmitAnnotated("a")),
directRoundTripTest[OmitAnnotated]("""{"s":"a"}""")(OmitAnnotated("a", "default")),
// omitted regardless of the field's runtime value
test("omits non-default value") {
assert(JsonEncoder[OmitAnnotated].encodeJsonStringCompact(OmitAnnotated("a", "SECRET")))(equalTo("""{"s":"a"}"""))
},
// an incoming value for the omitted key is ignored (non-strict)
successfulDecodeTest[OmitAnnotated]("""{"s":"a","hidden":"ignored"}""")(OmitAnnotated("a", "default")),
),
suite("@jsonOmit with Option (no default)")(
directRoundTripTest[OmitAnnotatedOption]("""{"s":"a"}""")(OmitAnnotatedOption("a", None)),
test("omits present Option value") {
assert(JsonEncoder[OmitAnnotatedOption].encodeJsonStringCompact(OmitAnnotatedOption("a", "x".some)))(equalTo("""{"s":"a"}"""))
},
),
suite("OmitValue wrapper")(
directRoundTripTest[OmitWrapped]("""{"s":"a"}""")(OmitWrapped("a")),
test("omits non-default wrapped value") {
assert(JsonEncoder[OmitWrapped].encodeJsonStringCompact(OmitWrapped("a", OmitValue("SECRET"))))(equalTo("""{"s":"a"}"""))
},
// NOTE: unlike the `@jsonOmit` annotation, the `OmitValue` type only affects encoding.
// On decode there is no annotation to hook, so a present key is decoded normally.
successfulDecodeTest[OmitWrapped]("""{"s":"a","hidden":"ignored"}""")(OmitWrapped("a", OmitValue("ignored"))),
// OmitValue[Option[_]] round-trips without an explicit constructor default value
directRoundTripTest[OmitWrappedOption]("""{"s":"a"}""")(OmitWrappedOption("a")),
),
suite("@jsonOmit + @jsonFlatten")(
// omit wins over flatten: the flattened object is fully suppressed
directRoundTripTest[OmitFlatten]("""{"outer1":1}""")(OmitFlatten(1)),
test("omits flattened value") {
assert(JsonEncoder[OmitFlatten].encodeJsonStringCompact(OmitFlatten(1, FlattenInner(2, "x".some))))(equalTo("""{"outer1":1}"""))
},
),
suite("@jsonOmit + @jsonStrict")(
directRoundTripTest[OmitStrict]("""{"i":1}""")(OmitStrict(1)),
// in strict mode the omitted key is not part of the schema -> rejected as extra
failedDecodeTest[OmitStrict]("""{"i":1,"hidden":"x"}"""),
),
),
),
suite("string transform")(
directRoundTripTest("""{"field":"eyJ0eXBlIjoiYmFzZTY0IiwidmFsdWUiOiJTdHJpbmcifQ"}""")(MyClass2(MyClass1("base64", "String"))),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ object JsonSchemaEmitterSpec extends OxygenSpecDefault {
children: List[Tree],
) derives JsonSchema

final case class WithOmit(
name: String,
@jsonOmit internalId: String = "internal",
) derives JsonSchema

//////////////////////////////////////////////////////////////////////////////////////////////////////
// Helpers
//////////////////////////////////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -127,6 +132,16 @@ object JsonSchemaEmitterSpec extends OxygenSpecDefault {
lead.fld("$ref").asString == members.fld("items").fld("$ref").asString,
)
},
test("@jsonOmit field is hidden from the emitted schema") {
val withOmit = soleDef(standalone[WithOmit])
val props = withOmit.fld("properties")
val required = stringSet(withOmit.fld("required"))
assertTrue(
props.objKeys == Set("name"),
!props.objKeys.contains("internalId"),
!required.contains("internalId"),
)
},
test("recursive type terminates via $ref to itself") {
val root = standalone[Tree]
val treeDef = soleDef(root)
Expand Down
Loading
Loading