dev.constructive.eo.avro.circe

Structural Avro ↔ circe bridge (AvroJson): move between Avro generic runtime values, payload bytes and io.circe.Json with no typed case class in the middle. circe is an '''Optional''' dependency of cats-eo-avro — add circe-core to use this package.

Attributes

Members list

Type members

Classlikes

object AvroJson

Structural Avro ↔ circe bridge: move between an Avro generic runtime value and a circe io.circe.Json document '''without''' a typed case class in the middle. The Avro mirror of dev.constructive.eo.circe. Lives inside cats-eo-avro with circe as an Optional dependency — the API surface names io.circe.Json, so any caller already depends on circe directly; add circe-core to use this package.

Structural Avro ↔ circe bridge: move between an Avro generic runtime value and a circe io.circe.Json document '''without''' a typed case class in the middle. The Avro mirror of dev.constructive.eo.circe. Lives inside cats-eo-avro with circe as an Optional dependency — the API surface names io.circe.Json, so any caller already depends on circe directly; add circe-core to use this package.

The bidirectional entry points are record, a lawful Prism[Json, IndexedRecord] per schema — getOption is the strict schema-guided parse (Json → record, misses on any shape the schema does not pin), reverseGet the total structural walk avroToJson (record → Json) — and its polymorphic diagonal family rooted at valuePrismpPrism, bytesPrism, recordPrism and pRecord pre-compose its inputs via tearFrom / mendFrom, tearing generic values / payload bytes / records into a typed A and mending back out as Json.

==Rendering conventions (record → Json)==

avroToJson is a purely structural walk of the Avro value model:

  • IndexedRecord → object (field name from getSchema.getFields, recurse on value, schema declaration order);
  • java.util.Map → object (stringify keys, recurse on values) — matches Encoder[Map[String, ?]];
  • java.util.List → array (recurse);
  • CharSequence (incl. org.apache.avro.util.Utf8) → Json.fromString;
  • Integer / LongJson.fromLong (Json.fromInt(i) == Json.fromLong(i.toLong));
  • DoubleJson.fromDoubleOrNull (matches circe's Encoder[Double]);
  • FloatJson.fromFloatOrNull (matches circe's Encoder[Float]). NB widening float→double first would change the value (0.1f0.10000000149…) and break JsonNumber equality, so the float branch must not;
  • BooleanJson.fromBoolean; a resolved null union branch → Json.Null;
  • GenericEnumSymbolJson.fromString;
  • ByteBuffer / GenericFixed → array of signed byte ints (circe's Encoder[Array[Byte]] convention);
  • any other runtime type → Json.fromString(value.toString) — a lenient last resort (the walk is total), not a convention to rely on.

Unions are resolved at the value level (the runtime value IS the branch), so dispatch on the runtime type needs no union special-casing.

==Parsing conventions (Json → record, the prism's getOption)==

The parse inverts the walk under the schema, and is '''strict''' so the prism stays lawful (getOption(j).map(reverseGet) must reproduce j, so nothing may be guessed, defaulted, or dropped):

  • a record object's key set must equal the schema's field names exactly — an extra key would be silently dropped on re-render, a missing one has NO schema default applied; both miss;
  • int / long parse via JsonNumber.toInt / toLong — non-integral or out-of-range numbers miss; float / double accept any JSON number (toFloat / toDouble);
  • enum requires a string among the schema's symbols; fixed requires the exact byte length; bytes / fixed parse the signed-byte-int array rendering;
  • a union is matched '''first branch that parses wins''' (Json.Null only ever matches a null branch). For the ubiquitous ["null", X] this is exact; a union whose branches overlap structurally (e.g. ["int", "long"]) resolves to the first — and the prism laws only hold up to that choice.

==Non-goals (deliberate)==

The bridge sees only the '''runtime''' Avro value, never the logical type or a case-class field type — so a source whose circe encoder does a '''non-structural''' transform is not reproducible structurally and the bridge is not a drop-in there. Two representative cases:

  • '''logical types''': an Instant stored as timestamp-millis is a long at runtime; the bridge renders Json.fromLong, not the ISO-8601 string a Encoder[Instant] would emit;
  • '''stringified numerics''': an encoder that renders a long as a decimal '''string''' has no structural counterpart — the bridge renders Json.fromLong.

These are the caller's concern (post-process the Json, or decode the typed value): the walk is defined by the wire shape, not the intended semantic type.

==Drilled cursors (.json and .avro faces)==

The full AvroPrism cursor sugar — .field(_.x) / .fields(...) / .at(i) / .union[B] / .each / Dynamic selection — reaches this bridge through the json extensions on AvroPrism / AvroTraversal (drill first, flip last, like .record): reads yield the typed focus, writes render the whole modified document as Json. render is the focus-as-standalone-Json terminal for the read side. The reverse cursor is the avro extension on dev.constructive.eo.circe.JsonPrism: the drilled focus itself converts — subtree ↔ Avro binary, both directions the structural walks above. Same face family as dev.constructive.eo.avro.jsoniter, landing on the AST instead of bytes; the .avro face is why cats-eo-circe is a second Optional dependency next to circe-core.

Attributes

Source
AvroJson.scala
Supertypes
class Object
trait Matchable
class Any
Self type
AvroJson.type

Extensions

Extensions

extension [A](p: AvroPrism[A])
def json: Optic[AvroBytes, Json, A, A, Affine]

JSON-carried face of a drilled dev.constructive.eo.avro.AvroPrism — the circe sibling of the jsoniter module's .json face, byte-identical in shape but landing on the io.circe.Json AST: drill with the full cursor sugar (.field(_.x) / .fields(...) / .at(i) / .union[B] / Dynamic selection) and flip last, like .record:

JSON-carried face of a drilled dev.constructive.eo.avro.AvroPrism — the circe sibling of the jsoniter module's .json face, byte-identical in shape but landing on the io.circe.Json AST: drill with the full cursor sugar (.field(_.x) / .fields(...) / .at(i) / .union[B] / Dynamic selection) and flip last, like .record:

 import dev.constructive.eo.avro.codecPrism
 import dev.constructive.eo.avro.circe.*

 codecPrism[Person].name.json.modify(_.toUpperCase)(avroBytes)  // Json of the WHOLE doc,
                                                                // name uppercased

Reads (.getOption) still yield the typed focus A; every write (.modify / .replace) rebuilds the record through the record face's single-walk writer and renders it via AvroJson.avroToJson. Routing through .record is what makes .at(i) navigable (index steps are unsupported by the byte-span locate) and gives single-walk writes. A path / branch / decode Miss renders the document unchanged; genuinely malformed bytes throw at the eager parse.

Attributes

Source
AvroJson.scala
extension [A](p: JsonPrism[A])
def avro(using codec: AvroCodec[A]): Optic[Json, Json, AvroBytes, AvroBytes, Affine]

Avro-carried face of a drilled dev.constructive.eo.circe.JsonPrism — the reverse cursor, mirror of the jsoniter .avro face: drill a Json document with the circe cursor sugar (.field(_.x) / .at(i) / Dynamic selection) and flip last; the '''drilled focus itself''' is the unit of conversion:

Avro-carried face of a drilled dev.constructive.eo.circe.JsonPrism — the reverse cursor, mirror of the jsoniter .avro face: drill a Json document with the circe cursor sugar (.field(_.x) / .at(i) / Dynamic selection) and flip last; the '''drilled focus itself''' is the unit of conversion:

 import dev.constructive.eo.circe.JsonPrism
 import dev.constructive.eo.avro.circe.*

 val nameA = JsonPrism[Person].name.avro       // Optic[Json, Json,
                                               //       AvroBytes, AvroBytes, Affine]
 nameA.getOption(jsonDoc)                      // Some(Avro binary of the name alone)
 nameA.replace(avroName)(jsonDoc)              // Json doc with the subtree spliced back

Both directions are structural, schema-directed (the AvroCodec[A] is schema evidence only — no typed A is ever materialised, matching codecPrism's doctrine): reads take the focused subtree raw (dev.constructive.eo.circe.JsonPrism.raw) and run the strict parse AvroJson.jsonToValue before encoding to Avro binary — a subtree the schema does not pin is a '''Miss''', exactly like a path miss; writes parse the Avro binary to a generic value, render it with the structural walk, and splice the subtree back. A write whose Avro bytes do not parse under the schema passes the document through unchanged (from has no failure channel).

Attributes

Source
AvroJson.scala