diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4f663d863..fe0bea0e57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,7 +167,7 @@ jobs: run: sbt $SBT_JAVA_OPTS catsJS/test circeJsonJS/test clientCoreJS/test clientTestsJS/test coreJS/test enumeratumJS/test jsoniterScalaJS/test newtypeJS/test openapiDocsJS/test playJsonJS/test redocJS/test serverCoreJS/test sttpClientJS/test testingJS/test testsJS/test uPickleJsonJS/test zioJsonJS/test clientTestServer/reStop - name: Test if: matrix.target-platform == 'JS' && matrix.scala-version == '3' - run: sbt $SBT_JAVA_OPTS catsJS3/test circeJsonJS3/test clientCoreJS3/test clientTestsJS3/test coreJS3/test jsoniterScalaJS3/test openapiDocsJS3/test redocJS3/test serverCoreJS3/test sttpClientJS3/test testingJS3/test testsJS3/test uPickleJsonJS3/test zioJsonJS3/test clientTestServer/reStop + run: sbt $SBT_JAVA_OPTS catsJS3/test circeJsonJS3/test clientCoreJS3/test clientTestsJS3/test coreJS3/test jsoniterScalaJS3/test openapiDocsJS3/test picklerJsonJS3/test redocJS3/test serverCoreJS3/test sttpClientJS3/test testingJS3/test testsJS3/test uPickleJsonJS3/test zioJsonJS3/test clientTestServer/reStop - uses: actions/upload-artifact@v7 # upload test results if: success() || failure() # run this step even if previous step failed with: diff --git a/build.sbt b/build.sbt index c05b69832a..e54d55957d 100644 --- a/build.sbt +++ b/build.sbt @@ -967,14 +967,29 @@ lazy val uPickleJson: ProjectMatrix = (projectMatrix in file("json/upickle")) ) .dependsOn(core) +// Derives the tapir Schema and a jsoniter-scala JsonValueCodec in a single Hearth-based macro expansion, so the +// two cannot drift apart. The codec half is produced by configuring `JsonCodecMaker.make` from the derived +// schema's names, so `jsoniter-scala-macros` is a *compile* dependency: the generated code calls the macro, +// which therefore has to be on the user's compile classpath too. lazy val picklerJson: ProjectMatrix = (projectMatrix in file("json/pickler")) .settings(commonSettings) .settings( name := "tapir-json-pickler", libraryDependencies ++= Seq( - "com.lihaoyi" %%% "upickle" % Versions.upickle3, - scalaTest.value % Test + "com.kubuszok" %%% "hearth" % Versions.hearth, + compilerPlugin("com.kubuszok" %% "hearth-cross-quotes" % Versions.hearth), + "com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-core" % Versions.jsoniter, + "com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-macros" % Versions.jsoniter, + scalaTest.value % Test, + scalaCheck.value % Test, + scalaTestPlusScalaCheck.value % Test, + // a JSON AST for the schema/codec agreement tests, which walk the written JSON alongside the schema + "com.lihaoyi" %%% "ujson" % Versions.upickle % Test ) + // NB: Hearth requires -language:implicitConversions (to unwrap `Type.Lazy[A]` into `Type[A]` at use sites); + // tapir's commonSettings already enables it, so setting it here again only produces a redundancy warning. + // Uncomment to debug the cross-quotes compiler plugin's rewriting: + // scalacOptions += "-P:hearth.cross-quotes:logging=true", ) .jvmPlatform(scalaVersions = List(scala3), settings = commonJvmSettings) .jsPlatform(scalaVersions = List(scala3), settings = commonJsSettings) @@ -2437,8 +2452,6 @@ lazy val documentation: ProjectMatrix = (projectMatrix in file("generated-doc")) mdocExtraArguments := Seq("--clean-target"), publishArtifact := false, name := "doc", - // Force upickle3 to match picklerJson's dependency and avoid version conflict - dependencyOverrides += "com.lihaoyi" %% "upickle" % Versions.upickle3, libraryDependencies ++= Seq( "org.playframework" %% "play-netty-server" % Versions.playServer, "org.http4s" %% "http4s-blaze-server" % Versions.http4sBlazeServer, diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/CreateDerivedEnumerationPickler.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/CreateDerivedEnumerationPickler.scala index d1302c64aa..5ae4d7f5ce 100644 --- a/json/pickler/src/main/scala/sttp/tapir/json/pickler/CreateDerivedEnumerationPickler.scala +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/CreateDerivedEnumerationPickler.scala @@ -1,109 +1,33 @@ package sttp.tapir.json.pickler -import _root_.upickle.implicits.{macros => upickleMacros} -import sttp.tapir.macros.CreateDerivedEnumerationSchema -import sttp.tapir.{Schema, SchemaAnnotations, SchemaType, Validator} -import upickle.core.{Annotator, Types} - -import scala.deriving.Mirror -import scala.reflect.ClassTag - -import compiletime.* - -/** A builder allowing deriving Pickler for enums, used by [[Pickler.derivedEnumeration]]. Can be used to set non-standard encoding logic, - * schema type or default value for an enum. +import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec +import sttp.tapir.{Schema, Validator} +import sttp.tapir.json.pickler.internal.runtime.{CodecCombinators, PicklerFactories} + +/** Builder returned by [[Pickler.derivedEnumeration]]: a pickler for an enumeration (a sealed hierarchy or `enum` whose cases are all + * singletons), with a choice of how the cases are rendered as strings. + * + * Instances are created by the derivation macro, which supplies the singleton values and the default schema and codec — the ones + * [[Pickler.derived]] would produce for the same type and configuration. */ -class CreateDerivedEnumerationPickler[T: ClassTag]( - validator: Validator.Enumeration[T], - schemaAnnotations: SchemaAnnotations[T] -): - - /** @param encode - * Specify how values of this type can be encoded to a raw value (typically a [[String]]; the raw form should correspond with - * `schemaType`). This encoding will be used when writing/reading JSON and generating documentation. Defaults to an identity function, - * which effectively means that `.toString` will be used to represent the enumeration in the docs. - * @param schemaType - * The low-level representation of the enumeration. Defaults to a string. +final class CreateDerivedEnumerationPickler[T] private[pickler] ( + values: List[T], + defaultSchema: Schema[T], + defaultCodec: JsonValueCodec[T] +) { + + /** Each case is rendered by the configured `toDiscriminatorValue` — the same as [[Pickler.derived]] does for an enumeration, so this is + * only ever needed for symmetry with [[customStringBased]]. */ - inline def apply( - encode: T => Any = identity, - schemaType: SchemaType[T] = SchemaType.SString[T](), - default: Option[T] = None - )(using m: Mirror.SumOf[T]): Pickler[T] = { - val schema: Schema[T] = new CreateDerivedEnumerationSchema(validator, schemaAnnotations).apply( - Some(encode), - schemaType, - default - ) - lazy val childReadWriters = buildEnumerationReadWriters[T, m.MirroredElemTypes] - val tapirPickle = new TapirPickle[T] { - override lazy val reader: Reader[T] = { - val readersForPossibleValues: Seq[TaggedReader[T]] = - childReadWriters.map { case (enumValue, reader, _) => - TaggedReader.Leaf[T](encode(enumValue.asInstanceOf[T]).toString, reader.asInstanceOf[LeafWrapper[_]].r.asInstanceOf[Reader[T]]) - } - new TaggedReader.Node[T](readersForPossibleValues: _*) - } - - override lazy val writer: Writer[T] = - new TaggedWriter.Node[T](childReadWriters.map(_._3.asInstanceOf[TaggedWriter[T]]): _*) { - override def findWriterWithKey(v: Any): (String, String, ObjectWriter[T]) = - val (tagKey, tagValue, writer) = super.findWriterWithKey(v) - // Here our custom encoding transforms the value of a singleton object - val overriddenTag = encode(v.asInstanceOf[T]).toString - (tagKey, overriddenTag, writer) - } - } - new Pickler[T](tapirPickle, schema) - } - - private inline def buildEnumerationReadWriters[T: ClassTag, Cases <: Tuple]: List[(Any, Types#Reader[_], Types#Writer[_])] = - inline erasedValue[Cases] match { - case _: (enumerationCase *: enumerationCasesTail) => - val (reader, writer) = readWriterForEnumerationCase[enumerationCase] - val processedTail = buildEnumerationReadWriters[T, enumerationCasesTail] - ((productValue[enumerationCase], reader, writer) +: processedTail) - case _: EmptyTuple.type => Nil - } + def defaultStringBased: Pickler[T] = PicklerFactories.instance(defaultSchema, defaultCodec) - private inline def productValue[E] = summonFrom { case m: Mirror.ProductOf[E] => m.fromProduct(EmptyTuple) } - - /** Enumeration cases and case objects in an enumeration need special writers and readers, which are generated here, instead of being - * taken from child picklers. For example, for enum Color and case values Red and Blue, a Writer should just use the object Red or Blue - * and serialize it to "Red" or "Blue". If user needs to encode the singleton object using a custom function, this happens on a higher - * level - the top level of coproduct reader and writer. - */ - private inline def readWriterForEnumerationCase[C]: (Types#Reader[C], Types#Writer[C]) = - val pickle = new TapirPickle[C] { - // We probably don't need a separate TapirPickle for each C, this could be optimized. - // https://github.com/softwaremill/tapir/issues/3192 - override lazy val writer = annotate[C]( - SingletonWriter[C](null.asInstanceOf[C]), - Annotator.defaultTagKey, // not used in enumerations - upickleMacros.tagName[C], - Annotator.Checker.Val(upickleMacros.getSingleton[C]) - ) - override lazy val reader = annotate[C]( - SingletonReader[C](upickleMacros.getSingleton[C]), - Annotator.defaultTagKey, // not used in enumerations - upickleMacros.tagName[C] - ) - } - (pickle.reader, pickle.writer) - - /** Creates the Pickler assuming the low-level representation is a `String`. The encoding function passes the object unchanged (which - * means `.toString` will be used to represent the enumeration in JSON and documentation). Typically you don't need to explicitly use - * `Pickler.derivedEnumeration[T].defaultStringBased`, as this is the default behavior of [[Pickler.derived]] for enums. + /** Each case is rendered by `encode`, in the JSON and in the documentation alike. `encode` must give distinct strings to distinct cases; + * a collision fails immediately, not on first decode. */ - inline def defaultStringBased(using Mirror.SumOf[T]) = apply() - - /** Creates the Pickler assuming the low-level representation is a `String`. Provide your custom encoding function for representing an - * enum value as a String. It will be used to represent the enumeration in JSON and documentation. This approach is recommended if you - * need to encode enums using a common field in their base trait, or another specific logic for extracting string representation. - */ - inline def customStringBased(encode: T => String)(using Mirror.SumOf[T]): Pickler[T] = - apply( - encode, - schemaType = SchemaType.SString[T](), - default = None - ) + def customStringBased(encode: T => String): Pickler[T] = { + val codec = CodecCombinators.stringEnum(values, encode) + // The validator keeps the schema's name, as the default one does: the OpenAPI interpreter uses it to emit a named component. + val schema = defaultSchema.copy(validator = Validator.enumeration(values, (v: T) => Some(encode(v)), defaultSchema.name)) + PicklerFactories.instance(schema, codec) + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/Pickler.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/Pickler.scala index 12fa0f3131..1bc548f4cd 100644 --- a/json/pickler/src/main/scala/sttp/tapir/json/pickler/Pickler.scala +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/Pickler.scala @@ -1,461 +1,59 @@ package sttp.tapir.json.pickler -import sttp.tapir.internal.EnumerationMacros.* +import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec import sttp.tapir.Codec.JsonCodec -import sttp.tapir.DecodeResult.Error.JsonDecodeException -import sttp.tapir.DecodeResult.{Error, Value} -import sttp.tapir.SchemaType.SProduct -import sttp.tapir.{Codec, Schema, SchemaAnnotations, Validator} +import sttp.tapir.Schema +import scala.annotation.implicitNotFound import scala.collection.Factory -import scala.compiletime.* -import scala.deriving.Mirror -import scala.quoted.* import scala.reflect.ClassTag -import scala.util.{Failure, NotGiven, Success, Try} -import java.math.{BigDecimal as JBigDecimal, BigInteger as JBigInteger} -import scala.annotation.implicitNotFound -import sttp.tapir.generic.Configuration +/** A pickler combines the [[Schema]] of a type (used for documentation and validation of deserialized values) with a jsoniter-scala + * [[JsonValueCodec]]. Both are derived by a single macro expansion from a single [[PicklerConfiguration]], which is what guarantees that + * the documented schema and the actual JSON encoding cannot drift apart. + * + * An in-scope pickler instance is required by `jsonBody` (and its variants), but it can also be converted to a codec explicitly using + * [[toCodec]]. + */ +@implicitNotFound(msg = """Could not summon a Pickler for type ${A}. +Picklers can be derived automatically by adding: `import sttp.tapir.json.pickler.generic.auto.*`, or manually using `Pickler.derived[A]`. +The latter is also useful for debugging derivation errors.""") +trait Pickler[A] { -object Pickler: + def schema: Schema[A] - /** Derive a [[Pickler]] instance for the given type, at compile-time. Depending on the derivation mode (auto / semi-auto), picklers for - * referenced types (e.g. via a field, enum case or subtype) will either be derived automatically, or will need to be provided manually. - * - * This method can either be used explicitly, in the definition of a `given`, or indirectly by adding a `... derives Pickler` modifier to - * a datatype definition. - * - * The in-scope [[PicklerConfiguration]] instance is used to customise field names and other behavior. - */ - inline def derived[T: ClassTag](using PicklerConfiguration): Pickler[T] = - summonFrom { - case schema: Schema[T] => fromExistingSchemaAndRw[T](schema) - case m: Mirror.Of[T] => buildNewPickler[T]() - case _ => errorForType[T]("Cannot derive Pickler[%s], you need to provide both Schema and uPickle ReadWriter for this type.") - } + def codec: JsonValueCodec[A] - /** Create a coproduct pickler (e.g. for an `enum` or `sealed trait`), where the value of the discriminator between child types is a read - * of a field of the base type. The field, if not yet present, is added to each child schema. - * - * The picklers for the child types have to be provided explicitly with their value mappings in `mapping`. - * - * Note that if the discriminator value is some transformation of the child's type name (obtained using the implicit - * [[PicklerConfiguration]]), the coproduct schema can be derived automatically or semi-automatically. - * - * @param discriminatorPickler - * The pickler that is used when adding the discriminator as a field to child picklers (if it's not yet added). + /** Converts this pickler into a tapir JSON [[JsonCodec]], combining the derived schema with the derived jsoniter-scala codec. */ - inline def oneOfUsingField[T: ClassTag, V](inline extractorFn: T => V, inline asStringFn: V => String)( - mapping: (V, Pickler[_ <: T])* - )(using m: Mirror.Of[T], c: PicklerConfiguration, discriminatorPickler: Pickler[V]): Pickler[T] = + final def toCodec: JsonCodec[A] = internal.runtime.PicklerUtils.toTapirCodec(codec, schema) - val paramMapping = mapping - type ParamV = V - val subtypeDiscriminator: SubtypeDiscriminator[T] = new CustomSubtypeDiscriminator[T] { - type V = ParamV - override lazy val fieldName = c.discriminator - override def extractor = extractorFn - override def asString = asStringFn - override lazy val mapping = paramMapping - } - summonFrom { - case schema: Schema[T] => fromExistingSchemaAndRw[T](schema) - case _ => - inline m match { - case p: Mirror.ProductOf[T] => - error( - s"Unexpected product type (case class) ${implicitly[ClassTag[T]].runtimeClass.getSimpleName()}, this method should only be used with sum types (like sealed hierarchy)" - ) - case _: Mirror.SumOf[T] => - inline if (isEnumeration[T]) - error("oneOfUsingField cannot be used with enums. Try Pickler.derivedEnumeration instead.") - else { - given Schema[V] = discriminatorPickler.schema - given Configuration = c.genericDerivationConfig - val schema: Schema[T] = Schema.oneOfUsingField[T, V](extractorFn, asStringFn)( - mapping.toList.map { case (v, p) => - (v, p.schema) - }: _* - ) - lazy val childPicklers: Tuple.Map[m.MirroredElemTypes, Pickler] = summonChildPicklerInstances[T, m.MirroredElemTypes] - picklerSum(schema, childPicklers, subtypeDiscriminator) - } - } - } + /** A pickler for `Option[A]`: the schema is marked optional, `None` is written as `null`. */ + final def asOption: Pickler[Option[A]] = + Pickler.fromSchemaAndCodec(schema.asOption, internal.runtime.CodecCombinators.option(codec)) - /** Creates a pickler for an enumeration, where the validator is derived using [[sttp.tapir.Validator.derivedEnumeration]]. This requires - * that this is an `enum`, where all cases are parameterless, or that all subtypes of the sealed hierarchy `T` are `object` s. - * - * This method cannot be a `given`, as there's no way to constraint the type `T` to be an enum / sealed trait or class enumeration, so - * that this would be invoked only when necessary. - */ - inline def derivedEnumeration[T: ClassTag](using Mirror.Of[T]): CreateDerivedEnumerationPickler[T] = - inline erasedValue[T] match - case _: Null => - error("Unexpected non-enum Null passed to derivedEnumeration") - case _: Nothing => - error("Unexpected non-enum Nothing passed to derivedEnumeration") - case _: reflect.Enum => - new CreateDerivedEnumerationPickler(Validator.derivedEnumeration[T], SchemaAnnotations.derived[T]) - case _ => - error("Unexpected non-enum type passed to derivedEnumeration") + /** A pickler for a collection of `A`, written as a JSON array. */ + final def asIterable[C[X] <: Iterable[X]](using Factory[A, C[A]]): Pickler[C[A]] = + Pickler.fromSchemaAndCodec(schema.asIterable[C], internal.runtime.CodecCombinators.iterable[A, C](codec)) - inline given nonMirrorPickler[T](using PicklerConfiguration, NotGiven[Mirror.Of[T]]): Pickler[T] = - summonFrom { - case n: NotGiven[Mirror.Of[T]] => - Pickler( - new TapirPickle[T] { - override lazy val reader = summonFrom { - case r: Reader[T] => r - case _ => - errorForType[T]( - "Use Pickler.derive[%s] instead of nonMirrorPickler. This method has to be in scope to resolve predefined picklers." - ) - } - override lazy val writer = summonFrom { - case w: Writer[T] => w - case _ => - errorForType[T]( - "Use Pickler.derive[%s] instead of nonMirrorPickler. This method has to be in scope to resolve predefined picklers." - ) - } - }, - summonInline[Schema[T]] - ) - // It turns out that summoning a Pickler can sometimes fall into this branch, even if we explicitly state that we want a NotGiven in the method signature - case m: Mirror.Of[T] => - errorForType[T]( - "Found unexpected Mirror. Failed to summon a Pickler[%s]. Please report it as an issue at https://github.com/softwaremill/tapir/issues. To avoid this issue, try using Pickler.derived or importing sttp.tapir.json.pickler.generic.auto.*" - ) - } + final def asArray(using ClassTag[A]): Pickler[Array[A]] = + Pickler.fromSchemaAndCodec(schema.asArray, internal.runtime.CodecCombinators.array(codec)) +} - given picklerForOption[T: Pickler](using PicklerConfiguration, Mirror.Of[T]): Pickler[Option[T]] = - summon[Pickler[T]].asOption +object Pickler extends PicklerCompanionCompat { - given picklerForIterable[T: Pickler, C[X] <: Iterable[X]](using PicklerConfiguration, Mirror.Of[T], Factory[T, C[T]]): Pickler[C[T]] = - summon[Pickler[T]].asIterable[C] - - given picklerForEither[A, B](using pa: Pickler[A], pb: Pickler[B]): Pickler[Either[A, B]] = - given Schema[A] = pa.schema - given Schema[B] = pb.schema - val newSchema = summon[Schema[Either[A, B]]] - - new Pickler[Either[A, B]]( - new TapirPickle[Either[A, B]] { - given Reader[A] = pa.innerUpickle.reader.asInstanceOf[Reader[A]] - given Writer[A] = pa.innerUpickle.writer.asInstanceOf[Writer[A]] - given Reader[B] = pb.innerUpickle.reader.asInstanceOf[Reader[B]] - given Writer[B] = pb.innerUpickle.writer.asInstanceOf[Writer[B]] - override lazy val writer = summon[Writer[Either[A, B]]] - override lazy val reader = summon[Reader[Either[A, B]]] - }, - newSchema - ) - - given picklerForArray[T: Pickler: ClassTag]: Pickler[Array[T]] = - summon[Pickler[T]].asArray - - inline given picklerForStringMap[V](using pv: Pickler[V]): Pickler[Map[String, V]] = - given Schema[V] = pv.schema - val newSchema = Schema.schemaForMap[V] - new Pickler[Map[String, V]]( - new TapirPickle[Map[String, V]] { - given Reader[V] = pv.innerUpickle.reader.asInstanceOf[Reader[V]] - given Writer[V] = pv.innerUpickle.writer.asInstanceOf[Writer[V]] - override lazy val writer = summon[Writer[Map[String, V]]] - override lazy val reader = summon[Reader[Map[String, V]]] - }, - newSchema - ) + /** A pickler over an existing schema and jsoniter-scala codec, for types whose JSON representation is hand-written (or comes from + * elsewhere) rather than derived. Put the result in a `given` and both derivation halves honour it wherever the type is nested. Keeping + * the two halves in step is the caller's responsibility here. + */ + def fromSchemaAndCodec[A](schema: Schema[A], codec: JsonValueCodec[A]): Pickler[A] = + internal.runtime.PicklerFactories.instance(schema, codec) - /** Create a pickler for a map with arbitrary keys. The pickler for the keys (`Pickler[K]`) should be string-like (that is, the schema - * type should be [[sttp.tapir.SchemaType.SString]]), however this cannot be verified at compile-time and is not verified at run-time. - * - * The given `keyToString` conversion function is used during validation. + /** Marker type: if an implicit instance is in scope, the macro will log its derivation process. * - * If you'd like this pickler to be available as a given type of keys, create an custom implicit, e.g.: - * - * {{{ - * case class MyKey(value: String) extends AnyVal - * given picklerForMyMap: Pickler[Map[MyKey, MyValue]] = Pickler.picklerForMap[MyKey, MyValue](_.value) - * }}} + * @see + * [[sttp.tapir.json.pickler.debug.logDerivationForPickler]] */ - inline def picklerForMap[K, V](keyToString: K => String)(using pk: Pickler[K], pv: Pickler[V]): Pickler[Map[K, V]] = - given Schema[V] = pv.schema - val newSchema = Schema.schemaForMap[K, V](keyToString) - new Pickler[Map[K, V]]( - new TapirPickle[Map[K, V]] { - given Reader[K] = pk.innerUpickle.reader.asInstanceOf[Reader[K]] - given Writer[K] = pk.innerUpickle.writer.asInstanceOf[Writer[K]] - given Reader[V] = pv.innerUpickle.reader.asInstanceOf[Reader[V]] - given Writer[V] = pv.innerUpickle.writer.asInstanceOf[Writer[V]] - override lazy val writer = summon[Writer[Map[K, V]]] - override lazy val reader = summon[Reader[Map[K, V]]] - }, - newSchema - ) - - given Pickler[JBigDecimal] = new Pickler[JBigDecimal]( - new TapirPickle[JBigDecimal] { - override lazy val writer = summon[Writer[BigDecimal]].comap(jBd => BigDecimal(jBd)) - override lazy val reader = summon[Reader[BigDecimal]].map(bd => bd.bigDecimal) - }, - summon[Schema[JBigDecimal]] - ) - - given Pickler[JBigInteger] = new Pickler[JBigInteger]( - new TapirPickle[JBigInteger] { - override lazy val writer = summon[Writer[BigInt]].comap(jBi => BigInt(jBi)) - override lazy val reader = summon[Reader[BigInt]].map(bi => bi.bigInteger) - }, - summon[Schema[JBigInteger]] - ) - - inline given picklerForAnyVal[T <: AnyVal]: Pickler[T] = ${ picklerForAnyValImpl[T] } - - // - - private inline def errorForType[T](inline template: String): Null = ${ errorForTypeImpl[T]('template) } - - private def errorForTypeImpl[T: Type](template: Expr[String])(using Quotes): Expr[Null] = { - import quotes.reflect.* - val templateStr = template.valueOrAbort - val typeName = TypeRepr.of[T].show - report.error(String.format(templateStr, typeName)) - '{ null } - } - - private def picklerForAnyValImpl[T: Type](using quotes: Quotes): Expr[Pickler[T]] = - import quotes.reflect.* - val tpe = TypeRepr.of[T] - - val isValueCaseClass = - tpe.typeSymbol.isClassDef && tpe.classSymbol.get.flags.is(Flags.Case) && tpe.baseClasses.contains(Symbol.classSymbol("scala.AnyVal")) - - if (!isValueCaseClass) { - '{ nonMirrorPickler[T] } - } else { - - val field = tpe.typeSymbol.declaredFields.head - val fieldTpe = tpe.memberType(field) - fieldTpe.asType match - case '[f] => - val basePickler = Expr.summon[Pickler[f]].getOrElse { - report.errorAndAbort( - s"Cannot summon Pickler for value class ${tpe.show}. Missing Pickler[${fieldTpe.show}] in implicit scope." - ) - } - '{ - val newSchema: Schema[T] = ${ basePickler }.schema.as[T] - new Pickler[T]( - new TapirPickle[T] { - override lazy val writer = summonInline[Writer[f]].comap[T]( - // writing object of type T means writing T.field - ccObj => ${ Select.unique(('ccObj).asTerm, field.name).asExprOf[f] } - ) - // a reader of type f (field) will read it and wrap into value object using the consutructor of T - override lazy val reader = summonInline[Reader[f]] - .map[T](fieldObj => ${ Apply(Select.unique(New(Inferred(tpe)), ""), List(('fieldObj).asTerm)).asExprOf[T] }) - }, - newSchema - ) - } - } - - private inline def fromExistingSchemaAndRw[T](schema: Schema[T])(using ClassTag[T], PicklerConfiguration): Pickler[T] = - Pickler( - new TapirPickle[T] { - override lazy val reader: Reader[T] = summonFrom { - case foundR: _root_.upickle.core.Types#Reader[T] => - foundR.asInstanceOf[Reader[T]] - case _ => - errorForType[T]( - "Found implicit Schema[%s] but couldn't find a uPickle Reader for this type. Either provide a Reader/ReadWriter, or remove the Schema from scope and let Pickler derive all on its own." - ) - null - } - override lazy val writer: Writer[T] = summonFrom { - case foundW: _root_.upickle.core.Types#Writer[T] => - foundW.asInstanceOf[Writer[T]] - case _ => - errorForType[T]( - "Found implicit Schema[%s] but couldn't find a uPickle Writer for this type. Either provide a Writer/ReadWriter, or remove the Schema from scope and let Pickler derive all on its own." - ) - null - } - }, - schema - ) - - private[pickler] inline def buildNewPickler[T: ClassTag]()(using m: Mirror.Of[T], config: PicklerConfiguration): Pickler[T] = - inline m match - case p: Mirror.ProductOf[T] => - // The lazy modifier is necessary for preventing infinite recursion in the derived instance for recursive types such as Lst - lazy val childPicklers: Tuple.Map[m.MirroredElemTypes, Pickler] = summonChildPicklerInstances[T, m.MirroredElemTypes] - picklerProduct(p, childPicklers) - case sum: Mirror.SumOf[T] => - inline if (isEnumeration[T]) - new CreateDerivedEnumerationPickler(Validator.derivedEnumeration[T], SchemaAnnotations.derived[T]).defaultStringBased(using sum) - else - given Configuration = config.genericDerivationConfig - val schema = Schema.derived[T] - lazy val childPicklers: Tuple.Map[m.MirroredElemTypes, Pickler] = summonChildPicklerInstances[T, m.MirroredElemTypes] - val discriminator: SubtypeDiscriminator[T] = DefaultSubtypeDiscriminator(config) - picklerSum(schema, childPicklers, discriminator) - - private[pickler] inline def summonChildPicklerInstances[T: ClassTag, Fields <: Tuple](using - m: Mirror.Of[T], - c: PicklerConfiguration - ): Tuple.Map[Fields, Pickler] = - inline erasedValue[Fields] match { - case _: (fieldType *: fieldTypesTail) => - val processedHead = deriveOrSummon[T, fieldType] - val processedTail = summonChildPicklerInstances[T, fieldTypesTail] - Tuple.fromArray((processedHead +: processedTail.toArray)).asInstanceOf[Tuple.Map[Fields, Pickler]] - case _: EmptyTuple.type => EmptyTuple.asInstanceOf[Tuple.Map[Fields, Pickler]] - } - - private inline def deriveOrSummon[T, FieldType](using PicklerConfiguration): Pickler[FieldType] = - inline erasedValue[FieldType] match - case _: T => deriveRec[T, FieldType] - case _ => - summonFrom { - case p: Pickler[FieldType] => p - case _ => - errorForType[FieldType]( - "Failed to summon Pickler[%s]. Try using Pickler.derived or importing sttp.tapir.json.pickler.generic.auto.*" - ) - } - - private inline def deriveRec[T, FieldType](using config: PicklerConfiguration): Pickler[FieldType] = - inline erasedValue[T] match - case _: FieldType => error("Infinite recursive derivation") - case _ => Pickler.derived[FieldType](using summonInline[ClassTag[FieldType]], config) - - // Extract child RWs from child picklers - // create a new RW from scratch using children rw and fields of the product - // use provided existing schema - // use data from schema to customize the new schema - private inline def picklerProduct[T: ClassTag, TFields <: Tuple]( - product: Mirror.ProductOf[T], - childPicklers: => Tuple.Map[TFields, Pickler] - )(using - config: PicklerConfiguration - ): Pickler[T] = - lazy val derivedChildSchemas: Tuple.Map[TFields, Schema] = - childPicklers.map([t] => (p: t) => p.asInstanceOf[Pickler[t]].schema).asInstanceOf[Tuple.Map[TFields, Schema]] - val schema: Schema[T] = productSchema(derivedChildSchemas) - // only now schema fields are enriched properly - val enrichedChildSchemas = schema.schemaType.asInstanceOf[SProduct[T]].fields.map(_.schema) - val childDefaults = enrichedChildSchemas.map(_.default.map(_._1)) - - val tapirPickle = new TapirPickle[T] { - override lazy val writer: Writer[T] = - macroProductW[T]( - schema, - childPicklers.map([a] => (obj: a) => obj.asInstanceOf[Pickler[a]].innerUpickle.writer).productIterator.toList, - childDefaults, - config - ) - override lazy val reader: Reader[T] = - macroProductR[T]( - schema, - childPicklers.map([a] => (obj: a) => obj.asInstanceOf[Pickler[a]].innerUpickle.reader), - childDefaults, - product, - config - ) - } - Pickler[T](tapirPickle, schema) - - private inline def productSchema[T, TFields <: Tuple](childSchemas: Tuple.Map[TFields, Schema])(using - config: PicklerConfiguration - ): Schema[T] = - SchemaDerivation.productSchema(config.genericDerivationConfig, childSchemas) - - private[tapir] inline def picklerSum[T: ClassTag, CP <: Tuple]( - schema: Schema[T], - childPicklers: => CP, - subtypeDiscriminator: SubtypeDiscriminator[T] - )(using - m: Mirror.Of[T], - config: PicklerConfiguration - ): Pickler[T] = - val childPicklersList = childPicklers.productIterator.toList.asInstanceOf[List[Pickler[_ <: T]]] - val tapirPickle = new TapirPickle[T] { - override lazy val writer: Writer[T] = - macroSumW[T]( - childPicklersList, - subtypeDiscriminator - ) - override lazy val reader: Reader[T] = - macroSumR[T]( - childPicklersList, - subtypeDiscriminator - ) - } - new Pickler[T](tapirPickle, schema) - -/** A pickler combines the [[Schema]] of a type (which is used for documentation and validation of deserialized values), with a uPickle - * encoder/decoder ([[ReadWriter]]). The pickler module can derive both the schema, and the uPickle readwriters in a single go, using a - * common configuration API. - * - * An in-scope pickler instance is required by [[jsonBody]] (and its variants), but it can also be manually converted to a codec using - * [[Pickler.toCodec]]. - */ -@implicitNotFound(msg = """Could not summon a Pickler for type ${T}. -Picklers can be derived automatically by adding: `import sttp.tapir.json.pickler.generic.auto.*`, or manually using `Pickler.derived[T]`. -The latter is also useful for debugging derivation errors. -You can find more details in the docs: https://tapir.softwaremill.com/en/latest/endpoint/pickler.html.""") -case class Pickler[T](innerUpickle: TapirPickle[T], schema: Schema[T]): - - def toCodec: JsonCodec[T] = - import innerUpickle._ - given innerUpickle.Reader[T] = innerUpickle.reader - given innerUpickle.Writer[T] = innerUpickle.writer - given schemaT: Schema[T] = schema - Codec.json[T] { s => - Try(read[T](s)) match { - case Success(v) => Value(v) - case Failure(e) => Error(s, JsonDecodeException(errors = List.empty, e)) - } - } { t => write(t) } - - def asOption: Pickler[Option[T]] = - val newSchema = schema.asOption - new Pickler[Option[T]]( - new TapirPickle[Option[T]] { - given Reader[T] = innerUpickle.reader.asInstanceOf[Reader[T]] - given Writer[T] = innerUpickle.writer.asInstanceOf[Writer[T]] - override lazy val writer = summon[Writer[Option[T]]] - override lazy val reader = summon[Reader[Option[T]]] - }, - newSchema - ) - - def asIterable[C[X] <: Iterable[X]](using Factory[T, C[T]]): Pickler[C[T]] = - val newSchema = schema.asIterable[C] - new Pickler[C[T]]( - new TapirPickle[C[T]] { - given Reader[T] = innerUpickle.reader.asInstanceOf[Reader[T]] - given Writer[T] = innerUpickle.writer.asInstanceOf[Writer[T]] - override lazy val writer = summon[Writer[C[T]]] - override lazy val reader = summon[Reader[C[T]]] - }, - newSchema - ) - - def asArray(using ct: ClassTag[T]): Pickler[Array[T]] = - val newSchema = schema.asArray - new Pickler[Array[T]]( - new TapirPickle[Array[T]] { - given Reader[T] = innerUpickle.reader.asInstanceOf[Reader[T]] - given Writer[T] = innerUpickle.writer.asInstanceOf[Writer[T]] - override lazy val writer = summon[Writer[Array[T]]] - override lazy val reader = summon[Reader[Array[T]]] - }, - newSchema - ) - -given picklerToCodec[T](using p: Pickler[T]): JsonCodec[T] = p.toCodec + sealed trait LogDerivation + object LogDerivation extends LogDerivation +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/PicklerCompanionCompat.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/PicklerCompanionCompat.scala new file mode 100644 index 0000000000..49e5a17d76 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/PicklerCompanionCompat.scala @@ -0,0 +1,78 @@ +package sttp.tapir.json.pickler + +import sttp.tapir.Schema + +/** Scala 3 entry points for [[Pickler]] derivation. + * + * This lives in its own trait (rather than directly in the `Pickler` companion) so that adding Scala 2.13 support later is a matter of + * moving this file to `src/main/scala-3` and adding a `src/main/scala-2` counterpart with `implicit def ... = macro ...` definitions. The + * shared macro logic in `internal.compiletime` stays untouched. + */ +private[pickler] trait PicklerCompanionCompat { this: Pickler.type => + + /** Derive a [[Pickler]] instance for `A` at compile time. + * + * Can be used explicitly, in the definition of a `given`, or indirectly via a `... derives Pickler` clause. It is deliberately not a + * `given` itself: automatic derivation is opt-in through `import sttp.tapir.json.pickler.generic.auto.*`. + */ + inline def derived[A](using inline config: PicklerConfiguration): Pickler[A] = + ${ internal.compiletime.PicklerMacros.derivePicklerImpl[A]('config) } + + /** Derive only the tapir [[Schema]] for `A`, without building a codec. + * + * This is the entry point used by the schema-focused regression tests, and is also useful on its own when a type is only ever + * documented, never serialized. + */ + inline def schemaFor[A](using inline config: PicklerConfiguration): Schema[A] = + ${ internal.compiletime.PicklerMacros.deriveSchemaOnlyImpl[A]('config) } + + /** Create a pickler for a sealed hierarchy `T`, where the discriminator value of each child is not derived from its type name but decided + * by the user: the value of `extractorFn` applied to the child, rendered with `asStringFn`. + * + * The children have to be listed explicitly, each with the value that selects it: + * {{{ + * Pickler.oneOfUsingField[Status, Int](_.code, code => s"code-$code")( + * 200 -> Pickler.derived[StatusOk], + * 400 -> Pickler.derived[StatusBadRequest] + * ) + * }}} + * + * The mapping keys and `asStringFn` must be evaluable at compile time (literals and lambdas over them), because the resulting + * discriminator values become part of the generated jsoniter codec. The children's *schemas* are taken from the given picklers; their + * *codecs* are derived here with the overridden discriminator values. + */ + inline def oneOfUsingField[T, V](inline extractorFn: T => V, inline asStringFn: V => String)( + inline mapping: (V, Pickler[? <: T])* + )(using inline config: PicklerConfiguration): Pickler[T] = + ${ internal.compiletime.PicklerMacros.oneOfUsingFieldImpl[T, V]('extractorFn, 'asStringFn, 'mapping, 'config) } + + /** Create a pickler for an enumeration: a sealed hierarchy or `enum` whose cases are all singletons (parameterised `enum` cases + * included). The returned builder chooses how the cases are rendered: + * {{{ + * Pickler.derivedEnumeration[Color].defaultStringBased // as Pickler.derived[Color] would + * Pickler.derivedEnumeration[Color].customStringBased(_.ordinal.toString) + * }}} + * The custom `encode` function is applied at runtime, so it is not restricted to compile-time evaluable code. + */ + inline def derivedEnumeration[T](using inline config: PicklerConfiguration): CreateDerivedEnumerationPickler[T] = + ${ internal.compiletime.PicklerMacros.derivedEnumerationImpl[T]('config) } + + /** Create a pickler for a map with arbitrary keys. Keys are rendered with `keyToString` and parsed back with `stringToKey`; the schema + * documents them through the same `keyToString`, as `Schema.schemaForMap` does. Values use the given pickler. + * + * Maps with `String` keys need none of this: they are derived directly. To make this pickler available for automatic derivation of + * enclosing types, define it as a `given`, e.g.: + * {{{ + * given Pickler[Map[UUID, Book]] = Pickler.picklerForMap(_.toString, UUID.fromString) + * }}} + */ + // `inline` so that `Schema.schemaForMap` (a macro) sees the concrete `K` and `V` at the call site and names the schema after them, + // rather than after the abstract type parameters `picklerForMap.K` / `picklerForMap.V`. + inline def picklerForMap[K, V](keyToString: K => String, stringToKey: String => K)(using pv: Pickler[V]): Pickler[Map[K, V]] = { + given Schema[V] = pv.schema + fromSchemaAndCodec( + Schema.schemaForMap[K, V](keyToString), + internal.runtime.CodecCombinators.map(keyToString, stringToKey, pv.codec) + ) + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/PicklerConfiguration.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/PicklerConfiguration.scala index 87dc1176ac..b3c6931e29 100644 --- a/json/pickler/src/main/scala/sttp/tapir/json/pickler/PicklerConfiguration.scala +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/PicklerConfiguration.scala @@ -1,9 +1,11 @@ package sttp.tapir.json.pickler import sttp.tapir.generic.Configuration -import upickle.core.Annotator -/** Configuration parameters for Pickler. +/** Configuration parameters for [[Pickler]] derivation. + * + * A single instance of this drives both halves of the derivation: the tapir `Schema` and the jsoniter-scala `JsonValueCodec`. + * * @param genericDerivationConfig * basic configuration for schema and codec derivation * @param transientNone @@ -12,7 +14,7 @@ import upickle.core.Annotator final case class PicklerConfiguration(genericDerivationConfig: Configuration, transientNone: Boolean = true) { export genericDerivationConfig.{toEncodedName, toDiscriminatorValue} - def discriminator: String = genericDerivationConfig.discriminator.getOrElse(Annotator.defaultTagKey) + def discriminator: String = genericDerivationConfig.discriminator.getOrElse(PicklerConfiguration.DefaultTagKey) def withSnakeCaseMemberNames: PicklerConfiguration = PicklerConfiguration(genericDerivationConfig.withSnakeCaseMemberNames) def withScreamingSnakeCaseMemberNames: PicklerConfiguration = PicklerConfiguration( @@ -41,7 +43,11 @@ final case class PicklerConfiguration(genericDerivationConfig: Configuration, tr } object PicklerConfiguration { + + /** The default discriminator field name. */ + final val DefaultTagKey = "$type" + given default: PicklerConfiguration = PicklerConfiguration( - Configuration.default.copy(discriminator = Some(Annotator.defaultTagKey)) + Configuration.default.copy(discriminator = Some(DefaultTagKey)) ) } diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/Readers.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/Readers.scala deleted file mode 100644 index 4f383d7d0d..0000000000 --- a/json/pickler/src/main/scala/sttp/tapir/json/pickler/Readers.scala +++ /dev/null @@ -1,93 +0,0 @@ -package sttp.tapir.json.pickler - -import _root_.upickle.implicits.{ReadersVersionSpecific, macros => upickleMacros} -import sttp.tapir.{Schema, SchemaType} - -import scala.deriving.Mirror -import scala.reflect.ClassTag - -/** A modification of upickle.implicits.Readers, implemented in order to provide our custom JSON decoding and typeclass derivation logic: - * - * 1. A CaseClassReader[T] is built based on readers for child fields passed as an argument, instead of just summoning these readers. - * This allows us to operate on Picklers and use readers extracted from these Picklers. Summoning is now done on Pickler, not Reader - * level. - * 1. Default values can be passed as parameters, which are read from Schema annotations if present. Vanilla uPickle reads defaults only - * from case class defaults. - * 1. Subtype discriminator can be passed as a parameter, allowing specyfing custom key for discriminator field, as well as function for - * extracting discriminator value. - * 1. Schema is passed as a parameter, so that we can use its encodedName to transform field keys. - * 1. Configuration can be used for setting discrtiminator field name or decoding all field names according to custom function (allowing - * transformations like snake_case, etc.) - */ -private[pickler] trait Readers extends ReadersVersionSpecific with UpickleHelpers { - - case class LeafWrapper[T](leaf: TaggedReader.Leaf[T], r: Reader[T], leafTagValue: String) extends TaggedReader[T] { - override def findReader(s: String) = if (s == leafTagValue) r else null - } - - override def annotate[V](rw: Reader[V], key: String, value: String) = { - LeafWrapper(new TaggedReader.Leaf[V](key, value, rw), rw, value) - } - - inline def macroProductR[T]( - schema: Schema[T], - childReaders: Tuple, - childDefaults: List[Option[Any]], - m: Mirror.ProductOf[T], - config: PicklerConfiguration - ): Reader[T] = - val schemaFields = schema.schemaType.asInstanceOf[SchemaType.SProduct[T]].fields - - val reader = new CaseClassReadereader[T](upickleMacros.paramsCount[T], upickleMacros.checkErrorMissingKeysCount[T]()) { - override def visitors0 = childReaders - override def fromProduct(p: Product): T = m.fromProduct(p) - override def keyToIndex(x: String): Int = - schemaFields.indexWhere(_.name.encodedName == x) - - override def allKeysArray = schemaFields.map(_.name.encodedName).toArray - override def storeDefaults(x: _root_.upickle.implicits.BaseCaseObjectContext): Unit = { - macros.storeDefaultsTapir[T](x, childDefaults) - } - } - - inline if upickleMacros.isSingleton[T] then - annotate[T](SingletonReader[T](upickleMacros.getSingleton[T]), config.discriminator, upickleMacros.tagName[T]) - else if upickleMacros.isMemberOfSealedHierarchy[T] then annotate[T](reader, config.discriminator, upickleMacros.tagName[T]) - else reader - - inline def macroSumR[T](childPicklers: List[Pickler[_]], subtypeDiscriminator: SubtypeDiscriminator[T]): Reader[T] = - implicit val currentlyDeriving: _root_.upickle.core.CurrentlyDeriving[T] = new _root_.upickle.core.CurrentlyDeriving() - subtypeDiscriminator match { - case discriminator: CustomSubtypeDiscriminator[T] => - // This part ensures that child product readers are replaced with product readers with proper "tag value". - // This value is used by uPickle internals to find a matching reader for given discriminator value. - // Originally product readers have this value set to class name when they are derived individually, - // so we need to 'fix' them here using discriminator settings. - val readersFromMapping = discriminator.mapping - .map { case (k, v) => (k, v.innerUpickle.reader) } - .map { - case (k, leaf) if leaf.isInstanceOf[LeafWrapper[_]] => - TaggedReader - .Leaf[T](discriminator.fieldName, discriminator.asString(k), leaf.asInstanceOf[LeafWrapper[_]].r.asInstanceOf[Reader[T]]) - case (_, otherKindOfReader) => - otherKindOfReader - } - - new TaggedReader.Node[T](discriminator.fieldName, readersFromMapping.asInstanceOf[Seq[TaggedReader[T]]]: _*) - - case discriminator: DefaultSubtypeDiscriminator[T] => - val readers = childPicklers.map(cp => { - (cp.schema.name, cp.innerUpickle.reader) match { - case (Some(sName), wrappedReader: Readers#LeafWrapper[_]) => - TaggedReader.Leaf[T]( - discriminator.fieldName, - discriminator.toValue(sName), - wrappedReader.r.asInstanceOf[Reader[T]] - ) - case _ => - cp.innerUpickle.reader.asInstanceOf[Reader[T]] - } - }) - Reader.merge(subtypeDiscriminator.fieldName, readers: _*) - } -} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/SchemaDerivation.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/SchemaDerivation.scala deleted file mode 100644 index a3fbd28a70..0000000000 --- a/json/pickler/src/main/scala/sttp/tapir/json/pickler/SchemaDerivation.scala +++ /dev/null @@ -1,226 +0,0 @@ -package sttp.tapir.json.pickler - -import sttp.tapir.SchemaType.{SProduct, SProductField, SRef} -import sttp.tapir.generic.Configuration -import sttp.tapir.{FieldName, Schema, SchemaType} - -import java.util.concurrent.ConcurrentHashMap -import scala.jdk.CollectionConverters.ConcurrentMapHasAsScala -import scala.quoted.* -import scala.reflect.ClassTag -import sttp.tapir.Validator - -private[pickler] object SchemaDerivation: - private[pickler] val deriveInProgress: scala.collection.mutable.Map[String, Unit] = new ConcurrentHashMap[String, Unit]().asScala - - inline def productSchema[T, TFields <: Tuple]( - genericDerivationConfig: Configuration, - childSchemas: Tuple.Map[TFields, Schema] - ): Schema[T] = - ${ productSchemaImpl('genericDerivationConfig, 'childSchemas) } - - def productSchemaImpl[T: Type, TFields <: Tuple]( - genericDerivationConfig: Expr[Configuration], - childSchemas: Expr[Tuple.Map[TFields, Schema]] - )(using Quotes, Type[TFields]): Expr[Schema[T]] = - new SchemaDerivation(genericDerivationConfig).productSchemaImpl(childSchemas) - -private class SchemaDerivation(genericDerivationConfig: Expr[Configuration])(using Quotes): - - import quotes.reflect.* - - private def productSchemaImpl[T: Type, TFields <: Tuple]( - childSchemas: Expr[Tuple.Map[TFields, Schema]] - )(using Quotes, Type[TFields]): Expr[Schema[T]] = - val tpe = TypeRepr.of[T] - val typeInfo = TypeInfo.forType(tpe) - val annotations = Annotations.onType(tpe) - val schema = '{ - Schema[T](schemaType = ${ productSchemaType(childSchemas) }, name = Some(${ typeNameToSchemaName(typeInfo, annotations) })) - } - enrichSchema(schema, annotations) - - private def productSchemaType[T: Type, TFields <: Tuple]( - childSchemas: Expr[Tuple.Map[TFields, Schema]] - )(using Quotes, Type[TFields]): Expr[SProduct[T]] = - val tpe: TypeRepr = TypeRepr.of[T] - val fieldsAnnotations = Annotations.onParams(tpe) - val childSchemasArray = '{ $childSchemas.toArray } - '{ - SProduct(${ - Expr.ofList(tpe.typeSymbol.caseFields.zipWithIndex.map { case (fieldSymbol, i) => - val name = Expr(fieldSymbol.name) - - val fieldTpe = tpe.memberType(fieldSymbol) - val fieldAnnotations = fieldsAnnotations.getOrElse(fieldSymbol.name, Annotations.Empty) - - val encodedName = fieldAnnotations.encodedName.getOrElse('{ $genericDerivationConfig.toEncodedName($name) }) - - fieldTpe.asType match - case '[f] => - val fieldSchema: Expr[Schema[f]] = '{ $childSchemasArray(${ Expr(i) }).asInstanceOf[Schema[f]] } - val enrichedFieldSchema = enrichSchema(fieldSchema, fieldAnnotations) - - '{ - SProductField( - FieldName($name, $encodedName), - $enrichedFieldSchema, - obj => Some(${ Select('{ obj }.asTerm, fieldSymbol).asExprOf[f] }) - ) - } - }) - }) - } - - // helper methods - - private def summonClassTag[T: Type]: Expr[ClassTag[T]] = Expr.summon[ClassTag[T]] match - case None => report.errorAndAbort(s"Cannot find a ClassTag for ${Type.show[T]}!") - case Some(ct) => ct - - private def summonChildSchema[T: Type]: Expr[Schema[T]] = Expr.summon[Schema[T]] match - case None => report.errorAndAbort(s"Cannot find schema for ${Type.show[T]}!") - case Some(s) => s - - /** To avoid recursive loops, we keep track of the fully qualified names of types for which derivation is in progress using a global - * mutable Set. - */ - private def withCache[T: Type](typeInfo: TypeInfo, annotations: Annotations)(f: => Expr[Schema[T]]): Expr[Schema[T]] = - import SchemaDerivation.deriveInProgress - val cacheKey = typeInfo.full - if deriveInProgress.contains(cacheKey) then '{ Schema[T](SRef(${ typeNameToSchemaName(typeInfo, annotations) })) } - else - try - deriveInProgress.put(cacheKey, ()) - val schema = f - schema - finally deriveInProgress.remove(cacheKey) - - private def typeNameToSchemaName(typeInfo: TypeInfo, annotations: Annotations): Expr[Schema.SName] = - val encodedName: Option[Expr[String]] = annotations.topLevelEncodedName - - encodedName match - case None => - def allTypeArguments(tn: TypeInfo): Seq[TypeInfo] = tn.typeParams.toList.flatMap(tn2 => tn2 +: allTypeArguments(tn2)) - '{ Schema.SName(${ Expr(typeInfo.full) }, ${ Expr.ofList(allTypeArguments(typeInfo).map(_.full).toList.map(Expr(_))) }) } - case Some(en) => - '{ Schema.SName($en, Nil) } - - private def enrichSchema[X: Type](schema: Expr[Schema[X]], annotations: Annotations): Expr[Schema[X]] = - annotations.all.foldLeft(schema) { (schema, annTerm) => - annTerm.asExpr match - case '{ $ann: Schema.annotations.description } => '{ $schema.description($ann.text) } - case '{ $ann: Schema.annotations.encodedExample } => '{ $schema.encodedExample($ann.example) } - case '{ $ann: Schema.annotations.default[? <: X] } => '{ $schema.default($ann.default, $ann.encoded) } - case '{ $ann: Schema.annotations.validate[X] } => '{ $schema.validate($ann.v) } - case '{ $ann: Schema.annotations.validateEach[?] } => - '{ $schema.modifyUnsafe[X](Schema.ModifyCollectionElements)((_: Schema[X]).validate($ann.v.asInstanceOf[Validator[X]])) } - case '{ $ann: Schema.annotations.format } => '{ $schema.format($ann.format) } - case '{ $ann: Schema.annotations.deprecated } => '{ $schema.deprecated(true) } - case '{ $ann: Schema.annotations.customise } => '{ $ann.f($schema).asInstanceOf[Schema[X]] } - case _ => schema - } - - // helper classes - - private case class TypeInfo(owner: String, short: String, typeParams: Iterable[TypeInfo]): - def full: String = s"$owner.$short" - - private object TypeInfo: - def forType(tpe: TypeRepr): TypeInfo = - def normalizedName(s: Symbol): String = - if s.flags.is(Flags.Module) then s.name.stripSuffix("$") else s.name - def name(tpe: TypeRepr): String = tpe match - case TermRef(typeRepr, name) if tpe.typeSymbol.flags.is(Flags.Module) => name.stripSuffix("$") - case TermRef(typeRepr, name) => name - case _ => normalizedName(tpe.typeSymbol) - - def ownerNameChain(sym: Symbol): List[String] = - if sym.isNoSymbol then List.empty - else if sym == defn.EmptyPackageClass then List.empty - else if sym == defn.RootPackage then List.empty - else if sym == defn.RootClass then List.empty - else ownerNameChain(sym.owner) :+ normalizedName(sym) - - def owner(tpe: TypeRepr): String = ownerNameChain(tpe.typeSymbol.maybeOwner).mkString(".") - - tpe match - case AppliedType(tpe, args) => TypeInfo(owner(tpe), name(tpe), args.map(forType)) - case _ => TypeInfo(owner(tpe), name(tpe), Nil) - - // - private class Annotations(topLevel: List[Term], inherited: List[Term]): - lazy val all: List[Term] = - // skip inherited annotations if defined at the top-level - topLevel ++ inherited.filterNot(i => topLevel.exists(t => t.tpe <:< i.tpe)) - - def topLevelEncodedName: Option[Expr[String]] = findEncodedName(topLevel) - - def encodedName: Option[Expr[String]] = findEncodedName(all) - - private def findEncodedName(terms: List[Term]): Option[Expr[String]] = terms - .map(_.asExpr) - .collectFirst { case '{ $en: Schema.annotations.encodedName } => en } - .map(en => '{ $en.name }) - - private object Annotations: - val Empty: Annotations = Annotations(Nil, Nil) - - def onType(tpe: TypeRepr): Annotations = - val topLevel: List[Term] = tpe.typeSymbol.annotations.filter(filterAnnotation) - val inherited: List[Term] = - tpe.baseClasses - .filterNot(isObjectOrScala) - .collect { - case s if s != tpe.typeSymbol => s.annotations - } // skip self - .flatten - .filter(filterAnnotation) - Annotations(topLevel, inherited) - - def onParams(tpe: TypeRepr): Map[String, Annotations] = - def paramAnns: List[(String, List[Term])] = groupByParamName { - (fromConstructor(tpe.typeSymbol) ++ fromDeclarations(tpe.typeSymbol)) - .filter { case (_, anns) => anns.nonEmpty } - } - - def inheritedParamAnns: List[(String, List[Term])] = - groupByParamName { - tpe.baseClasses - .filterNot(isObjectOrScala) - .collect { - case s if s != tpe.typeSymbol => - (fromConstructor(s) ++ fromDeclarations(s)).filter { case (_, anns) => - anns.nonEmpty - } - } - .flatten - } - - def fromConstructor(from: Symbol): List[(String, List[Term])] = - from.primaryConstructor.paramSymss.flatten.map { field => field.name -> field.annotations.filter(filterAnnotation) } - - def fromDeclarations(from: Symbol): List[(String, List[Term])] = - from.declarations.collect { - // using TypeTest - case field: Symbol if (field.tree match { case _: ValDef => true; case _ => false }) => - field.name -> field.annotations.filter(filterAnnotation) - } - - def groupByParamName(anns: List[(String, List[Term])]) = - anns - .groupBy { case (name, _) => name } - .toList - .map { case (name, l) => name -> l.flatMap(_._2) } - - val topLevel = paramAnns.toMap - val inherited = inheritedParamAnns.toMap - val params = topLevel.keySet ++ inherited.keySet - params.map(p => p -> Annotations(topLevel.getOrElse(p, Nil), inherited.getOrElse(p, Nil))).toMap - - private def isObjectOrScala(bc: Symbol) = - bc.name.contains("java.lang.Object") || bc.fullName.startsWith("scala.") - - private def filterAnnotation(a: Term): Boolean = - a.tpe.typeSymbol.maybeOwner.isNoSymbol || - a.tpe.typeSymbol.owner.fullName != "scala.annotation.internal" diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/SubtypeDiscriminator.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/SubtypeDiscriminator.scala deleted file mode 100644 index ef3e6fdf28..0000000000 --- a/json/pickler/src/main/scala/sttp/tapir/json/pickler/SubtypeDiscriminator.scala +++ /dev/null @@ -1,27 +0,0 @@ -package sttp.tapir.json.pickler - -import sttp.tapir.Validator -import sttp.tapir.Schema.SName - -private[pickler] sealed trait SubtypeDiscriminator[T]: - def fieldName: String - -/** Describes non-standard encoding/decoding for subtypes in sealed hierarchies. Allows specifying an extractor function, for example to - * read subtype discriminator from a field. Requires also mapping in the opposite direction, to specify how to read particular - * discriminator values into concrete subtype picklers. - */ -private[pickler] trait CustomSubtypeDiscriminator[T] extends SubtypeDiscriminator[T]: - type V - def extractor: T => V - def asString: V => String - def write(t: T): String = asString(extractor(t)) - def mapping: Seq[(V, Pickler[_ <: T])] - - // to integrate with uPickle where at some point all we have is Any - def writeUnsafe(t: Any): String = asString(extractor(t.asInstanceOf[T])) - -private[pickler] case class DefaultSubtypeDiscriminator[T](fieldName: String, toValue: SName => String) extends SubtypeDiscriminator[T] - -private[pickler] object DefaultSubtypeDiscriminator: - def apply[T](config: PicklerConfiguration): DefaultSubtypeDiscriminator[T] = - new DefaultSubtypeDiscriminator[T](config.discriminator, config.toDiscriminatorValue) diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/TapirPickle.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/TapirPickle.scala deleted file mode 100644 index c45ee95ca4..0000000000 --- a/json/pickler/src/main/scala/sttp/tapir/json/pickler/TapirPickle.scala +++ /dev/null @@ -1,24 +0,0 @@ -package sttp.tapir.json.pickler - -import _root_.upickle.AttributeTagged - -/** Our custom modification of uPickle encoding/decoding logic. A standard way to use uPickle is to import `upickle.default` object which - * allows generating Reader[T]/Writer[T]. We create our own object with same API as `upickle.default`, but modified logic, which can be - * found in Readers and Writers traits. - */ -trait TapirPickle[T] extends AttributeTagged with Readers with Writers: - def reader: this.Reader[T] - def writer: this.Writer[T] - - // This ensures that None is encoded as null instead of an empty array - override given OptionWriter[T: Writer]: Writer[Option[T]] = - summon[Writer[T]].comapNulls[Option[T]] { - case None => null.asInstanceOf[T] - case Some(x) => x - } - - // This ensures that null is read as None - override given OptionReader[T: Reader]: Reader[Option[T]] = - new Reader.Delegate[Any, Option[T]](summon[Reader[T]].map(Some(_))) { - override def visitNull(index: Int) = None - } diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/UpickleHelpers.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/UpickleHelpers.scala deleted file mode 100644 index 4a11d64405..0000000000 --- a/json/pickler/src/main/scala/sttp/tapir/json/pickler/UpickleHelpers.scala +++ /dev/null @@ -1,11 +0,0 @@ -package sttp.tapir.json.pickler - -private[pickler] trait UpickleHelpers: - def scanChildren[T, V](xs: Seq[T])(f: T => V) = // copied from uPickle - var x: V = null.asInstanceOf[V] - val i = xs.iterator - while (x == null && i.hasNext) { - val t = f(i.next()) - if (t != null) x = t - } - x diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/Writers.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/Writers.scala deleted file mode 100644 index 289c28f4a4..0000000000 --- a/json/pickler/src/main/scala/sttp/tapir/json/pickler/Writers.scala +++ /dev/null @@ -1,104 +0,0 @@ -package sttp.tapir.json.pickler - -import _root_.upickle.core.Annotator.Checker -import _root_.upickle.core.{ObjVisitor, Visitor, _} -import _root_.upickle.implicits.{WritersVersionSpecific, macros => upickleMacros} -import sttp.tapir.Schema -import sttp.tapir.SchemaType.SProduct -import sttp.tapir.generic.Configuration -import sttp.tapir.internal.EnumerationMacros.* - -import scala.reflect.ClassTag - -/** A modification of upickle.implicits.Writers, implemented in order to provide our custom JSON encoding and typeclass derivation logic: - * - * 1. A CaseClassWriter[T] is built based on writers for child fields passed as an argument, instead of just summoning these writers. - * This allows us to operate on Picklers and use Writers extracted from these Picklers. Summoning is now done on Pickler, not Writer - * level. - * 1. Default values can be passed as parameters, which are read from Schema annotations if present. Vanilla uPickle reads defaults only - * from case class defaults. - * 1. Subtype discriminator can be passed as a parameter, allowing specyfing custom key for discriminator field, as well as function for - * extracting discriminator value - * 1. Schema is passed as a parameter, so that we can use its encodedName to transform field keys - * 1. Configuration can be used for setting discrtiminator field name or encoding all field names according to custom function (allowing - * transformations like snake_case, etc.) - */ -private[pickler] trait Writers extends WritersVersionSpecific with UpickleHelpers: - inline def macroProductW[T: ClassTag]( - schema: Schema[T], - childWriters: => List[Any], - childDefaults: => List[Option[Any]], - config: PicklerConfiguration - ) = - lazy val writer = new CaseClassWriter[T] { - def length(v: T) = upickleMacros.writeLength[T](outerThis, v) - - val sProduct = schema.schemaType.asInstanceOf[SProduct[T]] - - override def write0[R](out: Visitor[_, R], v: T): R = { - if (v == null) out.visitNull(-1) - else { - val ctx = out.visitObject(length(v), true, -1) - macros.writeSnippets[R, T]( - sProduct, - outerThis, - this, - v, - ctx, - childWriters, - childDefaults, - config.transientNone - ) - ctx.visitEnd(-1) - } - } - - def writeToObject[R](ctx: _root_.upickle.core.ObjVisitor[_, R], v: T): Unit = - macros.writeSnippets[R, T]( - sProduct, - outerThis, - this, - v, - ctx, - childWriters, - childDefaults, - config.transientNone - ) - } - - inline if upickleMacros.isMemberOfSealedHierarchy[T] && !isEnumeration[T] then - annotate[T]( - writer, - config.discriminator, - schema.name.map(config.toDiscriminatorValue).getOrElse(upickleMacros.tagName[T]), - Annotator.Checker.Cls(implicitly[ClassTag[T]].runtimeClass) - ) // tagName is responsible for extracting the @tag annotation meaning the discriminator value - else if upickleMacros.isSingleton[T] - then // moved after "if MemberOfSealed" to handle case objects in hierarchy as case classes - with discriminator, for consistency - // here we handle enums - annotate[T]( - SingletonWriter[T](null.asInstanceOf[T]), - config.discriminator, - upickleMacros.tagName[T], - Annotator.Checker.Val(upickleMacros.getSingleton[T]) - ) - else writer - - inline def macroSumW[T: ClassTag](childPicklers: => List[Pickler[? <: T]], subtypeDiscriminator: SubtypeDiscriminator[T])(using - Configuration - ) = - implicit val currentlyDeriving: _root_.upickle.core.CurrentlyDeriving[T] = new _root_.upickle.core.CurrentlyDeriving() - val writers: List[TaggedWriter[_ <: T]] = childPicklers.map(_.innerUpickle.writer.asInstanceOf[TaggedWriter[_ <: T]]) - - new TaggedWriter.Node[T](writers: _*) { - override def findWriterWithKey(v: Any): (String, String, ObjectWriter[T]) = { - subtypeDiscriminator match { - case discriminator: CustomSubtypeDiscriminator[T] => - val (tagKey, tagValue, w) = super.findWriterWithKey(v) - val overriddenTag = discriminator.writeUnsafe(v) // here we use our discirminator instead of uPickle's - (tagKey, overriddenTag, w) - case _ => - super.findWriterWithKey(v) - } - } - } diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/debug/package.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/debug/package.scala new file mode 100644 index 0000000000..676bf81c6f --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/debug/package.scala @@ -0,0 +1,10 @@ +package sttp.tapir.json.pickler + +/** Import `sttp.tapir.json.pickler.debug.logDerivationForPickler` to make the derivation macro log every step of its work as compiler + * `info` messages. + * + * The equivalent global switch is the scalac option `-Xmacro-settings:tapirPickler.logDerivation=true`. + */ +package object debug { + implicit val logDerivationForPickler: Pickler.LogDerivation = Pickler.LogDerivation +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/generic.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/generic.scala index d55fd1fc9d..bc5a3b291b 100644 --- a/json/pickler/src/main/scala/sttp/tapir/json/pickler/generic.scala +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/generic.scala @@ -1,14 +1,17 @@ package sttp.tapir.json.pickler.generic -import sttp.tapir.json.pickler.Pickler -import sttp.tapir.json.pickler.PicklerConfiguration +import sttp.tapir.json.pickler.{Pickler, PicklerConfiguration} -import scala.reflect.ClassTag -import scala.deriving.Mirror - -/** Import `sttp.tapir.json.pickler.auto.*`` for automatic generic pickler derivation. A [[Pickler]] will be derived at the usage side using - * [[Pickler.derived]] for each type where a given `Pickler` is not available in the current given/implicit scope. +/** Import `sttp.tapir.json.pickler.generic.auto.*` for automatic pickler derivation: a [[Pickler]] will be derived at the use site for + * every type that does not already have one in the given/implicit scope. + * + * There is deliberately no `Mirror.Of[T]` requirement — the derivation handles primitives, collections and `Map`s as well as product and + * sum types, so constraining it to Mirrors would exclude exactly the types that need no user-visible ceremony. */ object auto { - inline implicit def picklerForCaseClass[T: ClassTag](implicit m: Mirror.Of[T], c: PicklerConfiguration): Pickler[T] = Pickler.derived[T] + + /** `config` is an `inline` parameter on purpose: the derivation must evaluate the configuration at compile time (see + * `PicklerMacrosImpl.foldConfiguration`), which needs the *reference to the given*, not a proxy `val` holding it. + */ + inline implicit def picklerForType[T](implicit inline config: PicklerConfiguration): Pickler[T] = Pickler.derived[T] } diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/AnnotationSupport.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/AnnotationSupport.scala new file mode 100644 index 0000000000..c1ac99a271 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/AnnotationSupport.scala @@ -0,0 +1,118 @@ +package sttp.tapir.json.pickler.internal.compiletime + +import hearth.MacroCommons +import hearth.std.* +import sttp.tapir.Schema + +/** Cross-platform access to the annotations the tapir `Schema` derivation cares about. + * + * Hearth's `Type#annotations` / `Parameter#annotations` already abstract over the Scala 2 and Scala 3 reflection APIs, so nothing here is + * platform-specific. They are lowered to [[UntypedExpr]] so that the caller can re-type them as `Any` and splice them into a `List[Any]` + * that the runtime helpers fold over — which keeps annotation *matching* out of the macro entirely. + */ +trait AnnotationSupport { this: MacroCommons & StdExtensions => + + /** Annotations on a constructor parameter, including those inherited from a member of the same name declared on a base class or trait. + * + * Inheritance matters because tapir users routinely document a hierarchy once, on the parent: + * {{{ + * sealed trait Pet { @description("name") def name: String } + * case class Dog(name: String, @description("dog food") dogFood: String) extends Pet + * }}} + * `Dog.name` must pick up `@description("name")`. Hearth's `Parameter#annotations` only reports annotations written on the parameter + * itself, so the base classes are walked explicitly. + * + * An annotation declared on the parameter wins over an inherited one of the same type, which is what lets a subclass override a parent's + * `@description`. + */ + protected def allParamAnnotations[A: Type](param: Parameter, memberName: String): List[UntypedExpr] = + allParamAnnotationsTyped[A](param, memberName).map(_.asUntyped) + + /** Same as [[allParamAnnotations]], but keeping the annotation types so that callers can look one up. */ + protected def allParamAnnotationsTyped[A: Type](param: Parameter, memberName: String): List[Expr_??] = + withInheritanceAppliedTyped(param.annotations, inheritedMemberAnnotations[A](memberName)) + + /** The literal argument of a field's `@encodedName`, if the field has one. + * + * `Left` when the annotation is present but its argument is not a string literal: the codec half needs the value during expansion (it + * becomes a jsoniter field-name mapping), so a computed argument cannot be honoured, and silently falling back to the configured + * transformation would let the schema (which folds the annotation at runtime) disagree with the JSON. + */ + protected def literalEncodedFieldName[A: Type](param: Parameter, memberName: String): Either[String, Option[String]] = { + implicit val EncodedNameT: Type[Schema.annotations.encodedName] = Type.of[Schema.annotations.encodedName] + allParamAnnotationsTyped[A](param, memberName) + .find { annotation => + import annotation.Underlying as Ann + Type[Ann] <:< Type[Schema.annotations.encodedName] + } match { + case None => Right(None) + case Some(annotation) => + literalStringArg(annotation.value) match { + case Some(value) => Right(Some(value)) + case None => + Left( + s"@encodedName on field '$memberName' of ${Type[A].plainPrint} must be a string literal " + + s"(got: ${annotation.value.plainPrint})" + ) + } + } + } + + /** Annotations on a type, including those inherited from its base classes. + * + * Note that `@encodedName` is deliberately *not* consumed from this list when building an `SName` — see `SchemaDerivation.sNameExpr`. A + * parent's `@encodedName` must not rename its subtypes. + */ + protected def allTypeAnnotations[A: Type]: List[UntypedExpr] = + withInheritanceApplied( + Type[A].annotations, + baseClassesOf[A].flatMap { base => + import base.Underlying as Base + Type[Base].annotations + } + ) + + /** Annotations on same-named members of every base class, in linearization order. */ + private def inheritedMemberAnnotations[A: Type](memberName: String): List[Expr_??] = + baseClassesOf[A].flatMap { base => + import base.Underlying as Base + Type[Base].unsortedMethods.filter(_.name == memberName).flatMap(_.annotations) + } + + /** Base classes of `A`, excluding `A` itself and the universal/`scala.*` ancestors that carry nothing useful. */ + private def baseClassesOf[A: Type]: List[??] = + Type[A].baseClasses.filterNot { base => + import base.Underlying as Base + val name = Type[Base].plainPrint + (Base =:= Type[A]) || name.startsWith("scala.") || name == "java.lang.Object" || name == "java.io.Serializable" + } + + /** Concatenate own and inherited annotations, dropping inherited ones already present (by type) on the member. */ + private def withInheritanceApplied(own: List[Expr_??], inherited: List[Expr_??]): List[UntypedExpr] = + withInheritanceAppliedTyped(own, inherited).map(_.asUntyped) + + private def withInheritanceAppliedTyped(own: List[Expr_??], inherited: List[Expr_??]): List[Expr_??] = + own ++ inherited.filterNot { i => + own.exists { o => + import o.Underlying as Own, i.Underlying as Inherited + Type[Own] <:< Type[Inherited] + } + } + + /** The string argument of a single-string annotation, when it is a literal. + * + * Used for `@encodedName` on a type, which has to be known at compile time because it replaces the `SName` that other parts of the + * derivation (notably the discriminator mapping) are built from. + */ + protected def literalStringArg[Ann](annotation: Expr[Ann]): Option[String] = + Annotations.decodedConstructorArguments(annotation).getOrElse(Nil) match { + case List(Right(value: String)) => Some(value) + case _ => None + } + + /** The type-level `@encodedName` of `A`, when it is a string literal. Both derivation halves read it through this. */ + protected def typeEncodedName[A: Type]: Option[String] = { + implicit val EncodedNameT: Type[Schema.annotations.encodedName] = Type.of[Schema.annotations.encodedName] + Type[A].annotationsOfType[Schema.annotations.encodedName].headOption.flatMap(literalStringArg(_)) + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/CodecDerivation.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/CodecDerivation.scala new file mode 100644 index 0000000000..bd9bf6c0d0 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/CodecDerivation.scala @@ -0,0 +1,370 @@ +package sttp.tapir.json.pickler.internal.compiletime + +import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec +import com.github.plokhotnyuk.jsoniter_scala.macros.{CodecMakerConfig, JsonCodecMaker} +import hearth.MacroCommons +import hearth.fp.effect.* +import hearth.std.* +import sttp.tapir.json.pickler.PicklerConfiguration +import sttp.tapir.json.pickler.internal.runtime.{CodecCombinators, LeafCodecs} + +/** Derivation of the `JsonValueCodec` half of a `Pickler`, by **configuring `JsonCodecMaker.make`** rather than by generating reader/writer + * code ourselves. + * + * ==Shape of the generated code== + * {{{ + * { + * implicit lazy val codec$Address: JsonValueCodec[Address] = JsonCodecMaker.make[Address]() + * implicit lazy val codec$Status: JsonValueCodec[Status] = JsonCodecMaker.make[Status]() + * JsonCodecMaker.make[Person]() + * } + * }}} + * One `make` per case class and per sealed hierarchy in the type graph, each with its own configuration. jsoniter finds the sibling codecs + * through implicit search when it meets a nested type, which is what allows every configuration to be *local*: `fieldNameMapper` is keyed + * by bare field name, so a graph-wide mapper could not express `@encodedName` on one class's `name` but not another's. + * + * ==Why the configuration is computed here== + * jsoniter interprets its `CodecMakerConfig` argument at *its* expansion time, and only accepts trees made of literals and stable + * references. tapir's `PicklerConfiguration` is a runtime value, so it is folded with `semiEval` first, its `toEncodedName` / + * `toDiscriminatorValue` are *invoked* during our expansion ([[NameSupport]]), and the results are emitted as literal `Match` cases (see + * [[PlatformSupport]]). The schema half splices the very same literals, so the two agree by construction. + * + * ==Knobs, and why each is set== + * - `transientEmpty(false)`: empty collections are written as `[]`. + * - `transientDefault(false)`: fields equal to their Scala default are still written. + * - `transientNone`: from `PicklerConfiguration.transientNone`. + * - `requireDiscriminatorFirst(false)`: the discriminator may appear anywhere in the object. + * - `allowRecursiveTypes(true)`: recursion is handled by jsoniter's own `def`s; the schema uses `SRef`. + * - `alwaysEmitDiscriminator(true)` on case classes: a leaf written on its own carries its discriminator, so that a member of a sealed + * hierarchy is encoded the same way whether it is written through the parent or through its own pickler. It is also what makes + * per-leaf codecs composable into the hierarchy codec: jsoniter delegates to the leaf's implicit and the leaf writes the tag itself. + * - `discriminatorFieldName(None)` on a hierarchy whose leaves are all singletons: such hierarchies are encoded as bare strings. + * + * ==`Either`== + * jsoniter has no encoding for it, so an `Either[L, R]` in the graph gets a hand-written `CodecCombinators.either` val over the codecs of + * its two sides (untagged, as tapir core's `Codec.eitherRight`). A side that has a val of its own is referenced; any other (a primitive, a + * collection) gets an inline `make` under the base config. + * + * ==What is deliberately not supported== + * tapir's `@default` annotation does not drive decoding: jsoniter fills a missing field only from a Scala default parameter and has no + * hook for anything else. Scala default parameters *are* honoured. + */ +trait CodecDerivation { + this: MacroCommons & StdExtensions & TypeShape & AnnotationSupport & NameSupport & PlatformSupport & ImplicitPicklerSupport => + + private[compiletime] object CTypes { + def CodecOf[A: Type]: Type[JsonValueCodec[A]] = Type.of[JsonValueCodec[A]] + lazy val MakerConfig: Type[CodecMakerConfig] = Type.of[CodecMakerConfig] + lazy val StringT: Type[String] = Type.of[String] + lazy val BooleanT: Type[Boolean] = Type.of[Boolean] + lazy val OptionStringT: Type[Option[String]] = Type.of[Option[String]] + lazy val StringPF: Type[PartialFunction[String, String]] = Type.of[PartialFunction[String, String]] + lazy val StringFn: Type[String => String] = Type.of[String => String] + lazy val JBigDecimalT: Type[java.math.BigDecimal] = Type.of[java.math.BigDecimal] + lazy val JBigIntegerT: Type[java.math.BigInteger] = Type.of[java.math.BigInteger] + } + + /** References to the sibling vals of the generated block, by the `typeKey` of the type they are a codec for. */ + private type CodecRefs = String => Option[UntypedExpr] + + /** One `implicit lazy val` of type `JsonValueCodec[tpe]` the generated code will contain: a `JsonCodecMaker.make` call, a hand-written + * leaf codec from [[LeafCodecs]], a user pickler's codec, or a [[CodecCombinators]] call over sibling vals (hence the right-hand side is + * a function of the block's references). + */ + private final case class CodecVal(tpe: ??, rhs: CodecRefs => UntypedExpr) + private object CodecVal { + def const(tpe: ??, rhs: UntypedExpr): CodecVal = CodecVal(tpe, _ => rhs) + } + + // ----------------------------------------------------------------------------------------------------------------- + // Entry point + // ----------------------------------------------------------------------------------------------------------------- + + def deriveCodec[A: Type](env: DerivationEnv): MIO[Expr[JsonValueCodec[A]]] = + Log.namedScope(s"deriveCodec[${Type[A].prettyPrint}]") { + implicit val CodecA: Type[JsonValueCodec[A]] = CTypes.CodecOf[A] + val rootShape = classify[A] + for { + // The root is subject to the same rule as nested types: `make[A]` never looks its own type up, so a bare + // `given JsonValueCodec[A]` would be silently ignored while the user expects it to be honoured. + _ <- rejectBareCodec[A](rootShape) + units <- walkChildren(childrenOf(rootShape), env, Set(typeKey[A]), Vector.empty).map(_._2.toList) + // The root gets an implicit too. `make[A]` itself never looks its own type up (jsoniter pre-seeds the root as + // "no implicit"), but a *nested* `make[Leaf]` whose field refers back to `A` must find it: otherwise jsoniter + // would inline `A` there, under the leaf's configuration -- whose leaf-name mapper knows only that one leaf. + rootUnits <- codecValFor[A](rootShape, env, isRoot = true).map { + case Nil => List(CodecVal.const(Type[A].as_??, makeExpr[A](baseConfig(env.config)).asUntyped)) + case units => units + } + _ <- Log.info(s"Emitting ${units.size} nested codec(s): ${units.map(_.tpe.Underlying.plainPrint).mkString(", ")}") + } yield { + // The last unit is the root's own codec, whatever else its shape needed emitted before it. + val all = units ++ rootUnits + val keys = all.map { unit => import unit.tpe.Underlying as U; typeKey[U] } + def refsByType(refs: List[UntypedExpr]): CodecRefs = key => + keys.indexOf(key) match { + case -1 => None + case i => Some(refs(i)) + } + val vals = all.zipWithIndex.map { case (unit, i) => + import unit.tpe.Underlying as U + implicit val CodecU: Type[JsonValueCodec[U]] = CTypes.CodecOf[U] + (s"codec$$${Type[U].shortName}$$$i", Type[JsonValueCodec[U]].asUntyped, (refs: List[UntypedExpr]) => unit.rhs(refsByType(refs))) + } + implicitLazyVals[JsonValueCodec[A]](vals)(refs => refs.last.asTyped[JsonValueCodec[A]]) + } + } + + private def makeExpr[A: Type](config: Expr[CodecMakerConfig]): Expr[JsonValueCodec[A]] = { + implicit val CodecA: Type[JsonValueCodec[A]] = CTypes.CodecOf[A] + implicit val ConfigT: Type[CodecMakerConfig] = CTypes.MakerConfig + Expr.quote(JsonCodecMaker.make[A](Expr.splice(config))) + } + + // ----------------------------------------------------------------------------------------------------------------- + // Type graph walk + // ----------------------------------------------------------------------------------------------------------------- + + /** The state threaded through the walk over every type reachable from the root (dependencies first): the types already visited, and the + * vals emitted so far. + */ + private type Walk = (Set[String], Vector[CodecVal]) + + private def makeUnit[A: Type](config: Expr[CodecMakerConfig]): CodecVal = CodecVal.const(Type[A].as_??, makeExpr[A](config).asUntyped) + + /** The vals `A` contributes to the block given its shape (usually one, for `A` itself; empty for the shapes jsoniter derives on its own). + * Shared by the root and the nested walk; for the root, the last val must be `A`'s own codec. + */ + private def codecValFor[A: Type](shape: Shape[A], env: DerivationEnv, isRoot: Boolean): MIO[List[CodecVal]] = shape match { + case Shape.JavaBigDecimal() => MIO.pure(List(javaBigDecimalVal[A])) + case Shape.JavaBigInteger() => MIO.pure(List(javaBigIntegerVal[A])) + case Shape.Product(_, params) => productConfig[A](params, env).map(cfg => List(makeUnit[A](cfg))) + case Shape.Enumeration(_, leaves) => hierarchyConfig[A](leaves, asEnumeration = true, env).map(cfg => List(makeUnit[A](cfg))) + case Shape.Coproduct(_, leaves) => hierarchyConfig[A](leaves, asEnumeration = false, env).map(cfg => List(makeUnit[A](cfg))) + case Shape.EitherOf(l, r) => MIO.pure(List(eitherUnit[A](l, r, env.config))) + case Shape.NestedOption(i, e) => MIO.pure(nestedOptionUnits[A](i, e, env.config, isRoot)) + case Shape.Tuple() => fail(PicklerDerivationError.TupleNotSupported(Type[A].plainPrint)) + case Shape.NonStringMap(key, _) => fail(PicklerDerivationError.NonStringMapKey(key.Underlying.plainPrint)) + case _ => MIO.pure(Nil) + } + + private def fail[T](error: PicklerDerivationError): MIO[T] = Log.error(error.message) >> MIO.fail(error) + + // Hand-written codecs from `LeafCodecs`, for leaf types tapir has a `Schema` for but `JsonCodecMaker` cannot derive. + + private def javaBigDecimalVal[A: Type]: CodecVal = { + implicit val JBigDecimalT: Type[java.math.BigDecimal] = CTypes.JBigDecimalT + implicit val CodecBD: Type[JsonValueCodec[java.math.BigDecimal]] = CTypes.CodecOf[java.math.BigDecimal] + CodecVal.const(Type[A].as_??, Expr.quote(LeafCodecs.javaBigDecimal).asUntyped) + } + + private def javaBigIntegerVal[A: Type]: CodecVal = { + implicit val JBigIntegerT: Type[java.math.BigInteger] = CTypes.JBigIntegerT + implicit val CodecBI: Type[JsonValueCodec[java.math.BigInteger]] = CTypes.CodecOf[java.math.BigInteger] + CodecVal.const(Type[A].as_??, Expr.quote(LeafCodecs.javaBigInteger).asUntyped) + } + + /** The codec of a sibling type: its val in the block when it has one, otherwise an inline `make` under the base config (primitives, + * collections -- anything jsoniter derives on its own and we emit no val for). + */ + private def refOrMake[S: Type](refs: CodecRefs, config: PicklerConfiguration): Expr[JsonValueCodec[S]] = { + implicit val CodecS: Type[JsonValueCodec[S]] = CTypes.CodecOf[S] + refs(typeKey[S]).map(_.asTyped[JsonValueCodec[S]]).getOrElse(makeExpr[S](baseConfig(config))) + } + + private def eitherUnit[A: Type](left: ??, right: ??, config: PicklerConfiguration): CodecVal = { + import left.Underlying as L + import right.Underlying as R + implicit val CodecL: Type[JsonValueCodec[L]] = CTypes.CodecOf[L] + implicit val CodecR: Type[JsonValueCodec[R]] = CTypes.CodecOf[R] + // `A` *is* `Either[L, R]`; the cast only tells the quote so. + implicit val EitherLR: Type[Either[L, R]] = Type[A].asInstanceOf[Type[Either[L, R]]] + implicit val CodecEither: Type[JsonValueCodec[Either[L, R]]] = CTypes.CodecOf[Either[L, R]] + CodecVal( + Type[A].as_??, + refs => + Expr + .quote(CodecCombinators.either[L, R](Expr.splice(refOrMake[L](refs, config)), Expr.splice(refOrMake[R](refs, config)))) + .asUntyped + ) + } + + /** `A = Option[Option[X]]` flattened: `Some(Some(x))` is `x`, `Some(None)` and `None` are `null` (or omitted as a field), and `null` + * decodes as `None`. That is what the schema (`SOption(SOption(X))`, i.e. a nullable `X`) documents, and what the other tapir JSON + * modules do. + * + * The val is for the *inner* `Option[X]`: jsoniter's field writer unwraps the outer `Option` itself (that is how `transientNone` works) + * and only then looks for a codec, so a val for `A` would never be consulted from a field. At the root, `make[A]` is not an option + * either (it would look the inner type up and find our val, but the outer `Some(None)` would then become `null` while `None` became... + * also `null` -- fine -- yet the root type of the block must be `A`), so a second val wraps the inner one. + */ + private def nestedOptionUnits[A: Type](inner: ??, element: ??, config: PicklerConfiguration, isRoot: Boolean): List[CodecVal] = { + import inner.Underlying as I + import element.Underlying as X + implicit val CodecX: Type[JsonValueCodec[X]] = CTypes.CodecOf[X] + implicit val OptionX: Type[Option[X]] = Type[I].asInstanceOf[Type[Option[X]]] + implicit val CodecOptionX: Type[JsonValueCodec[Option[X]]] = CTypes.CodecOf[Option[X]] + val innerVal = + CodecVal(Type[I].as_??, refs => Expr.quote(CodecCombinators.option[X](Expr.splice(refOrMake[X](refs, config)))).asUntyped) + if (!isRoot) List(innerVal) + else { + implicit val OptionI: Type[Option[I]] = Type[A].asInstanceOf[Type[Option[I]]] + implicit val CodecI: Type[JsonValueCodec[I]] = CTypes.CodecOf[I] + implicit val CodecOptionI: Type[JsonValueCodec[Option[I]]] = CTypes.CodecOf[Option[I]] + val rootVal = + CodecVal(Type[A].as_??, refs => Expr.quote(CodecCombinators.option[I](Expr.splice(refOrMake[I](refs, config)))).asUntyped) + List(innerVal, rootVal) + } + } + + /** Visit `A` (nested somewhere under the root): recurse into its children, then emit its codec if it needs one. + * + * A user-supplied `Pickler[A]` short-circuits the visit: its `codec` becomes the implicit for `A`, and nothing beneath `A` is looked at + * -- whatever that pickler does for its own fields is its business. + */ + private def walk[A: Type](env: DerivationEnv, visited: Set[String], acc: Vector[CodecVal]): MIO[Walk] = { + val key = typeKey[A] + if (visited.contains(key)) MIO.pure((visited, acc)) + else + userPickler[A](env).flatMap { + case Some(pickler) => + implicit val CodecA: Type[JsonValueCodec[A]] = CTypes.CodecOf[A] + MIO.pure((visited + key, acc :+ CodecVal.const(Type[A].as_??, Expr.quote(Expr.splice(pickler).codec).asUntyped))) + case None => + val shape = classify[A] + rejectBareCodec[A](shape) >> walkChildren(childrenOf(shape), env, visited + key, acc).flatMap { case (v, a) => + codecValFor[A](shape, env, isRoot = false).map(units => (v, a ++ units)) + } + } + } + + /** A `given JsonValueCodec[A]` with no `given Pickler[A]`, for a structural `A`, is refused. + * + * It would not even be honoured: the `implicit lazy val codec$A` this derivation emits sits in a tighter scope than the user's given and + * wins the implicit search inside jsoniter, so the user's codec would be silently ignored (measured). Had it won instead, the JSON would + * follow the codec while the schema still documented the class. Either outcome is wrong; a `Pickler[A]` carries both halves and is + * honoured by both chains. Leaf types are exempt (their schema comes from an implicit `Schema` anyway, which the user controls the same + * way). + */ + private def rejectBareCodec[A: Type](shape: Shape[A]): MIO[Unit] = shape match { + case _: Shape.Product[?] | _: Shape.Enumeration[?] | _: Shape.Coproduct[?] => + implicit val CodecA: Type[JsonValueCodec[A]] = CTypes.CodecOf[A] + Expr.summonImplicit[JsonValueCodec[A]].toOption match { + case Some(codec) => fail(PicklerDerivationError.CodecWithoutPickler(Type[A].plainPrint, codec.plainPrint)) + case None => MIO.pure(()) + } + case _ => MIO.pure(()) + } + + private def walkChildren(children: List[??], env: DerivationEnv, visited: Set[String], acc: Vector[CodecVal]): MIO[Walk] = + children.foldLeft(MIO.pure((visited, acc))) { (walkSoFar, child) => + walkSoFar.flatMap { case (v, a) => + import child.Underlying as Child + walk[Child](env, v, a) + } + } + + // ----------------------------------------------------------------------------------------------------------------- + // Per-type configuration + // ----------------------------------------------------------------------------------------------------------------- + + /** The knobs every `make` gets. Non-structural roots (primitives, collections, `Option`, `Map`) need nothing more; jsoniter derives them + * directly. + */ + private def baseConfig(config: PicklerConfiguration): Expr[CodecMakerConfig] = { + implicit val ConfigT: Type[CodecMakerConfig] = CTypes.MakerConfig + implicit val BooleanT: Type[Boolean] = CTypes.BooleanT + val transientNone = Expr(config.transientNone) + Expr.quote { + CodecMakerConfig + .withTransientEmpty(false) + .withTransientDefault(false) + .withTransientNone(Expr.splice(transientNone)) + .withRequireDiscriminatorFirst(false) + .withAllowRecursiveTypes(true) + } + } + + private def productConfig[A: Type](params: List[(String, Parameter)], env: DerivationEnv): MIO[Expr[CodecMakerConfig]] = { + val config = env.config + implicit val ConfigT: Type[CodecMakerConfig] = CTypes.MakerConfig + implicit val StringT: Type[String] = CTypes.StringT + implicit val OptionStringT: Type[Option[String]] = CTypes.OptionStringT + implicit val StringPF: Type[PartialFunction[String, String]] = CTypes.StringPF + implicit val StringFn: Type[String => String] = CTypes.StringFn + + val renames: Either[PicklerDerivationError, List[(String, String)]] = + params.foldRight[Either[PicklerDerivationError, List[(String, String)]]](Right(Nil)) { case ((name, param), acc) => + for { + tail <- acc + encoded <- encodedFieldName[A](param, name, config) + } yield if (encoded == name) tail else (name -> encoded) :: tail + } + + (for { pairs <- renames; ownTag <- discriminatorValue[A](env) } yield (pairs, ownTag)) match { + case Left(error) => fail(error) + case Right((pairs, ownTag)) => + // `alwaysEmitDiscriminator` needs a discriminator field name; jsoniter only acts on it when `A` has a sealed + // parent, so setting both unconditionally is safe. The leaf mapper covers `A` itself, which is all a + // stand-alone leaf codec can ever be asked about. + val discriminator = Expr(Option(config.discriminator)) + val leafMapper = stringFunction(List(jsoniterLeafName[A] -> ownTag)) + val withoutFields = Expr.quote { + Expr + .splice(baseConfig(config)) + .withDiscriminatorFieldName(Expr.splice(discriminator)) + .withAdtLeafClassNameMapper(Expr.splice(leafMapper)) + .withAlwaysEmitDiscriminator(true) + } + val result = + if (pairs.isEmpty) withoutFields + else { + val fieldMapper = stringPartialFunction(pairs) + Expr.quote(Expr.splice(withoutFields).withFieldNameMapper(Expr.splice(fieldMapper))) + } + Log.info(s"${Type[A].prettyPrint}: field renames ${pairs.mkString("{", ", ", "}")}") >> MIO.pure(result) + } + } + + /** The configuration for a sealed hierarchy: bare strings for an enumeration (`Shape.Enumeration`), discriminated objects otherwise + * (`Shape.Coproduct`). + */ + private def hierarchyConfig[A: Type]( + leaves: List[(String, ??<:[A])], + asEnumeration: Boolean, + env: DerivationEnv + ): MIO[Expr[CodecMakerConfig]] = { + val config = env.config + implicit val ConfigT: Type[CodecMakerConfig] = CTypes.MakerConfig + implicit val StringT: Type[String] = CTypes.StringT // needed by `Expr(_: Option[String])` + implicit val OptionStringT: Type[Option[String]] = CTypes.OptionStringT + implicit val StringFn: Type[String => String] = CTypes.StringFn + + // Enumeration values are the cases' simple names, not discriminator values (`NameSupport.enumerationValue`); + // `SchemaDerivation.deriveStringEnumSchema` documents the same literals. + val values: Either[PicklerDerivationError, List[String]] = + if (asEnumeration) Right(leaves.map { case (_, leaf) => import leaf.Underlying as Leaf; enumerationValue[Leaf] }) + else discriminatorValues(leaves, env) + val jsoniterNames = leaves.map { case (_, leaf) => import leaf.Underlying as Leaf; jsoniterLeafName[Leaf] } + + if (leaves.isEmpty) fail(PicklerDerivationError.NoChildrenInSealedTrait(Type[A].plainPrint)) + else + values match { + case Left(error) => fail(error) + case Right(values) => + val mapping = jsoniterNames.zip(values) + val discriminator = Expr(if (asEnumeration) Option.empty[String] else Some(config.discriminator)) + val leafMapper = stringFunction(mapping) + val result = Expr.quote { + Expr + .splice(baseConfig(config)) + .withDiscriminatorFieldName(Expr.splice(discriminator)) + .withAdtLeafClassNameMapper(Expr.splice(leafMapper)) + } + Log.info( + s"${Type[A].prettyPrint}: ${if (asEnumeration) "string enum" else s"discriminated by '${config.discriminator}'"}, " + + s"leaves ${mapping.mkString("{", ", ", "}")}" + ) >> MIO.pure(result) + } + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/DerivationTimeout.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/DerivationTimeout.scala new file mode 100644 index 0000000000..e9ae17db45 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/DerivationTimeout.scala @@ -0,0 +1,63 @@ +package sttp.tapir.json.pickler.internal.compiletime + +import scala.concurrent.duration.FiniteDuration +import java.util.concurrent.TimeUnit + +/** Reads the derivation timeout from `-Xmacro-settings:.timeout=...`. + * + * Hearth's default is deliberately short (5s) so that a runaway derivation fails fast rather than hanging the compiler. A combined Schema + * + codec derivation over a large ADT graph can legitimately exceed it, hence the override. + * + * Accepted formats: `30` (seconds), `30s`, `5000ms`, `1m`. + */ +trait DerivationTimeout { this: hearth.MacroCommons => + + protected def derivationSettingsNamespace: String + + protected lazy val derivationTimeout: FiniteDuration = + (for { + data <- Environment.typedSettings.toOption + moduleSettings <- data.get(derivationSettingsNamespace) + timeoutData <- moduleSettings.get("timeout") + duration <- timeoutData.asInt + .filter(_ > 0) + .map(n => FiniteDuration(n.toLong, TimeUnit.SECONDS)) + .orElse(timeoutData.asLong.filter(_ > 0).map(n => FiniteDuration(n, TimeUnit.SECONDS))) + .orElse(timeoutData.asString.flatMap(parseDurationString)) + } yield duration).getOrElse(DerivationTimeout.Default) + + private def parseDurationString(str: String): Option[FiniteDuration] = + str.trim match { + case DerivationTimeout.DurationPattern(num, unit) => + val n = num.toLong + if (n > 0) { + val tu = unit match { + case "ms" | "millis" | "milliseconds" => TimeUnit.MILLISECONDS + case "s" | "second" | "seconds" => TimeUnit.SECONDS + case "m" | "minute" | "minutes" => TimeUnit.MINUTES + } + Some(FiniteDuration(n, tu)) + } else { + Environment.reportWarn( + s"$derivationSettingsNamespace.timeout: value must be positive, got '$str'. " + + s"Using default of ${DerivationTimeout.Default.toSeconds}s." + ) + None + } + case _ => + Environment.reportWarn( + s"$derivationSettingsNamespace.timeout: unrecognized format '$str'. " + + s"Expected formats: 30, 30s, 5000ms, 1m. " + + s"Using default of ${DerivationTimeout.Default.toSeconds}s." + ) + None + } +} + +object DerivationTimeout { + + val Default: FiniteDuration = FiniteDuration(5, TimeUnit.SECONDS) + + private[compiletime] val DurationPattern = + """^\s*(\d+)\s*(ms|millis|milliseconds|s|seconds?|m|minutes?)\s*$""".r +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/ImplicitPicklerSupport.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/ImplicitPicklerSupport.scala new file mode 100644 index 0000000000..c28272dc0a --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/ImplicitPicklerSupport.scala @@ -0,0 +1,46 @@ +package sttp.tapir.json.pickler.internal.compiletime + +import hearth.MacroCommons +import hearth.fp.effect.* +import sttp.tapir.json.pickler.Pickler + +import scala.collection.mutable + +/** Looking up a user-supplied `Pickler[X]` for a type nested in the one being derived. + * + * ==Why this is not a plain `Expr.summonImplicit`== + * With `generic.auto.*` in scope, `Pickler[X]` always has a candidate: `auto.picklerForType[X]`, which expands to `Pickler.derived[X]` — + * our own macro. Summoning from inside a derivation would therefore start a *nested* derivation for every field type, recursing forever on + * cyclic types and exponentially on deep ones. `Implicits.searchIgnoring`, the direct fix, needs Scala 3.7. + * + * The guard is instead in `PicklerMacros.derivePicklerImpl`: it aborts immediately when invoked while another derivation is on the stack. + * The compiler treats an aborted candidate as a failed one, so the search comes back empty and we derive structurally; a user's `given` is + * an already-typed value, not a pending macro call, and is found normally. + * + * ==Exclusions== + * `DerivationEnv.implicitLookupExclusions` lists the types never looked up: the root (a `given p: Pickler[A] = Pickler.derived[A]` would + * otherwise find itself) and, for `oneOfUsingField`, the mapped leaves. + */ +trait ImplicitPicklerSupport { this: MacroCommons & PlatformSupport => + + private val memo = mutable.Map.empty[String, Option[Expr_??]] + + private def PicklerOf[A: Type]: Type[Pickler[A]] = Type.of[Pickler[A]] + + protected def userPickler[A: Type](env: DerivationEnv): MIO[Option[Expr[Pickler[A]]]] = { + val key = typeKey[A] + if (env.implicitLookupExclusions.contains(key)) MIO.pure(None) + else + memo.get(key) match { + case Some(cached) => MIO.pure(cached.map(_.value.asInstanceOf[Expr[Pickler[A]]])) + case None => + implicit val PicklerA: Type[Pickler[A]] = PicklerOf[A] + val found = Expr.summonImplicit[Pickler[A]].toOption + memo.update(key, found.map(_.as_??)) + found match { + case Some(expr) => Log.info(s"Using user-supplied Pickler for ${Type[A].prettyPrint}: ${expr.prettyPrint}").as(found) + case None => MIO.pure(None) + } + } + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/LoadStandardExtensionsOnce.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/LoadStandardExtensionsOnce.scala new file mode 100644 index 0000000000..3e59ae3269 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/LoadStandardExtensionsOnce.scala @@ -0,0 +1,30 @@ +package sttp.tapir.json.pickler.internal.compiletime + +import hearth.MacroCommons +import hearth.fp.effect.MIO +import hearth.std.StdExtensions + +/** Guarantees that Hearth's standard extensions are loaded exactly once per macro expansion. + * + * The standard extensions register the providers behind the `IsCollection`, `IsMap`, `IsValueType` and `IsOption` extractors. Without them + * those extractor patterns silently never match, and collection/option fields fall through to the case-class rule or fail outright. + * + * Because a single `Pickler` derivation runs two sub-derivations (schema and codec), each of which would naturally want to load them, the + * `var` below is what keeps the ServiceLoader scan from running twice. It lives on a trait that is mixed into the bundle class exactly + * once, which is what makes it per-expansion state. + * + * Rules: never call `Environment.loadStandardExtensions()` directly, and never call this from inside an `Expr.quote` or a builder callback + * — load once, before any quotes are constructed. + */ +trait LoadStandardExtensionsOnce { this: MacroCommons & StdExtensions => + + private var standardExtensionsLoaded: Boolean = false + + protected def ensureStandardExtensionsLoaded(): MIO[Unit] = + if (standardExtensionsLoaded) MIO.pure(()) + else + Environment.loadStandardExtensions().toMIO(allowFailures = false).map { _ => + standardExtensionsLoaded = true + () + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/NameSupport.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/NameSupport.scala new file mode 100644 index 0000000000..cb7684d925 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/NameSupport.scala @@ -0,0 +1,85 @@ +package sttp.tapir.json.pickler.internal.compiletime + +import hearth.MacroCommons +import sttp.tapir.Schema.SName +import sttp.tapir.json.pickler.PicklerConfiguration + +/** Everything one derivation needs to know beyond the type it derives for, passed explicitly through both halves. + * + * @param config + * the folded `PicklerConfiguration` + * @param leafNameOverrides + * discriminator values that replace the configuration-derived ones for specific leaves (by `plainPrint`) — set by `oneOfUsingField` + * @param implicitLookupExclusions + * types (by `plainPrint`) for which no user `Pickler` is looked up: the root (a `given p = Pickler.derived[A]` would find itself) and, + * for `oneOfUsingField`, the mapped leaves (whose codecs must be derived here, with the overridden tags) + */ +final case class DerivationEnv( + config: PicklerConfiguration, + leafNameOverrides: Map[String, String] = Map.empty, + implicitLookupExclusions: Set[String] = Set.empty +) + +/** Every name that ends up in the JSON or in the schema — field names, type names, discriminator values, enumeration values — computed + * **once**, at expansion time, from the folded [[PicklerConfiguration]]. + * + * Both halves splice the resulting `String`s as literals: the codec half into `JsonCodecMaker`'s configuration (which can only take + * literals anyway), the schema half into the `Schema` constructors. There is therefore no second evaluation of the user's name + * transformations at runtime, and no way for the two halves to disagree about a name. + */ +trait NameSupport { this: MacroCommons & AnnotationSupport & PlatformSupport => + + /** The JSON name of a field: its `@encodedName` if present, otherwise `config.toEncodedName(scalaName)`. */ + protected def encodedFieldName[A: Type]( + param: Parameter, + scalaName: String, + config: PicklerConfiguration + ): Either[PicklerDerivationError, String] = + literalEncodedFieldName[A](param, scalaName) match { + case Right(Some(explicit)) => Right(explicit) + case Right(None) => evaluating("toEncodedName", scalaName)(config.toEncodedName(scalaName)) + case Left(detail) => Left(PicklerDerivationError.InvalidAnnotation(detail)) + } + + /** The configuration's functions are the user's code, evaluated by Hearth's interpreter; a body it cannot handle surfaces as an exception + * here, which is turned into an actionable error rather than an `UndeclaredThrowableException`. + */ + private def evaluating(function: String, input: String)(compute: => String): Either[PicklerDerivationError, String] = + try Right(compute) + catch { case scala.util.control.NonFatal(e) => Left(PicklerDerivationError.ConfigurationFunctionFailed(function, input, e)) } + + /** The `SName` of `A`: its type-level `@encodedName`, which replaces the name wholesale (type arguments included), or core's fully + * qualified name plus the flattened, fully qualified type arguments. + * + * Only the type's *own* `@encodedName` is consulted: a parent's renaming is deliberately not propagated to its subtypes. + */ + protected def sNameOf[A: Type]: SName = + typeEncodedName[A] match { + case Some(encoded) => SName(encoded, Nil) + // The base name comes from core's own `SNameMacros`, which is the only way to get a properly qualified name for + // a class nested in another class; the type arguments are flattened the way core's `Schema.renameWithTypeParameter` + // does (`SName.typeParameterShortNames` is a misnomer: the entries are fully qualified). + case None => SName(tapirFullName[A], flattenedTypeArguments[A]) + } + + /** The discriminator value written for, and documented on, leaf `A`: an `oneOfUsingField` override if there is one, otherwise + * `config.toDiscriminatorValue(sNameOf[A])`. + */ + protected def discriminatorValue[A: Type](env: DerivationEnv): Either[PicklerDerivationError, String] = + env.leafNameOverrides.get(typeKey[A]) match { + case Some(overridden) => Right(overridden) + case None => + val name = sNameOf[A] + evaluating("toDiscriminatorValue", name.fullName)(env.config.toDiscriminatorValue(name)) + } + + /** `discriminatorValue` for every leaf, or the first failure. */ + protected def discriminatorValues[A](leaves: List[(String, ??<:[A])], env: DerivationEnv): Either[PicklerDerivationError, List[String]] = + leaves.foldRight[Either[PicklerDerivationError, List[String]]](Right(Nil)) { case ((_, leaf), acc) => + import leaf.Underlying as Leaf + for { tail <- acc; value <- discriminatorValue[Leaf](env) } yield value :: tail + } + + /** The bare string an enumeration case `A` is written as. */ + protected def enumerationValue[A: Type]: String = enumCaseName[A](typeEncodedName[A]) +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/PicklerDerivationError.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/PicklerDerivationError.scala new file mode 100644 index 0000000000..1c045c99fb --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/PicklerDerivationError.scala @@ -0,0 +1,139 @@ +package sttp.tapir.json.pickler.internal.compiletime + +import scala.util.control.NoStackTrace + +/** Errors raised during derivation. + * + * Modelling these as an ADT rather than passing strings around is what makes it possible to render one coherent, actionable message at the + * end of a failed derivation instead of a pile of unrelated compiler errors. + * + * Every error site follows the `Log.error(err.message) >> MIO.fail(err)` pattern, so that the failure shows up both in the derivation log + * and as the compile error. + */ +sealed trait PicklerDerivationError extends NoStackTrace with Product with Serializable { + def message: String + override def getMessage: String = message +} + +object PicklerDerivationError { + + /** No derivation rule was applicable. `reasons` carries one entry per rule that declined, so the user can see why each one bowed out. + */ + final case class UnsupportedType(typeName: String, reasons: List[String]) extends PicklerDerivationError { + def message: String = { + val summary = + s"Cannot derive Pickler for $typeName: no implicit Schema was found and the type is not an Option, collection, Map, " + + "singleton, case class or sealed hierarchy." + if (reasons.isEmpty) summary else s"$summary\n${reasons.mkString("\n")}" + } + } + + final case class NoChildrenInSealedTrait(typeName: String) extends PicklerDerivationError { + def message: String = + s"Cannot derive Pickler for $typeName: it is a sealed hierarchy with no children" + } + + /** A `Map` is written as a JSON object, whose keys are strings; any other key type needs a user-supplied conversion. */ + final case class NonStringMapKey(keyTypeName: String) extends PicklerDerivationError { + def message: String = + s"Cannot derive Pickler for a Map with non-String keys ($keyTypeName); use Pickler.picklerForMap with an explicit key encoder." + } + + /** The `PicklerConfiguration` could not be evaluated during expansion. The codec half needs the configuration as a *value* (its name + * transformations are invoked at compile time and the results handed to `JsonCodecMaker` as literals), so a configuration that is only + * known at runtime cannot be supported. + */ + final case class ConfigurationNotStatic(configExpr: String, reason: String) extends PicklerDerivationError { + def message: String = + s"""The PicklerConfiguration must be known at compile time, but `$configExpr` could not be evaluated: $reason + |Define it as a `given`/`implicit val` with a right-hand side built from `PicklerConfiguration.default` and its + |`with*` methods (or an `inline given`), either in the same compilation unit as the derivation or in an already + |compiled module. Functions passed to `withToEncodedName` must themselves be evaluable, e.g. `_.toUpperCase`.""".stripMargin + } + + /** A function from the `PicklerConfiguration` (`toEncodedName`, `toDiscriminatorValue`) was evaluated at compile time but threw. The + * usual cause is a body Hearth's evaluator cannot interpret, e.g. one going through an implicit conversion such as `StringOps`. + */ + final case class ConfigurationFunctionFailed(function: String, input: String, cause: Throwable) extends PicklerDerivationError { + def message: String = { + val root = Iterator.iterate(cause)(_.getCause).takeWhile(_ != null).toList.last + // Hearth's evaluator reports its reasons through a private `ControlThrowable` case class with no message; being a + // case class, it is still a `Product`, which is how the reasons are recovered. + val detail = Option(root.getMessage) + .orElse(root match { + case p: Product => Some(p.productIterator.mkString("; ")) + case _ => None + }) + .getOrElse(root.getClass.getSimpleName) + s"""The PicklerConfiguration's `$function` could not be evaluated at compile time for `$input`: $detail + |Names are computed during derivation, so the function has to be evaluable there: keep it to `java.lang.String` method calls + |(e.g. `_.toUpperCase`, `n => n.toLowerCase.concat("_")`); string `+` and `StringOps` extensions (`.reverse`, `.capitalize`) + |are not supported.""".stripMargin + } + } + + final case class NotASealedHierarchy(typeName: String, macroName: String) extends PicklerDerivationError { + def message: String = s"$macroName can only be used with a sealed hierarchy or enum; $typeName is not one" + } + + /** `derivedEnumeration` needs every case to be a singleton, because that is what a bare-string encoding can name. */ + final case class NotAnEnumeration(typeName: String, nonSingletons: List[String]) extends PicklerDerivationError { + def message: String = + s"""Pickler.derivedEnumeration can only be used with a sealed hierarchy or enum whose cases are all objects (or + |parameterless enum cases); $typeName has cases with fields: ${nonSingletons.mkString(", ")}. + |Use Pickler.derived[$typeName] or Pickler.oneOfUsingField instead.""".stripMargin + } + + /** An all-singleton hierarchy is encoded as a bare string, which has no field to carry a discriminator. */ + final case class EnumerationInOneOfUsingField(typeName: String) extends PicklerDerivationError { + def message: String = + s"""Pickler.oneOfUsingField cannot be used with $typeName: all its cases are objects, so it is encoded as a bare + |string with no field to hold the discriminator. Use Pickler.derivedEnumeration[$typeName].customStringBased(...) + |to choose how each case is rendered.""".stripMargin + } + + /** `oneOfUsingField` turns every mapping key into a jsoniter discriminator literal at compile time, so both the keys and `asString` have + * to be evaluable during expansion. + */ + final case class OneOfMappingNotStatic(detail: String) extends PicklerDerivationError { + def message: String = + s"""Pickler.oneOfUsingField needs its mapping keys and `asString` function to be known at compile time: $detail + |Use literal keys (e.g. `200 -> picklerOk`) and a lambda over them (e.g. `code => s"code-$$code"`).""".stripMargin + } + + /** Every leaf of the hierarchy has to be given a discriminator value; a leaf that is not would otherwise get a configuration-derived one + * in the codec and none in the schema. + */ + final case class IncompleteOneOfMapping(typeName: String, unmapped: List[String]) extends PicklerDerivationError { + def message: String = + s"""Pickler.oneOfUsingField for $typeName does not map every case of the hierarchy; missing: ${unmapped.mkString(", ")}. + |Add a `value -> Pickler.derived[Case]` entry for each of them.""".stripMargin + } + + final case class AmbiguousOneOfMapping(typeName: String, detail: String) extends PicklerDerivationError { + def message: String = s"Pickler.oneOfUsingField for $typeName is ambiguous: $detail" + } + + /** A `JsonValueCodec[X]` alone cannot be honoured for a structural `X`: the schema would still be derived from the class, documenting a + * shape the codec no longer writes. + */ + final case class CodecWithoutPickler(typeName: String, codecExpr: String) extends PicklerDerivationError { + def message: String = + s"""Found a JsonValueCodec[$typeName] in scope ($codecExpr), but no Pickler[$typeName]. + |A codec on its own would be used for the JSON while the Schema is still derived from the class, so the two + |could disagree. Provide a `given Pickler[$typeName]` instead (it carries both), or remove the codec.""".stripMargin + } + + /** jsoniter writes a tuple as a JSON array while tapir has no array-of-heterogeneous-elements schema (a case-class reading would document + * an object with `_1`, `_2`, ... fields), so no agreeing pair exists. + */ + final case class TupleNotSupported(typeName: String) extends PicklerDerivationError { + def message: String = + s"""Cannot derive Pickler for $typeName: tuples have no JSON schema in tapir (the codec would write an array, + |the schema would document an object). Use a case class instead.""".stripMargin + } + + final case class InvalidAnnotation(detail: String) extends PicklerDerivationError { + def message: String = s"Cannot derive Pickler: $detail" + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/PicklerMacros.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/PicklerMacros.scala new file mode 100644 index 0000000000..f151974e88 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/PicklerMacros.scala @@ -0,0 +1,193 @@ +package sttp.tapir.json.pickler.internal.compiletime + +import hearth.MacroCommonsScala3 +import sttp.tapir.Schema +import sttp.tapir.internal.SNameMacros +import sttp.tapir.json.pickler.{CreateDerivedEnumerationPickler, Pickler, PicklerConfiguration} + +import scala.quoted.* + +/** Scala 3 macro bundle: the only file that knows about `Quotes`. + * + * `MacroCommonsScala3` supplies Hearth's cake (and already mixes in `StdExtensions`); all the actual derivation logic comes from + * `PicklerMacrosImpl`. Adding Scala 2.13 support later means adding a sibling bundle extending `MacroCommonsScala2` with the same + * `PicklerMacrosImpl` and its own [[PlatformSupport]] — nothing else changes. + */ +final private[pickler] class PicklerMacros(q: Quotes) + extends MacroCommonsScala3(using q), + LoadStandardExtensionsOnce, + PicklerMacrosImpl, + PlatformSupportScala3 + +private[pickler] object PicklerMacros { + + /** Number of pickler derivations currently on the stack of this compiler thread. + * + * A derivation summons `Pickler[X]` for every nested `X` (see [[ImplicitPicklerSupport]]). If `generic.auto` is in scope, one candidate + * is `Pickler.derived[X]` itself, i.e. this macro, expanded *while* the outer one is running. `derivePicklerImpl` refuses to run in that + * situation, which turns the candidate into a failed one and lets the search fall through to "no user-supplied pickler". A `ThreadLocal` + * rather than a plain `var` only because nothing guarantees the compiler will never expand macros on several threads. + */ + private val depth: ThreadLocal[Int] = ThreadLocal.withInitial(() => 0) + + private def nested[R](body: => R): R = { + depth.set(depth.get + 1) + try body + finally depth.set(depth.get - 1) + } + + def derivePicklerImpl[A: Type](config: Expr[PicklerConfiguration])(using q: Quotes): Expr[Pickler[A]] = + if (depth.get > 0) + // Reached only as an implicit candidate during another derivation's `summon[Pickler[X]]`. Aborting here is + // what makes that summon fail cleanly instead of deriving `X` a second time (or forever, for cyclic types). + q.reflect.report.errorAndAbort( + s"Pickler.derived[${q.reflect.TypeRepr.of[A].show}] invoked from within another Pickler derivation; " + + "nested types are derived structurally unless a user-defined Pickler is in scope." + ) + else nested(new PicklerMacros(q).derivePickler[A](config)) + + def deriveSchemaOnlyImpl[A: Type](config: Expr[PicklerConfiguration])(using q: Quotes): Expr[Schema[A]] = + nested(new PicklerMacros(q).deriveSchemaOnly[A](config)) + + def derivedEnumerationImpl[A: Type](config: Expr[PicklerConfiguration])(using q: Quotes): Expr[CreateDerivedEnumerationPickler[A]] = + nested(new PicklerMacros(q).deriveEnumerationBuilder[A](config)) + + def oneOfUsingFieldImpl[A: Type, V: Type]( + extractor: Expr[A => V], + asString: Expr[V => String], + mapping: Expr[Seq[(V, Pickler[? <: A])]], + config: Expr[PicklerConfiguration] + )(using q: Quotes): Expr[Pickler[A]] = + nested(new PicklerMacros(q).deriveOneOfUsingField[A, V](extractor, asString, mapping, config)) +} + +/** [[PlatformSupport]] for Scala 3. + * + * The two mapper shapes are pinned by jsoniter's `CompileTimeEval` (`NameMapper.scala` in `jsoniter-scala-macros`): + * - `evalApplyStringTerm` destructures the argument with the `Lambda(params, body)` extractor and evaluates the body as a `Match` with a + * `null` default, so a `Closure` over a `DefDef` whose body is a bare `Match` on `Literal` patterns is the shape to produce. This is + * exactly what the typer produces for `{ case "a" => "b" }` before `ExpandSAMs`, which is why hand-written jsoniter configurations + * work. + * - A `Map(...)` literal is matched by a quote pattern that does **not** see through the `Inlined` nodes splicing produces, so it is + * unusable from a macro. + */ +private[compiletime] trait PlatformSupportScala3 extends PlatformSupport { this: MacroCommonsScala3 => + import quotes.reflect.* + + private def stringMatch(pairs: List[(String, String)], scrutinee: Term, fallback: Option[Term]): Match = + Match( + scrutinee, + pairs.map { case (k, v) => CaseDef(Literal(StringConstant(k)), None, Literal(StringConstant(v))) } ++ + fallback.map(f => CaseDef(Wildcard(), None, f)).toList + ) + + private val stringToString: MethodType = + MethodType(List("x"))(_ => List(TypeRepr.of[String]), _ => TypeRepr.of[String]) + + protected def stringPartialFunction(pairs: List[(String, String)]): Expr[PartialFunction[String, String]] = { + val sym = Symbol.newMethod(Symbol.spliceOwner, "picklerFieldNameMapper", stringToString) + val defDef = DefDef( + sym, + { + case List(List(x: Term)) => Some(stringMatch(pairs, x, fallback = None)) + case other => throw new IllegalStateException(s"unexpected lambda parameters: $other") + } + ) + Block(List(defDef), Closure(Ref(sym), Some(TypeRepr.of[PartialFunction[String, String]]))) + .asExprOf[PartialFunction[String, String]] + } + + // No wildcard case on purpose. jsoniter's `evalApplyStringTerm` evaluates a lambda body with a `null` default, and a `None` from + // the leaf mapper makes `JsonCodecMaker` fail with "Discriminator is not defined for ...". Every leaf we know of is in `pairs`, so + // reaching the default means `jsoniterLeafName` no longer agrees with jsoniter's own naming -- which must be a compile error, not a + // silently different discriminator value from the one the schema documents. + protected def stringFunction(pairs: List[(String, String)]): Expr[String => String] = + Lambda( + Symbol.spliceOwner, + stringToString, + { + case (_, List(x: Term)) => stringMatch(pairs, x, fallback = None) + case (_, other) => throw new IllegalStateException(s"unexpected lambda parameters: $other") + } + ).asExprOf[String => String] + + /** Mirrors `JsonCodecMaker.discriminatorValue` in `jsoniter-scala-macros`: enum values are named by their term symbol, everything else by + * its type symbol, and a module's trailing `$` is dropped. Coupled to `Versions.jsoniter`: if jsoniter changes its naming, the leaf + * mapper (`stringFunction`) stops matching and `JsonCodecMaker` fails compilation with "Discriminator is not defined". + */ + protected def jsoniterLeafName[A: Type]: String = { + val tpe = TypeRepr.of[A] + val symbol = if (tpe.termSymbol.flags.is(Flags.Enum)) tpe.termSymbol else tpe.typeSymbol + val name = symbol.fullName + if (symbol.flags.is(Flags.Module)) name.substring(0, name.length - 1) else name + } + + protected def tapirFullName[A: Type]: String = SNameMacros.typeFullNameFromTpe(TypeRepr.of[A]) + + protected def flattenedTypeArguments[A: Type]: List[String] = SNameMacros.extractTypeArguments(TypeRepr.of[A].dealias) + + protected def typeKey[A: Type]: String = { + def deepDealias(tpe: TypeRepr): TypeRepr = tpe.dealias match { + case AppliedType(tycon, args) => AppliedType(tycon, args.map(deepDealias)) + case other => other + } + deepDealias(TypeRepr.of[A]).show + } + + protected def implicitLazyVals[Out: Type](vals: List[(String, UntypedType, List[UntypedExpr] => UntypedExpr)])( + body: List[UntypedExpr] => Expr[Out] + ): Expr[Out] = { + val syms = vals.map { case (name, tpe, _) => + Symbol.newVal(Symbol.spliceOwner, name, tpe, Flags.Implicit | Flags.Lazy, Symbol.noSymbol) + } + val refs: List[UntypedExpr] = syms.map(Ref(_)) + val defs = vals.zip(syms).map { case ((_, _, rhs), sym) => ValDef(sym, Some(rhs(refs).changeOwner(sym))) } + Block(defs, body(refs).asTerm).asExprOf[Out] + } + + protected def betaReduce[A: Type, B: Type](f: Expr[A => B], a: Expr[A]): Expr[B] = { + val application = '{ $f($a) } + Term.betaReduce(application.asTerm).fold(application)(_.asExprOf[B]) + } + + protected def constantInterpolation(expr: Expr[String]): Option[String] = { + def constant(term: Term): Option[Any] = term match { + case Inlined(_, Nil, inner) => constant(inner) + case Typed(inner, _) => constant(inner) + case Literal(c) => Some(c.value) + case _ => None + } + expr match { + case '{ StringContext(${ Varargs(parts) }*).s(${ Varargs(args) }*) } => + for { + ps <- parts.foldRight(Option(List.empty[String]))((p, acc) => acc.flatMap(tail => p.value.map(_ :: tail))) + as <- args.foldRight(Option(List.empty[Any]))((a, acc) => acc.flatMap(tail => constant(a.asTerm).map(_ :: tail))) + } yield StringContext(ps*).s(as*) + case _ => None + } + } + + protected def dropNamedArgs[A: Type](expr: Expr[A]): Expr[A] = { + val transform = new TreeMap { + override def transformTerm(tree: Term)(owner: Symbol): Term = tree match { + case NamedArg(_, arg) => transformTerm(arg)(owner) + case other => super.transformTerm(other)(owner) + } + } + transform.transformTerm(expr.asTerm)(Symbol.spliceOwner).asExprOf[A] + } + + protected def dereferenceStable[A: Type](expr: Expr[A]): Option[Expr[A]] = { + def loop(term: Term): Option[Term] = term match { + case Inlined(_, Nil, inner) => loop(inner) + case Typed(inner, _) => loop(inner) + case ref: Ref if ref.symbol.isValDef => + ref.symbol.tree match { + case ValDef(_, _, Some(rhs)) => Some(rhs) + case _ => None + } + case _ => None + } + loop(expr.asTerm).map(_.asExprOf[A]) + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/PicklerMacrosImpl.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/PicklerMacrosImpl.scala new file mode 100644 index 0000000000..60f6f4df79 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/PicklerMacrosImpl.scala @@ -0,0 +1,386 @@ +package sttp.tapir.json.pickler.internal.compiletime + +import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec +import hearth.MacroCommons +import hearth.fp.DirectStyle.RunSafe +import hearth.fp.data.NonEmptyVector +import hearth.fp.effect.* +import hearth.std.* +import sttp.tapir.Schema +import sttp.tapir.json.pickler.{CreateDerivedEnumerationPickler, Pickler, PicklerConfiguration} +import sttp.tapir.json.pickler.internal.runtime.PicklerFactories + +/** Core, platform-independent derivation logic for [[Pickler]]. + * + * This trait holds no `Quotes`/`Context` of its own — it is mixed into the platform bundle (`PicklerMacros`), which supplies Hearth's cake + * and the [[PlatformSupport]] implementation. That separation is what allows the same logic to be reused from a Scala 2 macro bundle later + * without touching this file. + * + * ==Structure== + * The schema half ([[SchemaDerivation]]) walks the type graph and hoists one `lazy val` per schema into a shared `ValDefsCache`. The codec + * half ([[CodecDerivation]]) walks the same graph and emits one `JsonCodecMaker.make` per case class / sealed hierarchy, configured from + * the *same* [[DerivationEnv]]. A single `toValDefs.use` wraps the whole instance expression, so every hoisted definition sits at instance + * scope and is built once. All four entry points go through [[runDerivation]], which owns that plumbing. + */ +trait PicklerMacrosImpl + extends DerivationTimeout + with TypeShape + with AnnotationSupport + with NameSupport + with ImplicitPicklerSupport + with SchemaDerivation + with CodecDerivation { + this: MacroCommons & StdExtensions & LoadStandardExtensionsOnce & PlatformSupport => + + override protected def derivationSettingsNamespace: String = "tapirPickler" + + /** Centralised `Type.of[...]` instances. + * + * These must not be written as `implicit val`s in the scope where they are also the implicit being summoned: Hearth resolves `Type[A]` + * implicits lazily via cross-quotes, so a self-referential definition causes a stack overflow at macro-expansion time with no usable + * stack trace. Keeping every `Type.of` behind a method/lazy val on this object, and assigning it to a local `implicit val` at each use + * site, avoids that. + */ + private[compiletime] object PTypes { + def SchemaOf[A: Type]: Type[Schema[A]] = Type.of[Schema[A]] + def PicklerOf[A: Type]: Type[Pickler[A]] = Type.of[Pickler[A]] + def CodecOf[A: Type]: Type[JsonValueCodec[A]] = Type.of[JsonValueCodec[A]] + def EnumBuilderOf[A: Type]: Type[CreateDerivedEnumerationPickler[A]] = Type.of[CreateDerivedEnumerationPickler[A]] + def ListOf[A: Type]: Type[List[A]] = Type.of[List[A]] + lazy val Config: Type[PicklerConfiguration] = Type.of[PicklerConfiguration] + lazy val StringT: Type[String] = Type.of[String] + lazy val LogDerivation: Type[Pickler.LogDerivation] = Type.of[Pickler.LogDerivation] + } + + // --------------------------------------------------------------------------------------------------------------- + // Entry points + // --------------------------------------------------------------------------------------------------------------- + + /** Full type class instance: `Schema` + `JsonValueCodec`, derived together. */ + def derivePickler[A: Type](configExpr: Expr[PicklerConfiguration]): Expr[Pickler[A]] = { + implicit val SchemaA: Type[Schema[A]] = PTypes.SchemaOf[A] + implicit val PicklerA: Type[Pickler[A]] = PTypes.PicklerOf[A] + implicit val CodecA: Type[JsonValueCodec[A]] = PTypes.CodecOf[A] + + runDerivation[A, Pickler[A]]("Pickler.derived", "Pickler") { (cache, runSafe) => + val (schemaExpr, codecExpr) = runSafe { + for { + env <- environment(configExpr, root = typeKey[A]) + schema <- deriveSchemaRecursively[A](cache, env) + codec <- deriveCodec[A](env) + } yield (schema, codec) + } + Expr.quote(PicklerFactories.instance[A](Expr.splice(schemaExpr), Expr.splice(codecExpr))) + } + } + + /** Schema-only entry point. Like the others it needs the configuration at compile time: the names it splices are computed from it. */ + def deriveSchemaOnly[A: Type](configExpr: Expr[PicklerConfiguration]): Expr[Schema[A]] = { + implicit val SchemaA: Type[Schema[A]] = PTypes.SchemaOf[A] + runDerivation[A, Schema[A]]("Pickler.schemaFor", "Schema") { (cache, runSafe) => + runSafe { + for { + env <- environment(configExpr, root = typeKey[A]) + schema <- deriveSchemaRecursively[A](cache, env) + } yield schema + } + } + } + + /** `Pickler.derivedEnumeration[A]`: the builder behind `defaultStringBased` / `customStringBased`. + * + * The default schema and codec are exactly what `derivePickler` produces for `A` (an all-singleton hierarchy is a string enumeration on + * both sides); the builder additionally receives the singleton values, from which `customStringBased` builds a runtime string codec and + * the matching enumeration validator. Nothing about the user's `encode` function is needed at compile time. + */ + def deriveEnumerationBuilder[A: Type](configExpr: Expr[PicklerConfiguration]): Expr[CreateDerivedEnumerationPickler[A]] = { + implicit val SchemaA: Type[Schema[A]] = PTypes.SchemaOf[A] + implicit val CodecA: Type[JsonValueCodec[A]] = PTypes.CodecOf[A] + implicit val BuilderA: Type[CreateDerivedEnumerationPickler[A]] = PTypes.EnumBuilderOf[A] + implicit val ListA: Type[List[A]] = PTypes.ListOf[A] + val macroName = "Pickler.derivedEnumeration" + + runDerivation[A, CreateDerivedEnumerationPickler[A]](macroName, "enumeration Pickler") { (cache, runSafe) => + val (valuesExpr, schemaExpr, codecExpr) = runSafe { + for { + children <- enumerationCases[A](macroName) + env <- environment(configExpr, root = typeKey[A]) + schema <- deriveSchemaRecursively[A](cache, env) + codec <- deriveCodec[A](env) + } yield (singletonValuesExpr[A](children), schema, codec) + } + Expr.quote(PicklerFactories.enumerationBuilder[A](Expr.splice(valuesExpr), Expr.splice(schemaExpr), Expr.splice(codecExpr))) + } + } + + /** The leaves of `A`, provided `A` is a sealed hierarchy whose leaves are all singletons. */ + private def enumerationCases[A: Type](macroName: String): MIO[List[(String, ??<:[A])]] = + classify[A] match { + case Shape.Enumeration(_, leaves) => MIO.pure(leaves) + case Shape.Coproduct(_, leaves) if leaves.isEmpty => fail(PicklerDerivationError.NoChildrenInSealedTrait(Type[A].plainPrint)) + case Shape.Coproduct(_, leaves) => + fail(PicklerDerivationError.NotAnEnumeration(Type[A].plainPrint, nonSingletonLeaves(leaves))) + case _ => fail(PicklerDerivationError.NotASealedHierarchy(Type[A].plainPrint, macroName)) + } + + /** `Pickler.oneOfUsingField[A, V](extractor, asString)(v1 -> pickler1, ...)`. + * + * The discriminator value of each leaf is `asString(v)`, decided by the user rather than by the configuration; the discriminator *field* + * is the configured one (`$type` by default). Not core's `Schema.oneOfUsingField`: that documents a discriminator field named after the + * extractor (`code` for `_.code`), which the JSON does not contain. + * + * Apart from those values, this *is* `Pickler.derived[A]`: both halves are derived structurally, with `DerivationEnv.leafNameOverrides` + * set to `asString(v)` per leaf. The child picklers in the mapping only tell us which leaf each value selects; neither their schemas nor + * their codecs are used. Taking them would let the two halves drift (a child pickler derived under another configuration would document + * field names the codec does not write), and a leaf codec derived on its own writes its configuration-derived tag, not the overridden + * one. jsoniter needs the values as literals, which is why the keys and `asString` are evaluated at expansion time. + * + * The mapping must cover every leaf exactly once. + * + * `extractor` is accepted for API compatibility with core's `Schema.oneOfUsingField`; nothing is derived from it, since the JSON does + * not carry that field. + */ + def deriveOneOfUsingField[A: Type, V: Type]( + extractor: Expr[A => V], + asString: Expr[V => String], + mapping: VarArgs[(V, Pickler[? <: A])], + configExpr: Expr[PicklerConfiguration] + ): Expr[Pickler[A]] = { + implicit val SchemaA: Type[Schema[A]] = PTypes.SchemaOf[A] + implicit val PicklerA: Type[Pickler[A]] = PTypes.PicklerOf[A] + implicit val CodecA: Type[JsonValueCodec[A]] = PTypes.CodecOf[A] + implicit val StringT: Type[String] = PTypes.StringT + val macroName = "Pickler.oneOfUsingField" + val _ = extractor + + runDerivation[A, Pickler[A]](macroName, "Pickler (oneOfUsingField)") { (cache, runSafe) => + val (schemaExpr, codecExpr) = runSafe { + for { + leaves <- classify[A] match { + case Shape.Coproduct(_, leaves) => MIO.pure(leaves) + // An all-singleton hierarchy is a bare string: there is no object to put a discriminator in. + case Shape.Enumeration(_, _) => fail(PicklerDerivationError.EnumerationInOneOfUsingField(Type[A].plainPrint)) + case _ => fail(PicklerDerivationError.NotASealedHierarchy(Type[A].plainPrint, macroName)) + } + entries <- parseOneOfMapping[A, V](mapping) + overrides <- entries.foldLeft(MIO.pure(Map.empty[String, String])) { case (acc, (key, child)) => + acc.flatMap { m => + // `asString(key)` is beta-reduced and the *body* evaluated, rather than evaluating the lambda and + // calling it: Hearth materialises an evaluated lambda as a reflective proxy that cannot be applied. + val applied = betaReduce[V, String](asString, key) + import child.Underlying as Child + val leaf = typeKey[Child] + applied.semiEval.left + .flatMap(reasons => constantInterpolation(applied).toRight(reasons)) + .fold( + reasons => + fail(PicklerDerivationError.OneOfMappingNotStatic(s"${applied.plainPrint}: ${reasons.toVector.mkString("; ")}")), + value => + if (m.contains(leaf)) + fail(PicklerDerivationError.AmbiguousOneOfMapping(Type[A].plainPrint, s"$leaf is mapped more than once")) + else if (m.values.exists(_ == value)) + fail(PicklerDerivationError.AmbiguousOneOfMapping(Type[A].plainPrint, s"several cases map to '$value'")) + else MIO.pure(m + (leaf -> value)) + ) + } + } + _ <- { + val unmapped = leaves.map { case (_, leaf) => import leaf.Underlying as Leaf; typeKey[Leaf] }.filterNot(overrides.contains) + if (unmapped.isEmpty) MIO.pure(()) else fail(PicklerDerivationError.IncompleteOneOfMapping(Type[A].plainPrint, unmapped)) + } + _ <- Log.info(s"Discriminator values from oneOfUsingField: ${overrides.mkString("{", ", ", "}")}") + // The leaves' schemas and codecs must be derived here, with the overridden tags; a `given Pickler[Leaf]` in + // scope would otherwise be picked up and write the configuration-derived tag instead. + env <- environment(configExpr, root = typeKey[A], leafNameOverrides = overrides, alsoExclude = overrides.keySet) + schema <- deriveSchemaRecursively[A](cache, env) + codec <- deriveCodec[A](env) + } yield (schema, codec) + } + Expr.quote(PicklerFactories.instance[A](Expr.splice(schemaExpr), Expr.splice(codecExpr))) + } + } + + private def fail[T](error: PicklerDerivationError): MIO[T] = Log.error(error.message) >> MIO.fail(error) + + /** `(key, leaf type)` for every `key -> pickler` / `(key, pickler)` element of the mapping. + * + * Done with Hearth's `DestructuredExpr` so that both tuple spellings, and both Scala versions, decompose the same way: the key is the + * single argument applied to the receiver (`ArrowAssoc(key)`) or the first of two applied to `Tuple2.apply`; the leaf type is the static + * type argument of the pickler expression. + */ + private def parseOneOfMapping[A: Type, V: Type](mapping: VarArgs[(V, Pickler[? <: A])]): MIO[List[(Expr[V], ??)]] = { + import DestructuredExpr.MethodCall.{AppliedInstance, AppliedValues} + val PicklerCtor = Type.Ctor1.of[Pickler] + + def argsOf(node: DestructuredExpr): List[DestructuredExpr] = node match { + case mc: DestructuredExpr.MethodCall => + mc.applied.flatMap { + case ai: AppliedInstance => ai.value match { case r: DestructuredExpr.MethodCall => argsOf(r); case _ => Nil } + case av: AppliedValues => av.args + case _ => Nil + } + case _ => Nil + } + + def entry(node: DestructuredExpr): Either[String, (Expr[V], ??)] = argsOf(node) match { + case List(key, pickler) => + import pickler.tpe.Underlying as P + PicklerCtor.unapply(Type[P]) match { + case Some(leaf) => Right((key.toUntypedExpr.asTyped[V], leaf.Underlying.as_??)) + case None => Left(s"expected a Pickler, got ${Type[P].plainPrint}") + } + case _ => Left(s"expected `key -> pickler`, got ${node.plainPrint}") + } + + // `Expr[Seq[X]]` *is* Hearth's `VarArgs[X]` on Scala 3 (and `Seq[Expr[X]]` on Scala 2), so this split is what + // makes the element-wise parse cross-platform. + val elements: List[Expr[(V, Pickler[? <: A])]] = mapping.toList + val parsed = elements.map(element => entry(DestructuredExpr.parseUntyped(element.asUntyped))) + parsed.collectFirst { case Left(reason) => reason } match { + case Some(reason) => fail(PicklerDerivationError.OneOfMappingNotStatic(reason)) + case None => MIO.pure(parsed.collect { case Right(e) => e }) + } + } + + // --------------------------------------------------------------------------------------------------------------- + // Runner + // --------------------------------------------------------------------------------------------------------------- + + /** The plumbing every entry point shares: the `Nothing`/`Any` guard, the log scope, the `ValDefsCache` and its `toValDefs.use` wrapper, + * loading of the standard extensions, and `runToExprOrFail` with the rendering flags and timeout. + * + * `body` receives the cache and a `runSafe`, derives whatever it derives, and returns the final expression; every hoisted definition + * ends up in scope around it. + */ + private def runDerivation[A: Type, Out: Type](macroName: String, what: String)( + body: (MLocal[ValDefsCache], RunSafe[MIO]) => Expr[Out] + ): Expr[Out] = { + // On Scala 3 an unconstrained type parameter is inferred as `Any`, which is almost never what the user meant. + if (Type[A] =:= Type.of[Nothing].asInstanceOf[Type[A]] || Type[A] =:= Type.of[Any].asInstanceOf[Type[A]]) + Environment.reportErrorAndAbort( + s"$macroName: type parameter was inferred as ${Type[A].prettyPrint}, which is likely unintended.\n" + + s"Provide an explicit type parameter, e.g.: $macroName[MyType]" + ) + + val rendering = if (shouldWeLogDerivation) RenderFrom(Log.Level.Info) else DontRender + + Log + .namedScope(s"Deriving $what for ${Type[A].prettyPrint} at: ${Environment.currentPosition.prettyPrint}") { + MIO.scoped { runSafe => + val cache = ValDefsCache.mlocal + runSafe(ensureStandardExtensionsLoaded()) + // The schema is derived before the codec, sequentially: the two share Hearth-internal MLocal state, and + // parallelising them has previously produced silently wrong codegen for parameterised Scala 3 enums in a + // comparable derivation. + val result = body(cache, runSafe) + val vals = runSafe(cache.get) + vals.toValDefs.use(_ => result) + } + } + .flatTap(result => Log.info(s"Derived final $what: ${result.prettyPrint}")) + .runToExprOrFail(macroName, infoRendering = rendering, errorRendering = rendering, timeout = derivationTimeout)( + renderDerivationErrorMessage + ) + } + + /** The [[DerivationEnv]] for one expansion: the folded configuration, plus the root (and anything else the caller names) excluded from + * user-pickler lookup. + */ + private def environment( + configExpr: Expr[PicklerConfiguration], + root: String, + leafNameOverrides: Map[String, String] = Map.empty, + alsoExclude: Set[String] = Set.empty + ): MIO[DerivationEnv] = + foldConfiguration(configExpr).map(config => DerivationEnv(config, leafNameOverrides, alsoExclude + root)) + + // --------------------------------------------------------------------------------------------------------------- + // Configuration + // --------------------------------------------------------------------------------------------------------------- + + /** Fold the `PicklerConfiguration` expression to a value. + * + * Both halves *need* the value: `toEncodedName` and `toDiscriminatorValue` are invoked during expansion and the results spliced as + * literals (see [[NameSupport]]). `semiEval` handles the common shapes directly (`PicklerConfiguration.default`, `.with*` chains, + * lambdas such as `_.toUpperCase`). When the expression is merely a reference to a `given`/`implicit val` — which is what an implicit + * parameter usually is — the definition is followed to its right-hand side and that is evaluated instead, as far as the tree is + * available. + */ + private def foldConfiguration(configExpr: Expr[PicklerConfiguration]): MIO[PicklerConfiguration] = { + implicit val ConfigT: Type[PicklerConfiguration] = PTypes.Config + + def attempt(expr: Expr[PicklerConfiguration], depth: Int): Either[String, PicklerConfiguration] = + dropNamedArgs(expr).semiEval match { + case Right(value) => Right(value) + case Left(reasons) => + dereferenceStable(expr) match { + case Some(rhs) if depth < 8 => attempt(rhs, depth + 1) + case _ => Left(reasons.toVector.mkString("; ")) + } + } + + attempt(configExpr, 0) match { + case Right(value) => Log.info(s"Configuration folded at compile time: $value") >> MIO.pure(value) + case Left(reason) => fail(PicklerDerivationError.ConfigurationNotStatic(configExpr.plainPrint, reason)) + } + } + + // --------------------------------------------------------------------------------------------------------------- + // Schema chain entry + // --------------------------------------------------------------------------------------------------------------- + + /** Entry into the schema rule pipeline. + * + * The `inProgress` set is created here, once per expansion, so that the recursion guard has the same lifetime as the `ValDefsCache` it + * cooperates with. + */ + private def deriveSchemaRecursively[A: Type](cache: MLocal[ValDefsCache], env: DerivationEnv): MIO[Expr[Schema[A]]] = { + val inProgress: MLocal[Set[String]] = MLocal(Set.empty[String])(identity)((a, b) => a ++ b) + deriveSchemaFor[A](using SchemaCtx(Type[A], env, cache, inProgress)) + } + + // --------------------------------------------------------------------------------------------------------------- + // Diagnostics + // --------------------------------------------------------------------------------------------------------------- + + /** Logging is enabled either by importing `sttp.tapir.json.pickler.debug.logDerivationForPickler` or by the scalac option + * `-Xmacro-settings:tapirPickler.logDerivation=true`. A `lazy val`: the answer cannot change within one expansion, and the implicit + * search is not free. + */ + lazy val shouldWeLogDerivation: Boolean = { + implicit val LogDerivationT: Type[Pickler.LogDerivation] = PTypes.LogDerivation + def importedIntoScope = Expr.summonImplicit[Pickler.LogDerivation].isDefined + def setGlobally = (for { + data <- Environment.typedSettings.toOption + namespace <- data.get(derivationSettingsNamespace) + shouldLog <- namespace.get("logDerivation").flatMap(_.asBoolean) + } yield shouldLog).getOrElse(false) + + importedIntoScope || setGlobally + } + + private def renderDerivationErrorMessage(errorLogs: String, errors: NonEmptyVector[Throwable]): String = { + val errorsRendered = errors + .map { e => + val msg = Option(e.getMessage).getOrElse(e.getClass.getName) + msg.split("\n").toList match { + case head :: tail => ((" - " + head) :: tail.map(" " + _)).mkString("\n") + case _ => " - " + msg + } + } + .mkString("\n") + val hint = + "Enable debug logging with: import sttp.tapir.json.pickler.debug.logDerivationForPickler " + + s"or the scalac option -Xmacro-settings:$derivationSettingsNamespace.logDerivation=true" + if (errorLogs.nonEmpty) + s"""Pickler derivation failed with the following errors: + |$errorsRendered + |and the following logs: + |$errorLogs + |$hint""".stripMargin + else + s"""Pickler derivation failed with the following errors: + |$errorsRendered + |$hint""".stripMargin + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/PlatformSupport.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/PlatformSupport.scala new file mode 100644 index 0000000000..dd89d72039 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/PlatformSupport.scala @@ -0,0 +1,89 @@ +package sttp.tapir.json.pickler.internal.compiletime + +import hearth.MacroCommons + +/** The few operations the codec derivation needs that Hearth does not abstract over, implemented per platform. + * + * Everything here exists because the codec half delegates to `jsoniter-scala-macros`' `JsonCodecMaker.make`, whose configuration is + * interpreted at *its* expansion time by walking the argument tree (`CompileTimeEval` in `jsoniter-scala-macros`). That interpreter + * accepts a narrow set of tree shapes, so the trees have to be built by hand rather than with `Expr.quote` (see the notes on + * `PlatformSupportScala3` for the shapes that were found to work). + * + * Keeping these behind an interface is what leaves `CodecDerivation` platform-independent: a Scala 2 bundle would implement the same six + * methods against `scala.reflect.macros` (where jsoniter evaluates its config with `c.eval` and therefore accepts different shapes). + */ +trait PlatformSupport { this: MacroCommons => + + /** `{ case "k1" => "v1"; case "k2" => "v2" }: PartialFunction[String, String]` — for `withFieldNameMapper`. + * + * The caller guarantees `pairs` is non-empty; an empty mapper should simply not be passed to jsoniter. + */ + protected def stringPartialFunction(pairs: List[(String, String)]): Expr[PartialFunction[String, String]] + + /** `(x: String) => x match { case "k1" => "v1"; ...; case other => other }` — for `withAdtLeafClassNameMapper`. */ + protected def stringFunction(pairs: List[(String, String)]): Expr[String => String] + + /** The name jsoniter hands to `adtLeafClassNameMapper` for this leaf: `Symbol.fullName`, module `$` stripped. */ + protected def jsoniterLeafName[A: Type]: String + + /** The type arguments of `A`, flattened and fully qualified the way tapir core's `SNameMacros.extractTypeArguments` does it: + * `Foo[Bar[Baz], Qux]` gives `List("Bar", "Baz", "Qux")`. Empty for a non-applied type. + */ + protected def flattenedTypeArguments[A: Type]: List[String] + + /** The key under which `A` is memoised throughout a derivation (codec vals, visited set, user-pickler memo, name overrides): its + * *dealiased* printed form, so that `type Id = UUID` and `UUID` share one entry. + */ + protected def typeKey[A: Type]: String + + /** The fully-qualified name tapir core's `SNameMacros.typeFullName[A]` produces — the base of the `SName` that `NameSupport.sNameOf` + * builds, hence the input to `toDiscriminatorValue`. + */ + protected def tapirFullName[A: Type]: String + + /** The string an all-singleton hierarchy's case is written as: the case's simple name (`VariantB`, `Cyan`), or its type-level + * `@encodedName`. Deliberately *not* run through `toDiscriminatorValue`: an enumeration value is not a discriminator, so e.g. + * `withFullKebabCaseDiscriminatorValues` must not turn a plain enum value into `sttp.tapir...variant-b`. Users who want a different + * rendering have `derivedEnumeration[T].customStringBased`. + */ + protected def enumCaseName[A: Type](encodedName: Option[String]): String = + encodedName.getOrElse(tapirFullName[A].split('.').last) + + /** `{ implicit lazy val n1: T1 = e1; ...; body(refs) }` where `refs` are references to the vals, in order. + * + * `implicit` because jsoniter finds codecs for nested types through `Implicits.search` and nothing else; `lazy` so that mutually + * recursive codecs can refer to each other regardless of declaration order. Each right-hand side is built from the same `refs`, so a + * hand-written combinator (`Either`) can name its sibling codecs directly rather than through an implicit search that happens at *our* + * expansion time, when the vals do not exist yet. + */ + protected def implicitLazyVals[Out: Type](vals: List[(String, UntypedType, List[UntypedExpr] => UntypedExpr)])( + body: List[UntypedExpr] => Expr[Out] + ): Expr[Out] + + /** `f(a)` with the lambda inlined, when `f` is a lambda literal: `((x: A) => body)(a)` becomes `body[x := a]`. + * + * Hearth's `semiEval` can evaluate a method-call tree but not apply a lambda it has evaluated (it materialises lambdas as reflective + * proxies), so `oneOfUsingField` reduces the application first and evaluates the body. Returns the plain application when `f` is not a + * literal lambda. + */ + protected def betaReduce[A: Type, B: Type](f: Expr[A => B], a: Expr[A]): Expr[B] + + /** A string interpolation over constants, folded: `s"code-${200}"` gives `"code-200"`. + * + * Covers the one shape Hearth's `semiEval` does not (`StringContext.apply(parts*).s(args*)`, a varargs call on a varargs-constructed + * receiver), which happens to be how `oneOfUsingField`'s `asString` is usually written. `None` for anything else. + */ + protected def constantInterpolation(expr: Expr[String]): Option[String] + + /** Follow a stable reference (`Ident`/`Select` of a `val`/`given`) to its right-hand side, when the definition's tree is available in + * this compilation run. `None` when `expr` is not such a reference or the tree is not retained (definitions from other compilation units + * need `-Yretain-trees`). + */ + protected def dereferenceStable[A: Type](expr: Expr[A]): Option[Expr[A]] + + /** `f(name = a)` rewritten as `f(a)`, everywhere in the tree. Hearth's `semiEval` does not see through named arguments, and + * `withToEncodedName(toEncodedName = _.toUpperCase)` is a natural thing to write. Only sound when the named arguments are already in + * parameter order, which holds for every single-parameter `with*` builder. + */ + protected def dropNamedArgs[A: Type](expr: Expr[A]): Expr[A] +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/SchemaDerivation.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/SchemaDerivation.scala new file mode 100644 index 0000000000..523919a512 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/SchemaDerivation.scala @@ -0,0 +1,477 @@ +package sttp.tapir.json.pickler.internal.compiletime + +import hearth.MacroCommons +import hearth.fp.effect.* +import hearth.fp.instances.* +import hearth.fp.syntax.* +import hearth.std.* +import sttp.tapir.Schema +import sttp.tapir.Schema.SName +import sttp.tapir.SchemaType.SProductField +import sttp.tapir.json.pickler.internal.runtime.SchemaUtils + +/** Derivation of the tapir [[Schema]] half of a `Pickler`. + * + * The shapes produced here (names, field order, where the discriminator field goes, which annotations are folded) are pinned by + * `SchemaDerivationTest`, and match what tapir core's `Schema.derived` produces for the same types. + * + * ==Design== + * Every name (field names, type names, discriminator and enumeration values) is computed at expansion time by [[NameSupport]] from the + * folded `PicklerConfiguration` and spliced as a literal — the same literal the codec half hands to `JsonCodecMaker`. The structure of the + * schema is built by [[SchemaUtils]] at runtime, so that the macro reifies as little as possible. + */ +trait SchemaDerivation { + this: MacroCommons & StdExtensions & TypeShape & AnnotationSupport & NameSupport & ImplicitPicklerSupport & PlatformSupport => + + /** Centralised `Type.of` instances — see the note on `PicklerMacrosImpl.PTypes` for why these are not `implicit val`s at their use sites. + */ + private[compiletime] object STypes { + def SchemaOf[A: Type]: Type[Schema[A]] = Type.of[Schema[A]] + def SchemaAny: Type[Schema[Any]] = Type.of[Schema[Any]] + def ProductFieldOf[A: Type]: Type[SProductField[A]] = Type.of[SProductField[A]] + lazy val SNameT: Type[SName] = Type.of[SName] + lazy val StringT: Type[String] = Type.of[String] + lazy val AnyT: Type[Any] = Type.of[Any] + lazy val ListAnyT: Type[List[Any]] = Type.of[List[Any]] + lazy val ListStringT: Type[List[String]] = Type.of[List[String]] + def ListOf[A: Type]: Type[List[A]] = Type.of[List[A]] + lazy val SchemaAnyPair: Type[(Schema[Any], String)] = Type.of[(Schema[Any], String)] + lazy val SchemaAnyPairs: Type[List[(Schema[Any], String)]] = Type.of[List[(Schema[Any], String)]] + lazy val IntT: Type[Int] = Type.of[Int] + def IndexFnOf[A: Type]: Type[A => Int] = Type.of[A => Int] + } + + // ----------------------------------------------------------------------------------------------------------------- + // Context + // ----------------------------------------------------------------------------------------------------------------- + + /** Everything a schema rule needs, threaded through the recursion. + * + * `inProgress` is the recursion guard: a type is added before its children are derived and restored (not merely removed) afterwards, so + * that sibling branches do not see each other's entries. + */ + final case class SchemaCtx[A]( + tpe: Type[A], + env: DerivationEnv, + cache: MLocal[ValDefsCache], + inProgress: MLocal[Set[String]] + ) { + def cacheKey: String = SchemaDerivation.this.cacheKey(tpe) + + def nest[B: Type]: SchemaCtx[B] = SchemaCtx(Type[B], env, cache, inProgress) + } + + def sctx[A](implicit A: SchemaCtx[A]): SchemaCtx[A] = A + + implicit def currentSchemaType[A: SchemaCtx]: Type[A] = sctx.tpe + + private def cacheKey[A](tpe: Type[A]): String = s"pickler-schema-for-${typeKey(using tpe)}" + + private def cacheName[A: Type]: String = s"schema_${Type[A].shortName}" + + /** Hoist a schema into a `lazy val` and return a reference to it. + * + * `lazy` (rather than `val`) is what lets mutually recursive schemas reference each other; hoisting at all is what stops a deep ADT from + * inlining the same sub-schema at every occurrence. + */ + private def setCachedAndGet[A: Type]( + cache: MLocal[ValDefsCache], + instance: Expr[Schema[A]] + ): MIO[Expr[Schema[A]]] = { + val key = cacheKey(Type[A]) + implicit val SchemaA: Type[Schema[A]] = STypes.SchemaOf[A] + cache.get0Ary[Schema[A]](key).flatMap { + case Some(ref) => MIO.pure(ref) + case None => + cache.buildCachedWith(key, ValDefBuilder.ofLazy[Schema[A]](cacheName[A]))(_ => instance) >> + cache.get0Ary[Schema[A]](key).map(_.getOrElse(instance)) + } + } + + /** Run `body` with `A` marked as in-progress, restoring the previous set afterwards. */ + private def guardingRecursion[A: SchemaCtx, Out](body: => MIO[Out]): MIO[Out] = + sctx.inProgress.get.flatMap { previous => + for { + _ <- sctx.inProgress.set(previous + sctx.cacheKey) + result <- body + _ <- sctx.inProgress.set(previous) + } yield result + } + + // ----------------------------------------------------------------------------------------------------------------- + // Rule pipeline + // ----------------------------------------------------------------------------------------------------------------- + + abstract class SchemaRule(val name: String) extends Rule { + def apply[A: SchemaCtx]: MIO[Rule.Applicability[Expr[Schema[A]]]] + } + + def deriveSchemaFor[A: SchemaCtx]: MIO[Expr[Schema[A]]] = + Log.namedScope(s"deriveSchema[${Type[A].prettyPrint}]") { + Rules( + UseCachedRule, + UseSelfRefWhenRecursiveRule, + // A user-supplied `Pickler[A]` beats everything structural: it is the override mechanism, and the codec chain + // honours the same instance, so schema and JSON stay in step. Safe to summon early only because of the + // re-entrancy guard described in `ImplicitPicklerSupport`. + UseUserPicklerRule, + StructuralRule + )(_[A]).flatMap { + case Right(result) => MIO.pure(result) + case Left(reasons) => + val explanation = reasons.toListMap.view.map { case (rule, why) => + if (why.isEmpty) s" - ${rule.name}: not applicable" + else s" - ${rule.name}: ${why.mkString("; ")}" + }.toList + failSchema(PicklerDerivationError.UnsupportedType(Type[A].plainPrint, explanation)) + } + } + + private def failSchema[T](error: PicklerDerivationError): MIO[T] = Log.error(error.message) >> MIO.fail(error) + + private object UseCachedRule extends SchemaRule("use cached schema") { + def apply[A: SchemaCtx]: MIO[Rule.Applicability[Expr[Schema[A]]]] = { + implicit val SchemaA: Type[Schema[A]] = STypes.SchemaOf[A] + sctx.cache.get0Ary[Schema[A]](sctx.cacheKey).map { + case Some(cached) => Rule.matched(cached) + case None => Rule.yielded(s"${Type[A].plainPrint} is not cached") + } + } + } + + private object UseSelfRefWhenRecursiveRule extends SchemaRule("emit SRef for a recursive type") { + def apply[A: SchemaCtx]: MIO[Rule.Applicability[Expr[Schema[A]]]] = + sctx.inProgress.get.map { inProgress => + if (inProgress.contains(sctx.cacheKey)) { + implicit val SNameT: Type[SName] = STypes.SNameT + val sName = sNameExpr[A] + Rule.matched(Expr.quote(SchemaUtils.refSchema[A](Expr.splice(sName)))) + } else Rule.yielded(s"${Type[A].plainPrint} is not currently being derived") + } + } + + private object UseUserPicklerRule extends SchemaRule("use the schema of a user-supplied Pickler") { + def apply[A: SchemaCtx]: MIO[Rule.Applicability[Expr[Schema[A]]]] = + userPickler[A](sctx.env).flatMap { + case Some(pickler) => + implicit val SchemaA: Type[Schema[A]] = STypes.SchemaOf[A] + setCachedAndGet[A](sctx.cache, Expr.quote(Expr.splice(pickler).schema)).map(Rule.matched) + case None => MIO.pure(Rule.yielded(s"no user-supplied Pickler[${Type[A].plainPrint}] in scope")) + } + } + + /** The structural rule: dispatch on the [[Shape]] shared with `CodecDerivation`, so that both halves take a type apart identically. + * + * Only the shapes that are not structural (`BuiltInScalar`, the Java numbers, `Opaque`) reach the implicit search for a `Schema[A]`. + * That is where primitives, `String`, `java.time` types and any user-provided schema for a non-structural type are resolved — + * `Schema.schemaForInt` and friends live on the `Schema` companion and need no import. Keeping the implicit search away from the + * structural shapes is load-bearing: with `sttp.tapir.generic.auto.*` in scope, `Schema.derivedSchema` would resolve every case class + * through magnolia instead of through our rules (which name types and fold annotations differently), and summoning tapir's own + * `Schema[Option[A]]` would make the *compiler* search for `Schema[A]`, out of our control. (Hearth's `summonExprIgnoring`, the direct + * fix, needs Scala 3.7+.) + * + * The cost is that a user-supplied `given Schema[MyCaseClass]` does not override structural derivation: overriding is done by supplying + * a `Pickler`, not a `Schema`, so that the codec follows suit. + */ + private object StructuralRule extends SchemaRule("derive from the type's shape") { + def apply[A: SchemaCtx]: MIO[Rule.Applicability[Expr[Schema[A]]]] = { + val shape = classify[A] + Log.info(s"${Type[A].prettyPrint} classified as ${shape.getClass.getSimpleName}") >> (shape match { + case Shape.BuiltInScalar() | Shape.JavaBigDecimal() | Shape.JavaBigInteger() | Shape.Opaque() => useImplicitSchema[A] + // tapir core declares no `Schema[Char]`, but jsoniter writes a `Char` as a one-character string. Supplying the + // matching `SString` here is what keeps the schema and the codec in agreement. + case Shape.CharScalar() => MIO.pure(Expr.quote(SchemaUtils.stringLikeSchema[A])) + case Shape.ValueClass(inner) => deriveValueClassSchema[A](inner) + case Shape.OptionOf(element) => deriveOptionSchema[A](element) + case Shape.NestedOption(inner, _) => deriveOptionSchema[A](inner) + case Shape.EitherOf(left, right) => deriveEitherSchema[A](left, right) + case Shape.StringMap(value) => deriveMapSchema[A](value) + case Shape.NonStringMap(key, _) => failSchema(PicklerDerivationError.NonStringMapKey(key.Underlying.plainPrint)) + case Shape.Collection(element) => deriveCollectionSchema[A](element) + case Shape.Tuple() => failSchema(PicklerDerivationError.TupleNotSupported(Type[A].plainPrint)) + case Shape.Singleton() => guardingRecursion[A, Expr[Schema[A]]](deriveSingletonSchema[A]) + case Shape.Product(_, params) => guardingRecursion[A, Expr[Schema[A]]](deriveCaseClassSchema[A](params)) + case Shape.Enumeration(_, leaves) => guardingRecursion[A, Expr[Schema[A]]](deriveStringEnumSchema[A](leaves)) + case Shape.Coproduct(_, leaves) => guardingRecursion[A, Expr[Schema[A]]](deriveCoproductSchema[A](leaves)) + }).map(Rule.matched) + } + } + + private def useImplicitSchema[A: SchemaCtx]: MIO[Expr[Schema[A]]] = { + implicit val SchemaA: Type[Schema[A]] = STypes.SchemaOf[A] + Expr.summonImplicit[Schema[A]].toOption match { + case Some(expr) => MIO.pure(expr) + case None => + failSchema(PicklerDerivationError.UnsupportedType(Type[A].plainPrint, List(s"no implicit Schema[${Type[A].plainPrint}] in scope"))) + } + } + + /** An `AnyVal` wrapper is documented as its inner type, because that is how jsoniter writes it (unwrapped). Matches tapir core's own + * `Schema.derived` for value classes. + */ + private def deriveValueClassSchema[A: SchemaCtx](inner: ??): MIO[Expr[Schema[A]]] = { + import inner.Underlying as Inner + deriveSchemaFor[Inner](using sctx.nest[Inner]).map(schema => Expr.quote(Expr.splice(schema).asInstanceOf[Schema[A]])) + } + + private def deriveOptionSchema[A: SchemaCtx](element: ??): MIO[Expr[Schema[A]]] = { + import element.Underlying as Element + deriveSchemaFor[Element](using sctx.nest[Element]).map { schema => + Expr.quote(SchemaUtils.optionSchema[Element](Expr.splice(schema)).asInstanceOf[Schema[A]]) + } + } + + /** tapir core documents `Either` as an *untagged* coproduct of the two sides (`Schema.schemaForEither`), and the codec writes the bare + * side value to match. + */ + private def deriveEitherSchema[A: SchemaCtx](left: ??, right: ??): MIO[Expr[Schema[A]]] = { + import left.Underlying as L + import right.Underlying as R + for { + l <- deriveSchemaFor[L](using sctx.nest[L]) + r <- deriveSchemaFor[R](using sctx.nest[R]) + } yield Expr.quote(SchemaUtils.eitherSchema[L, R](Expr.splice(l), Expr.splice(r)).asInstanceOf[Schema[A]]) + } + + private def deriveCollectionSchema[A: SchemaCtx](element: ??): MIO[Expr[Schema[A]]] = { + import element.Underlying as Element + deriveSchemaFor[Element](using sctx.nest[Element]).map { schema => + Expr.quote(SchemaUtils.collectionSchema[Element](Expr.splice(schema)).asInstanceOf[Schema[A]]) + } + } + + private def deriveMapSchema[A: SchemaCtx](value: ??): MIO[Expr[Schema[A]]] = { + import value.Underlying as Value + implicit val StringT: Type[String] = STypes.StringT + // tapir's own convention (`SchemaMacros.generateSchemaForMap`): a `String` key contributes nothing to the name, + // the value's name comes first and its type arguments are flattened in after it. + val typeParams = Expr(tapirFullName[Value] :: flattenedTypeArguments[Value]) + deriveSchemaFor[Value](using sctx.nest[Value]).map { schema => + Expr.quote(SchemaUtils.mapSchema[Value](Expr.splice(schema), Expr.splice(typeParams)).asInstanceOf[Schema[A]]) + } + } + + // ----------------------------------------------------------------------------------------------------------------- + // Shapes + // ----------------------------------------------------------------------------------------------------------------- + + /** A `case object` becomes a product with no fields — the discriminator field, if any, is added by the parent. */ + private def deriveSingletonSchema[A: SchemaCtx]: MIO[Expr[Schema[A]]] = { + implicit val SchemaA: Type[Schema[A]] = STypes.SchemaOf[A] + implicit val SNameT: Type[SName] = STypes.SNameT + val name = sNameExpr[A] + val annotations = typeAnnotationsExpr[A] + setCachedAndGet[A]( + sctx.cache, + Expr.quote { + SchemaUtils.enrichSchema[A]( + SchemaUtils.productSchema[A](Expr.splice(name), SchemaUtils.emptyFieldList[A]), + Expr.splice(annotations) + ) + } + ) + } + + private def deriveCaseClassSchema[A: SchemaCtx](params: List[(String, Parameter)]): MIO[Expr[Schema[A]]] = { + implicit val SchemaA: Type[Schema[A]] = STypes.SchemaOf[A] + implicit val SNameT: Type[SName] = STypes.SNameT + implicit val FieldT: Type[SProductField[A]] = STypes.ProductFieldOf[A] + + val name = sNameExpr[A] + val annotations = typeAnnotationsExpr[A] + + params + .foldLeft(MIO.pure(List.empty[Expr[SProductField[A]]])) { case (acc, (fieldName, param)) => + acc.flatMap(fields => deriveFieldExpr[A](fieldName, param).map(fields :+ _)) + } + .flatMap { fields => + val fieldsList = fields.foldRight(Expr.quote(SchemaUtils.emptyFieldList[A])) { (field, tail) => + Expr.quote(Expr.splice(field) :: Expr.splice(tail)) + } + setCachedAndGet[A]( + sctx.cache, + Expr.quote { + SchemaUtils.enrichSchema[A]( + SchemaUtils.productSchema[A](Expr.splice(name), Expr.splice(fieldsList)), + Expr.splice(annotations) + ) + } + ) + } + } + + private def deriveFieldExpr[A: SchemaCtx](fieldName: String, param: Parameter): MIO[Expr[SProductField[A]]] = { + import param.tpe.Underlying as Field + implicit val AnyT: Type[Any] = STypes.AnyT + implicit val FieldT: Type[SProductField[A]] = STypes.ProductFieldOf[A] + + val scalaName = Expr(fieldName) + val index = Expr(param.index) + val annotations = collectAnnotationsExpr(allParamAnnotations[A](param, fieldName)) + + encodedFieldName[A](param, fieldName, sctx.env.config) match { + case Left(error) => failSchema(error) + case Right(encoded) => + val encodedName = Expr(encoded) + deriveSchemaFor[Field](using sctx.nest[Field]).map { fieldSchema => + Expr.quote { + SchemaUtils.productField[A]( + Expr.splice(scalaName), + Expr.splice(encodedName), + Expr.splice(fieldSchema).asInstanceOf[Schema[Any]], + Expr.splice(index), + Expr.splice(annotations) + ) + } + } + } + } + + /** A sealed hierarchy with at least one non-singleton leaf: a discriminated coproduct over the **leaves** (`Shape.Coproduct`), each leaf + * paired with the discriminator value the codec writes for it. + */ + private def deriveCoproductSchema[A: SchemaCtx](leaves: List[(String, ??<:[A])]): MIO[Expr[Schema[A]]] = { + implicit val SchemaA: Type[Schema[A]] = STypes.SchemaOf[A] + implicit val SchemaAnyT: Type[Schema[Any]] = STypes.SchemaAny + implicit val SNameT: Type[SName] = STypes.SNameT + implicit val StringT: Type[String] = STypes.StringT + implicit val PairT: Type[(Schema[Any], String)] = STypes.SchemaAnyPair + implicit val PairsT: Type[List[(Schema[Any], String)]] = STypes.SchemaAnyPairs + + val name = sNameExpr[A] + val annotations = typeAnnotationsExpr[A] + val discriminatorField = Expr(sctx.env.config.discriminator) + + leaves + .foldLeft(MIO.pure(List.empty[Expr[(Schema[Any], String)]])) { case (acc, (_, leaf)) => + acc.flatMap { pairs => + import leaf.Underlying as Leaf + discriminatorValue[Leaf](sctx.env) match { + case Left(error) => failSchema(error) + case Right(value) => + val valueExpr = Expr(value) + deriveSchemaFor[Leaf](using sctx.nest[Leaf]).map { leafSchema => + pairs :+ Expr.quote((Expr.splice(leafSchema).asInstanceOf[Schema[Any]], Expr.splice(valueExpr))) + } + } + } + } + .flatMap { pairs => + val subtypesWithValues = pairs.foldRight(Expr.quote(Nil: List[(Schema[Any], String)])) { (pair, tail) => + Expr.quote(Expr.splice(pair) :: Expr.splice(tail)) + } + subtypeIndexExpr[A](leaves).flatMap { subtypeIndex => + setCachedAndGet[A]( + sctx.cache, + Expr.quote { + SchemaUtils.enrichSchema[A]( + SchemaUtils.coproductSchema[A]( + Expr.splice(name), + Expr.splice(subtypesWithValues), + Expr.splice(discriminatorField), + Expr.splice(subtypeIndex) + ), + Expr.splice(annotations) + ) + } + ) + } + } + } + + /** `(value: A) => value match { case _: Leaf0 => 0; case _: Leaf1 => 1; ... }`, the dispatch behind `SCoproduct.subtypeSchema` (and hence + * `Schema.applyValidation`). A type-test match, generated with Hearth's `Enum.matchOn`, is the only dispatch that handles parameterless + * Scala 3 enum cases (all one runtime class) and nested classes alike. `matchOn` sees one level of the hierarchy at a time, so + * intermediate sealed traits are descended recursively until a leaf is reached. + */ + private def subtypeIndexExpr[A: Type](leaves: List[(String, ??<:[A])]): MIO[Expr[A => Int]] = { + implicit val IntT: Type[Int] = STypes.IntT + implicit val FnT: Type[A => Int] = STypes.IndexFnOf[A] + val keys = leaves.map { case (_, leaf) => import leaf.Underlying as Leaf; typeKey[Leaf] } + + def indexOf[B: Type](value: Expr[B]): MIO[Expr[Int]] = + keys.indexOf(typeKey[B]) match { + case -1 => + Enum.parse[B].toEither match { + case Right(e) => + e.matchOn[MIO, Int](value) { matched => + import matched.{Underlying as Child, value as child} + indexOf[Child](child) + }.map(_.getOrElse(Expr(-1))) + case Left(_) => MIO.pure(Expr(-1)) + } + case index => MIO.pure(Expr(index)) + } + + LambdaBuilder.of1[A]("value").traverse(indexOf[A](_)).map(_.build[Int]) + } + + /** An all-singleton hierarchy (`Shape.Enumeration`): `SString` plus a `Validator.enumeration` of the singleton values — the schema + * counterpart of encoding it as a bare string. + */ + private def deriveStringEnumSchema[A: SchemaCtx](children: List[(String, ??<:[A])]): MIO[Expr[Schema[A]]] = { + implicit val SchemaA: Type[Schema[A]] = STypes.SchemaOf[A] + implicit val SNameT: Type[SName] = STypes.SNameT + implicit val ListA: Type[List[A]] = STypes.ListOf[A] + implicit val ListStringT: Type[List[String]] = STypes.ListStringT + implicit val StringT: Type[String] = STypes.StringT + + val name = sNameExpr[A] + val annotations = typeAnnotationsExpr[A] + + val values = singletonValuesExpr[A](children) + // The same literal the codec's leaf-name mapper produces (`NameSupport.enumerationValue`). + val encodedNames = children.foldRight(Expr.quote(Nil: List[String])) { case ((_, child), tail) => + import child.Underlying as Child + val childName = Expr(enumerationValue[Child]) + Expr.quote(Expr.splice(childName) :: Expr.splice(tail)) + } + + setCachedAndGet[A]( + sctx.cache, + Expr.quote { + SchemaUtils.enrichSchema[A]( + SchemaUtils.stringEnumSchema[A](Expr.splice(name), Expr.splice(values), Expr.splice(encodedNames)), + Expr.splice(annotations) + ) + } + ) + } + + /** `List(Case1, Case2, ...)` for the singleton children of an enumeration; the caller guarantees they are singletons. */ + protected def singletonValuesExpr[A: Type](children: List[(String, ??<:[A])]): Expr[List[A]] = { + implicit val ListA: Type[List[A]] = STypes.ListOf[A] + children.foldRight(Expr.quote(Nil: List[A])) { case ((_, child), tail) => + import child.Underlying as Child + val singleton = SingletonValue.parse[Child].toEither.toOption.get + Expr.quote(Expr.splice(singleton.singletonExpr).asInstanceOf[A] :: Expr.splice(tail)) + } + } + + // ----------------------------------------------------------------------------------------------------------------- + // Names and annotations + // ----------------------------------------------------------------------------------------------------------------- + + /** `NameSupport.sNameOf[A]`, reified: the same `SName` the codec half feeds to `toDiscriminatorValue`. */ + protected def sNameExpr[A: Type]: Expr[SName] = { + implicit val SNameT: Type[SName] = STypes.SNameT + implicit val StringT: Type[String] = STypes.StringT + implicit val ListStringT: Type[List[String]] = STypes.ListStringT + val name = sNameOf[A] + val fullName = Expr(name.fullName) + val typeParameters = Expr(name.typeParameterShortNames) + Expr.quote(SName(Expr.splice(fullName), Expr.splice(typeParameters))) + } + + private def collectAnnotationsExpr(annotations: List[UntypedExpr]): Expr[List[Any]] = { + implicit val AnyT: Type[Any] = STypes.AnyT + implicit val ListAnyT: Type[List[Any]] = STypes.ListAnyT + annotations.foldRight(Expr.quote(List.empty[Any])) { (annotation, tail) => + val typed: Expr[Any] = annotation.asTyped[Any] + Expr.quote(Expr.splice(typed) :: Expr.splice(tail)) + } + } + + protected def typeAnnotationsExpr[A: Type]: Expr[List[Any]] = + collectAnnotationsExpr(allTypeAnnotations[A]) +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/TypeShape.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/TypeShape.scala new file mode 100644 index 0000000000..c4ae61902c --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/compiletime/TypeShape.scala @@ -0,0 +1,185 @@ +package sttp.tapir.json.pickler.internal.compiletime + +import hearth.MacroCommons +import hearth.std.* + +/** The one place where a type is taken apart. + * + * Both halves of the derivation — the schema rules in [[SchemaDerivation]] and the codec walk in [[CodecDerivation]] — consume the + * [[Shape]] computed here, so they cannot disagree about *what* a type is: whether `String` is a scalar or a collection of `Char`, whether + * an `Either` is a coproduct, whether a hierarchy is an enumeration (bare strings) or a coproduct (discriminated objects), and so on. What + * each half *does* with a shape is its own business; the classification is not. + * + * The order of the cases in [[classify]] matters and is deliberate: + * - the scalar-likes come first because the structural extractors would happily match them (`String` is an `Iterable[Char]`); + * - `AnyVal` wrappers before `Option`/collections, since a value class may wrap one; + * - `Either` before the sealed-hierarchy check, which would otherwise claim it as `Left | Right`; + * - `Map` before `Collection`, since a `Map` is an `Iterable` of pairs; + * - singletons before case classes (a `case object` parses as both), tuples before case classes (likewise). + */ +trait TypeShape { this: MacroCommons & StdExtensions => + + /** Centralised `Type.of` instances — see the note on `PicklerMacrosImpl.PTypes` for why these are not `implicit val`s at their use sites. + */ + private object ShapeTypes { + lazy val StringT: Type[String] = Type.of[String] + lazy val CharT: Type[Char] = Type.of[Char] + lazy val AnyValT: Type[AnyVal] = Type.of[AnyVal] + lazy val JBigDecimalT: Type[java.math.BigDecimal] = Type.of[java.math.BigDecimal] + lazy val JBigIntegerT: Type[java.math.BigInteger] = Type.of[java.math.BigInteger] + lazy val EitherCtor: Type.Ctor2[Either] = Type.Ctor2.of[Either] + } + + sealed trait Shape[A] + object Shape { + + /** `String` or `Array[Byte]`: tapir has a built-in `Schema` and jsoniter a built-in codec, but the structural extractors would take + * both apart as collections. + */ + final case class BuiltInScalar[A]() extends Shape[A] + + /** `Char`: jsoniter writes it as a one-character string; tapir core declares no `Schema[Char]`, so the schema side supplies one. */ + final case class CharScalar[A]() extends Shape[A] + + /** `java.math.BigDecimal` / `java.math.BigInteger`: tapir has a `Schema`, `JsonCodecMaker` has no codec (see `LeafCodecs`). */ + final case class JavaBigDecimal[A]() extends Shape[A] + final case class JavaBigInteger[A]() extends Shape[A] + + /** An `AnyVal` wrapper: documented and written as its inner type. Restricted to `AnyVal` because Hearth's `IsValueType` also matches + * opaque types and Java boxes, which jsoniter does not unwrap. + */ + final case class ValueClass[A](inner: ??) extends Shape[A] + + final case class OptionOf[A](element: ??) extends Shape[A] + + /** `Option[Option[X]]`, flattened to a nullable `X`. `inner` is `Option[X]`, `element` is `X`. */ + final case class NestedOption[A](inner: ??, element: ??) extends Shape[A] + + /** Untagged: written as the bare side value, documented as a coproduct without discriminator. */ + final case class EitherOf[A](left: ??, right: ??) extends Shape[A] + + /** A `Map` with `String` keys: a JSON object. */ + final case class StringMap[A](value: ??) extends Shape[A] + + /** A `Map` with any other key type: not derivable, the user has to supply `Pickler.picklerForMap`. */ + final case class NonStringMap[A](key: ??, value: ??) extends Shape[A] + + final case class Collection[A](element: ??) extends Shape[A] + + /** Not supported: jsoniter writes a tuple as an array, tapir would document an object. */ + final case class Tuple[A]() extends Shape[A] + + /** A `case object` or parameterless enum case, on its own (not as a member of an enumeration). */ + final case class Singleton[A]() extends Shape[A] + + final case class Product[A](cc: CaseClass[A], params: List[(String, Parameter)]) extends Shape[A] + + /** A sealed hierarchy whose leaves are all singletons: encoded as bare strings, documented as a string enumeration. Never empty. */ + final case class Enumeration[A](e: Enum[A], leaves: List[(String, ??<:[A])]) extends Shape[A] + + /** A sealed hierarchy with at least one non-singleton leaf (or no leaves at all, which is an error downstream): discriminated objects. + */ + final case class Coproduct[A](e: Enum[A], leaves: List[(String, ??<:[A])]) extends Shape[A] + + /** Anything else: primitives, `java.time`, `UUID`, ... Resolved through an implicit `Schema` on one side and jsoniter's built-in codecs + * on the other. + */ + final case class Opaque[A]() extends Shape[A] + } + + def classify[A: Type]: Shape[A] = { + implicit val StringT: Type[String] = ShapeTypes.StringT + implicit val CharT: Type[Char] = ShapeTypes.CharT + implicit val AnyValT: Type[AnyVal] = ShapeTypes.AnyValT + implicit val JBigDecimalT: Type[java.math.BigDecimal] = ShapeTypes.JBigDecimalT + implicit val JBigIntegerT: Type[java.math.BigInteger] = ShapeTypes.JBigIntegerT + val EitherCtor = ShapeTypes.EitherCtor + + if (Type[A] <:< Type[String] || Type[A].plainPrint == "scala.Array[scala.Byte]") Shape.BuiltInScalar() + else if (Type[A] =:= Type[Char]) Shape.CharScalar() + else if (Type[A] =:= Type[java.math.BigDecimal]) Shape.JavaBigDecimal() + else if (Type[A] =:= Type[java.math.BigInteger]) Shape.JavaBigInteger() + else + Type[A] match { + case IsValueType(isValueType) if Type[A] <:< Type[AnyVal] => + import isValueType.Underlying as Inner + Shape.ValueClass(Type[Inner].as_??) + case IsOption(isOption) => + import isOption.Underlying as Element + Type[Element] match { + case IsOption(isInner) => + import isInner.Underlying as Innermost + Shape.NestedOption(Type[Element].as_??, Type[Innermost].as_??) + case _ => Shape.OptionOf(Type[Element].as_??) + } + case EitherCtor(left, right) => Shape.EitherOf(left, right) + case IsMap(isMap) => + import isMap.Underlying as Pair + mapShape[A, Pair](isMap.value) + case IsCollection(isCollection) => + import isCollection.Underlying as Element + Shape.Collection(Type[Element].as_??) + case _ if Type[A].isTuple => Shape.Tuple() + case _ if isSingleton[A] => Shape.Singleton() + case _ => + CaseClass.parse[A].toEither match { + case Right(cc) => Shape.Product(cc, cc.primaryConstructor.totalParameters.flatten.toList) + case Left(_) => + Enum.parse[A].toEither match { + case Right(e) => + val leaves = leavesOf(e) + if (leaves.nonEmpty && allSingletons(leaves)) Shape.Enumeration(e, leaves) else Shape.Coproduct(e, leaves) + case Left(_) => Shape.Opaque() + } + } + } + } + + private def mapShape[A: Type, Pair: Type](isMap: IsMapOf[A, Pair]): Shape[A] = { + import isMap.{Key, Value} + implicit val StringT: Type[String] = ShapeTypes.StringT + if (Key <:< Type[String]) Shape.StringMap(Type[Value].as_??) else Shape.NonStringMap(Key.as_??, Type[Value].as_??) + } + + /** The **leaves** of a sealed hierarchy: an intermediate sealed trait contributes its own children, not itself, so `Pet -> Rodent -> + * Hamster` yields `Hamster` as a direct subtype. Falls back to the direct children when the hierarchy cannot be enumerated exhaustively. + */ + def leavesOf[A](e: Enum[A]): List[(String, ??<:[A])] = + e.exhaustiveChildren.map(_.toList).getOrElse(e.directChildren.toList) + + def isSingleton[A: Type]: Boolean = SingletonValue.parse[A].toEither.isRight + + def allSingletons[A](leaves: List[(String, ??<:[A])]): Boolean = leaves.forall { case (_, leaf) => + import leaf.Underlying as Leaf + isSingleton[Leaf] + } + + /** The leaves that are *not* singletons, by name — for error messages. */ + def nonSingletonLeaves[A](leaves: List[(String, ??<:[A])]): List[String] = leaves.collect { + case (_, leaf) if { + import leaf.Underlying as Leaf + !isSingleton[Leaf] + } => + leaf.Underlying.plainPrint + } + + /** The children the codec walk has to visit for a shape: everything the shape's codec is built from. */ + def childrenOf[A](shape: Shape[A]): List[??] = shape match { + case Shape.ValueClass(inner) => List(inner) + case Shape.OptionOf(element) => List(element) + case Shape.NestedOption(inner, _) => List(inner) + case Shape.EitherOf(left, right) => List(left, right) + case Shape.StringMap(value) => List(value) + case Shape.NonStringMap(_, value) => List(value) + case Shape.Collection(element) => List(element) + case Shape.Product(_, params) => params.map { case (_, param) => param.tpe } + case Shape.Enumeration(_, leaves) => leafTypes(leaves) + case Shape.Coproduct(_, leaves) => leafTypes(leaves) + case _ => Nil + } + + private def leafTypes[A](leaves: List[(String, ??<:[A])]): List[??] = leaves.map { case (_, leaf) => + import leaf.Underlying as Leaf + Type[Leaf].as_?? + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/CodecCombinators.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/CodecCombinators.scala new file mode 100644 index 0000000000..3d8d6c03a7 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/CodecCombinators.scala @@ -0,0 +1,174 @@ +package sttp.tapir.json.pickler.internal.runtime + +import com.github.plokhotnyuk.jsoniter_scala.core.{readFromArrayReentrant, JsonReader, JsonValueCodec, JsonWriter} + +import scala.collection.Factory +import scala.reflect.ClassTag +import scala.util.control.NonFatal + +/** Hand-written `JsonValueCodec` combinators, for the shapes `JsonCodecMaker` either cannot produce or cannot produce from a codec that + * already exists at runtime. + * + * Two kinds of callers: + * - the `Pickler` facade (`asOption`, `asIterable`, `asArray`, `picklerForMap`, `derivedEnumeration`), which wraps a codec it already + * holds — `JsonCodecMaker.make` cannot help there because it runs at compile time; + * - the derivation macro, for `Either`, which jsoniter has no encoding for. + * + * Token handling follows the code `JsonCodecMaker` generates for the same shapes, so that the JSON accepted and produced here is + * indistinguishable from jsoniter's own. + */ +object CodecCombinators { + + /** `None` is `null`; anything else is the inner value. Matches jsoniter's handling of a standalone `Option`. */ + def option[T](inner: JsonValueCodec[T]): JsonValueCodec[Option[T]] = new JsonValueCodec[Option[T]] { + def nullValue: Option[T] = None + def decodeValue(in: JsonReader, default: Option[T]): Option[T] = + if (in.isNextToken('n')) in.readNullOrError(default, "expected value or null") + else { + in.rollbackToken() + Some(inner.decodeValue(in, inner.nullValue)) + } + def encodeValue(x: Option[T], out: JsonWriter): Unit = x match { + case Some(v) => inner.encodeValue(v, out) + case None => out.writeNull() + } + } + + def iterable[T, C[X] <: Iterable[X]](inner: JsonValueCodec[T])(implicit factory: Factory[T, C[T]]): JsonValueCodec[C[T]] = + new JsonValueCodec[C[T]] { + // `nullValue` is requested for every element decoded by an enclosing codec, so it must not allocate each time. + private val empty: C[T] = factory.newBuilder.result() + def nullValue: C[T] = empty + def decodeValue(in: JsonReader, default: C[T]): C[T] = + readArray(in, default, factory.newBuilder, inner) + def encodeValue(x: C[T], out: JsonWriter): Unit = writeArray(x, out, inner) + } + + def array[T: ClassTag](inner: JsonValueCodec[T]): JsonValueCodec[Array[T]] = new JsonValueCodec[Array[T]] { + private val empty: Array[T] = Array.empty[T] + def nullValue: Array[T] = empty + def decodeValue(in: JsonReader, default: Array[T]): Array[T] = readArray(in, default, Array.newBuilder[T], inner) + def encodeValue(x: Array[T], out: JsonWriter): Unit = { + out.writeArrayStart() + var i = 0 + while (i < x.length) { + inner.encodeValue(x(i), out) + i += 1 + } + out.writeArrayEnd() + } + } + + /** Keys go through `keyToString` / `stringToKey`, values through their codec — the same two functions the schema side + * (`Schema.schemaForMap(keyToString)`) documents. + */ + def map[K, V](keyToString: K => String, stringToKey: String => K, values: JsonValueCodec[V]): JsonValueCodec[Map[K, V]] = + new JsonValueCodec[Map[K, V]] { + def nullValue: Map[K, V] = Map.empty + def decodeValue(in: JsonReader, default: Map[K, V]): Map[K, V] = + if (in.isNextToken('{')) { + if (in.isNextToken('}')) Map.empty + else { + in.rollbackToken() + val builder = Map.newBuilder[K, V] + while ({ + val rawKey = in.readKeyAsString() + // A failing key parser is a malformed document, reported with jsoniter's position information like any + // other decoding error (and hence as a `JsonReaderException`, which `toTapirCodec` turns into a + // `JsonDecodeException` with a message). + val key = + try stringToKey(rawKey) + catch { + case NonFatal(e) => in.decodeError(s"illegal map key '$rawKey': ${Option(e.getMessage).getOrElse(e.getClass.getName)}") + } + builder += key -> values.decodeValue(in, values.nullValue) + in.isNextToken(',') + }) () + if (in.isCurrentToken('}')) builder.result() else in.objectEndOrCommaError() + } + } else in.readNullOrTokenError(default, '{') + def encodeValue(x: Map[K, V], out: JsonWriter): Unit = { + out.writeObjectStart() + x.foreach { case (k, v) => + out.writeKey(keyToString(k)) + values.encodeValue(v, out) + } + out.writeObjectEnd() + } + } + + /** Untagged: a `Left` is written as the bare left value, a `Right` as the bare right value. Decoding tries the right codec first and + * falls back to the left one — the convention of tapir core's `Codec.eitherRight`, and the only encoding that agrees with core's + * `Schema.schemaForEither` (a coproduct with no discriminator). + * + * The value is read as raw bytes and re-parsed rather than decoded with `setMark`/`rollbackToMark`, because jsoniter does not allow + * marks to nest and a generated coproduct codec uses one itself (`requireDiscriminatorFirst(false)`). + * + * Any non-fatal failure of the right codec (not only a `JsonReaderException`: a user codec may throw anything) falls back to the left + * one. If both fail, the *right* failure is reported, since `Right` is the side the encoding is expected to be. + */ + def either[A, B](left: JsonValueCodec[A], right: JsonValueCodec[B]): JsonValueCodec[Either[A, B]] = + new JsonValueCodec[Either[A, B]] { + def nullValue: Either[A, B] = null + def decodeValue(in: JsonReader, default: Either[A, B]): Either[A, B] = { + val raw = in.readRawValAsBytes() + try Right(readFromArrayReentrant(raw)(right)) + catch { + case NonFatal(rightFailure) => + try Left(readFromArrayReentrant(raw)(left)) + catch { case NonFatal(_) => throw rightFailure } + } + } + def encodeValue(x: Either[A, B], out: JsonWriter): Unit = x match { + case Left(a) => left.encodeValue(a, out) + case Right(b) => right.encodeValue(b, out) + } + } + + /** A bare string per value, chosen by `encode`; decoding uses the reverse mapping, built once. */ + def stringEnum[T](values: List[T], encode: T => String): JsonValueCodec[T] = { + val encoded: Map[T, String] = values.map(v => v -> encode(v)).toMap + val decoded: Map[String, T] = encoded.map(_.swap) + if (decoded.size != encoded.size) { + val duplicates = encoded.groupMap(_._2)(_._1).collect { case (s, vs) if vs.size > 1 => s"'$s' <- ${vs.mkString(", ")}" } + throw new IllegalArgumentException( + s"Enumeration encoding is not injective, several values share a string: ${duplicates.mkString("; ")}" + ) + } + new JsonValueCodec[T] { + def nullValue: T = null.asInstanceOf[T] + def decodeValue(in: JsonReader, default: T): T = { + val s = in.readString(null) + if (s eq null) default + else decoded.getOrElse(s, in.enumValueError(s)) + } + def encodeValue(x: T, out: JsonWriter): Unit = out.writeVal(encoded(x)) + } + } + + // -- shared token handling, mirroring JsonCodecMaker's generated code for collections -------------------------- + + private def readArray[T, C]( + in: JsonReader, + default: C, + builder: scala.collection.mutable.Builder[T, C], + inner: JsonValueCodec[T] + ): C = + if (in.isNextToken('[')) { + if (in.isNextToken(']')) builder.result() + else { + in.rollbackToken() + while ({ + builder += inner.decodeValue(in, inner.nullValue) + in.isNextToken(',') + }) () + if (in.isCurrentToken(']')) builder.result() else in.arrayEndOrCommaError() + } + } else in.readNullOrTokenError(default, '[') + + private def writeArray[T](xs: Iterable[T], out: JsonWriter, inner: JsonValueCodec[T]): Unit = { + out.writeArrayStart() + xs.foreach(inner.encodeValue(_, out)) + out.writeArrayEnd() + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/LeafCodecs.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/LeafCodecs.scala new file mode 100644 index 0000000000..dc89c6b1f2 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/LeafCodecs.scala @@ -0,0 +1,31 @@ +package sttp.tapir.json.pickler.internal.runtime + +import com.github.plokhotnyuk.jsoniter_scala.core.{JsonReader, JsonValueCodec, JsonWriter} + +import java.math.{BigDecimal => JBigDecimal, BigInteger => JBigInteger} + +/** Codecs for leaf types that tapir has a `Schema` for but `JsonCodecMaker` does not derive on its own. + * + * The derivation macro puts these into scope (as `implicit lazy val`s in the generated block) whenever the type graph contains one of the + * types, so that jsoniter picks them up through its ordinary implicit lookup. + */ +object LeafCodecs { + + val javaBigDecimal: JsonValueCodec[JBigDecimal] = new JsonValueCodec[JBigDecimal] { + def nullValue: JBigDecimal = null + def decodeValue(in: JsonReader, default: JBigDecimal): JBigDecimal = { + val d = in.readBigDecimal(null) + if (d eq null) default else d.bigDecimal + } + def encodeValue(x: JBigDecimal, out: JsonWriter): Unit = out.writeVal(BigDecimal(x)) + } + + val javaBigInteger: JsonValueCodec[JBigInteger] = new JsonValueCodec[JBigInteger] { + def nullValue: JBigInteger = null + def decodeValue(in: JsonReader, default: JBigInteger): JBigInteger = { + val i = in.readBigInt(null) + if (i eq null) default else i.bigInteger + } + def encodeValue(x: JBigInteger, out: JsonWriter): Unit = out.writeVal(BigInt(x)) + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/PicklerFactories.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/PicklerFactories.scala new file mode 100644 index 0000000000..01348c6371 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/PicklerFactories.scala @@ -0,0 +1,24 @@ +package sttp.tapir.json.pickler.internal.runtime + +import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec +import sttp.tapir.Schema +import sttp.tapir.json.pickler.{CreateDerivedEnumerationPickler, Pickler} + +/** Factory for the final [[Pickler]] instance emitted by the derivation macro. + * + * The macro emits a call to [[instance]] rather than expanding a `new Pickler[A] { ... }` literal at each derivation site: every anonymous + * class expansion would produce a separate `.class` file per derived type, whereas the one below is defined once and shared by every + * derived pickler. (The `JsonValueCodec` itself is an anonymous class generated by `JsonCodecMaker`, so that cost is paid once per type + * either way.) + */ +object PicklerFactories { + + def instance[A](schemaValue: Schema[A], codecValue: JsonValueCodec[A]): Pickler[A] = new Pickler[A] { + val schema: Schema[A] = schemaValue + val codec: JsonValueCodec[A] = codecValue + } + + /** Emitted by `Pickler.derivedEnumeration`: the singleton values plus the pickler `Pickler.derived` would produce. */ + def enumerationBuilder[A](values: List[A], schema: Schema[A], codec: JsonValueCodec[A]): CreateDerivedEnumerationPickler[A] = + new CreateDerivedEnumerationPickler[A](values, schema, codec) +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/PicklerUtils.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/PicklerUtils.scala new file mode 100644 index 0000000000..ebaa6d9fe5 --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/PicklerUtils.scala @@ -0,0 +1,35 @@ +package sttp.tapir.json.pickler.internal.runtime + +import com.github.plokhotnyuk.jsoniter_scala.core.{readFromString, writeToString, JsonReaderException, JsonValueCodec, ReaderConfig} +import sttp.tapir.Codec.JsonCodec +import sttp.tapir.DecodeResult.Error.{JsonDecodeException, JsonError} +import sttp.tapir.DecodeResult.{Error, Value} +import sttp.tapir.{Codec, Schema} + +import scala.util.{Failure, Success, Try} + +/** Runtime helpers invoked by macro-generated code, plus the bridge from a jsoniter-scala codec to a tapir codec. + * + * Everything here must be public (the generated code lives in user compilation units) and must not depend on any macro machinery. + */ +object PicklerUtils { + + private val readerConfig = ReaderConfig.withAppendHexDumpToParseException(false) + + /** Mirrors `sttp.tapir.json.jsoniter.TapirJsonJsoniter.jsoniterCodec`, kept here so that this module does not have to depend on + * `tapir-jsoniter-scala`. + */ + def toTapirCodec[A](codec: JsonValueCodec[A], schema: Schema[A]): JsonCodec[A] = { + given JsonValueCodec[A] = codec + given Schema[A] = schema + Codec.json[A] { s => + Try(readFromString[A](s, readerConfig)) match { + case Success(v) => Value(v) + case Failure(error: JsonReaderException) => + val errMsg = Option(error.getMessage) + Error(s, JsonDecodeException(errors = errMsg.toList.map(e => JsonError(e, Nil)), error)) + case Failure(error) => Error(s, JsonDecodeException(errors = List.empty, error)) + } + } { a => writeToString[A](a) } + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/SchemaUtils.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/SchemaUtils.scala new file mode 100644 index 0000000000..9bd3c3b23e --- /dev/null +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/internal/runtime/SchemaUtils.scala @@ -0,0 +1,164 @@ +package sttp.tapir.json.pickler.internal.runtime + +import sttp.tapir.Schema.SName +import sttp.tapir.SchemaType.{SArray, SCoproduct, SDiscriminator, SOpenProduct, SProduct, SProductField, SRef, SString, SchemaWithValue} +import sttp.tapir.{FieldName, Schema, Validator} + +/** Runtime constructors for [[Schema]] values, invoked by macro-generated code. + * + * Everything here must be public (generated code lives in user compilation units) and free of macro machinery. The split matters for + * compile times as much as for readability: every bit of logic expressed here is logic the macro does not have to reify into a tree. + * + * The shapes produced here are pinned by `SchemaDerivationTest`. + */ +object SchemaUtils { + + /** Typed empty list, so that cross-quotes can build field lists with `::` without inferring `Nothing`. */ + def emptyFieldList[T]: List[SProductField[T]] = Nil + + // -- Annotations ------------------------------------------------------------------------------------------------ + + /** Fold tapir's `Schema.annotations.*` onto a schema, ignoring every other annotation silently. + * + * `@encodedName` is deliberately absent: the macro consumes it while computing the [[SName]] of a type and the encoded name of a field. + */ + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) + def enrichSchema[T](schema: Schema[T], annotations: List[Any]): Schema[T] = + annotations.foldLeft(schema) { + case (s, ann: Schema.annotations.description) => s.description(ann.text) + case (s, ann: Schema.annotations.encodedExample) => s.encodedExample(ann.example) + case (s, ann: Schema.annotations.default[T @unchecked]) => s.default(ann.default, ann.encoded) + case (s, ann: Schema.annotations.validate[T @unchecked]) => s.validate(ann.v) + case (s, ann: Schema.annotations.validateEach[T @unchecked]) => + s.modifyUnsafe[T](Schema.ModifyCollectionElements)((_: Schema[T]).validate(ann.v)) + case (s, ann: Schema.annotations.format) => s.format(ann.format) + case (s, ann: Schema.annotations.title) => s.title(ann.name) + case (s, _: Schema.annotations.deprecated) => s.deprecated(true) + case (s, _: Schema.annotations.hidden) => s.hidden(true) + case (s, ann: Schema.annotations.customise) => ann.f(s).asInstanceOf[Schema[T]] + case (s, _) => s + } + + // -- Products --------------------------------------------------------------------------------------------------- + + /** Build one product field. `encodedName` is the JSON name the macro computed (`@encodedName`, or the configured transformation). + * + * `index` is the position of the parameter in the primary constructor, which for a case class is also its `Product` element index. + * `SProductField` compares by name and schema only, so the accessor does not affect the test assertions — but it does drive + * `Schema.applyValidation`, so it has to be right. + */ + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) + def productField[T]( + scalaName: String, + encodedName: String, + fieldSchema: Schema[Any], + index: Int, + annotations: List[Any] + ): SProductField[T] = + SProductField[T, Any]( + FieldName(scalaName, encodedName), + enrichSchema(fieldSchema, annotations), + t => Some(t.asInstanceOf[Product].productElement(index)) + ) + + def productSchema[T](name: SName, fields: List[SProductField[T]]): Schema[T] = + Schema[T](SProduct[T](fields), Some(name)) + + // -- Coproducts ------------------------------------------------------------------------------------------------- + + /** Build a coproduct schema over `(child schema, discriminator value)` pairs, injecting the discriminator field into every child product. + * The values are the ones the macro computed — and the ones the codec writes — so this is what the schema has to document. + * + * `subtypeIndex` maps a value to the position of its leaf in `subtypesWithValues` (`-1` for none). It is generated by the macro as a + * type-test `match`, because it drives `Schema.applyValidation`: a runtime class-name comparison could not tell parameterless Scala 3 + * enum cases apart, nor cope with the `$`-mangled names of nested classes. + * + * Three details are pinned by `SchemaDerivationTest` and easy to get wrong: + * 1. the discriminator field is appended **after** the declared fields, not prepended; + * 2. its schema is a bare `Schema(SString())` carrying only the `EncodedDiscriminatorValue` attribute — adding a + * `Validator.enumeration` would change `Schema` equality and fail the assertions; + * 3. the attribute goes on the **field's** schema, not on the child schema itself. + */ + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) + def coproductSchema[T]( + name: SName, + subtypesWithValues: List[(Schema[Any], String)], + discriminatorField: String, + subtypeIndex: T => Int + ): Schema[T] = { + val withDiscriminator: List[(Schema[Any], String)] = subtypesWithValues.map { case (child, value) => + val enriched = child.schemaType match { + case p: SProduct[Any @unchecked] if !p.fields.exists(_.name.encodedName == discriminatorField) => + val field = SProductField[Any, String]( + FieldName(discriminatorField, discriminatorField), + Schema(SString[String]()).encodedDiscriminatorValue(value), + _ => Some(value) + ) + child.copy(schemaType = p.copy(fields = p.fields :+ field)) + case _ => child + } + enriched -> value + } + + val mapping: Map[String, SRef[?]] = withDiscriminator.flatMap { case (child, value) => + child.name.map(childName => value -> SRef[Any](childName)) + }.toMap + + val enrichedSubtypes = withDiscriminator.map(_._1).toVector + + Schema[T]( + SCoproduct[T](enrichedSubtypes.toList, Some(SDiscriminator(FieldName(discriminatorField, discriminatorField), mapping))) { + (value: T) => enrichedSubtypes.lift(subtypeIndex(value)).map(s => SchemaWithValue(s, value)) + }, + Some(name) + ) + } + + /** Reference to a schema being derived further up the stack — how recursion terminates. */ + def refSchema[T](name: SName): Schema[T] = Schema[T](SRef[T](name)) + + // -- Wrappers --------------------------------------------------------------------------------------------------- + + // These return `Schema[Any]` because `Schema` is invariant and the macro needs to build homogeneous lists; the + // generated code casts back at the use site. + + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) + def optionSchema[E](elementSchema: Schema[E]): Schema[Any] = + elementSchema.asOption.asInstanceOf[Schema[Any]] + + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) + def collectionSchema[E](elementSchema: Schema[E]): Schema[Any] = + Schema[Any](SArray[Any, E](elementSchema)(_.asInstanceOf[Iterable[E]]), isOptional = true) + + /** Mirrors what `Schema.schemaForMap` generates, but with our own derived value schema. + * + * `typeParameters` is computed by the macro with core's own `SNameMacros`: the key type is omitted (it is `String`), the value's name + * comes first and its type arguments are flattened after it. + */ + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) + def mapSchema[V](valueSchema: Schema[V], typeParameters: List[String]): Schema[Any] = + Schema[Any]( + SOpenProduct[Any, V](Nil, valueSchema)(_.asInstanceOf[Map[String, V]]), + Some(SName("Map", typeParameters)) + ) + + /** tapir core's own `Either` schema — an untagged coproduct of the two sides — over our derived side schemas. The codec + * (`CodecCombinators.either`) writes the bare side value, which is what this schema documents. + */ + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) + def eitherSchema[L, R](left: Schema[L], right: Schema[R]): Schema[Any] = + Schema.schemaForEither(left, right).asInstanceOf[Schema[Any]] + + /** A bare `SString` schema, for a type that tapir core has no schema for but that the codec writes as a string. `Char` is the only such + * type today. + */ + def stringLikeSchema[T]: Schema[T] = Schema(SString[T]()) + + /** A string-valued schema whose validator enumerates the singleton values of an enum-like hierarchy. The validator carries the name too, + * as core's `Validator.derivedEnumeration` does: the OpenAPI interpreter uses it to emit a named component for the enumeration. + */ + def stringEnumSchema[T](name: SName, values: List[T], encodedNames: List[String]): Schema[T] = { + val encoded = values.zip(encodedNames).toMap + Schema.string[T].name(name).copy(validator = Validator.enumeration(values, (v: T) => encoded.get(v), Some(name))) + } +} diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/macros.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/macros.scala deleted file mode 100644 index 744857ee1d..0000000000 --- a/json/pickler/src/main/scala/sttp/tapir/json/pickler/macros.scala +++ /dev/null @@ -1,111 +0,0 @@ -package sttp.tapir.json.pickler - -import _root_.upickle.implicits.* -import _root_.upickle.implicits.{macros => uMacros} -import sttp.tapir.SchemaType -import sttp.tapir.SchemaType.SProduct - -import scala.quoted.* - -/** Macros, mostly copied from uPickle, and modified to allow our customizations like passing writers/readers as parameters, adjusting - * encoding/decoding logic to make it coherent with the schema. - */ -private[pickler] object macros: - type IsInt[A <: Int] = A - - private[pickler] inline def writeSnippets[R, T]( - inline sProduct: SProduct[T], - inline thisOuter: upickle.core.Types with upickle.implicits.MacrosCommon, - inline self: upickle.implicits.CaseClassReadWriters#CaseClassWriter[T], - inline v: T, - inline ctx: _root_.upickle.core.ObjVisitor[_, R], - childWriters: List[Any], - childDefaults: List[Option[Any]], - transientNone: Boolean - ): Unit = - ${ writeSnippetsImpl[R, T]('sProduct, 'thisOuter, 'self, 'v, 'ctx, 'childWriters, 'childDefaults, 'transientNone) } - - private[pickler] def writeSnippetsImpl[R, T]( - sProduct: Expr[SProduct[T]], - thisOuter: Expr[upickle.core.Types with upickle.implicits.MacrosCommon], - self: Expr[upickle.implicits.CaseClassReadWriters#CaseClassWriter[T]], - v: Expr[T], - ctx: Expr[_root_.upickle.core.ObjVisitor[_, R]], - childWriters: Expr[List[?]], - childDefaults: Expr[List[Option[?]]], - transientNone: Expr[Boolean] - )(using Quotes, Type[T], Type[R]): Expr[Unit] = - - import quotes.reflect.* - val optionSymbol = TypeRepr.of[Option[_]].typeSymbol - Expr.block( - for (((rawLabel, label), i) <- uMacros.fieldLabelsImpl0[T].zipWithIndex) yield { - val memberTypeRepr = TypeRepr.of[T].memberType(rawLabel) - val tpe0 = memberTypeRepr.asType - tpe0 match - case '[tpe] => - Literal(IntConstant(i)).tpe.asType match - case '[IsInt[index]] => - val encodedName = '{ ${ sProduct }.fields(${ Expr(i) }).name.encodedName } - val select = Select.unique(v.asTerm, rawLabel.name).asExprOf[Any] - val snippet = '{ - ${ self }.writeSnippetMappedName[R, tpe]( - ${ ctx }, - ${ encodedName }, - ${ childWriters }(${ Expr(i) }), - ${ select } - ) - } - if memberTypeRepr.typeSymbol == optionSymbol then '{ if ! ${ transientNone } || ${ select } != None then $snippet } - else snippet - }, - '{ () } - ) - - private[pickler] inline def storeDefaultsTapir[T]( - inline x: upickle.implicits.BaseCaseObjectContext, - defaultsFromSchema: List[Option[Any]] - ): Unit = ${ - storeDefaultsImpl[T]('x, 'defaultsFromSchema) - } - - private[pickler] def storeDefaultsImpl[T](x: Expr[upickle.implicits.BaseCaseObjectContext], defaultsFromSchema: Expr[List[Option[Any]]])( - using - Quotes, - Type[T] - ) = { - import quotes.reflect.* - - val optionSymbol = TypeRepr.of[Option[_]].typeSymbol - val defaults = uMacros.getDefaultParamsImpl0[T] - val members = TypeRepr.of[T].typeSymbol.caseFields - val statements = uMacros - .fieldLabelsImpl0[T] - .zipWithIndex - .map { case ((rawLabel, label), i) => - Expr.block( - List('{ - // modified uPickle macro - this additional expression looks for defaults in the schema - // and applies them to override defaults from the type definition - ${ defaultsFromSchema }(${ Expr(i) }).foreach { schemaDefaultValue => - ${ x }.storeValueIfNotFound(${ Expr(i) }, schemaDefaultValue) - } - }), - if (defaults.contains(label)) '{ ${ x }.storeValueIfNotFound(${ Expr(i) }, ${ defaults(label) }) } - else { - members - .find(_.name == label) - .collect { case member => - member.tree match { - case v: ValDef if v.tpt.tpe.typeSymbol == optionSymbol => - '{ ${ x }.storeValueIfNotFound(${ Expr(i) }, None) } - case _ => '{} - } - } - .getOrElse('{}) - } - ) - } - - Expr.block(statements, '{}) - } diff --git a/json/pickler/src/main/scala/sttp/tapir/json/pickler/package.scala b/json/pickler/src/main/scala/sttp/tapir/json/pickler/package.scala index 5ec48fc710..8530cca209 100644 --- a/json/pickler/src/main/scala/sttp/tapir/json/pickler/package.scala +++ b/json/pickler/src/main/scala/sttp/tapir/json/pickler/package.scala @@ -1,6 +1,12 @@ package sttp.tapir.json.pickler -import sttp.tapir._ +import sttp.tapir.* +import sttp.tapir.Codec.JsonCodec + +/** The bridge that makes a [[Pickler]] usable wherever tapir wants a JSON codec: with it in scope, `jsonBody[T]` from `sttp.tapir` resolves + * for any `T` that has a `Pickler`. + */ +given picklerToCodec[T](using p: Pickler[T]): JsonCodec[T] = p.toCodec def jsonBody[T: Pickler]: EndpointIO.Body[String, T] = stringBodyUtf8AnyFormat(summon[Pickler[T]].toCodec) diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/CodecDerivationTest.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/CodecDerivationTest.scala new file mode 100644 index 0000000000..94f4229eba --- /dev/null +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/CodecDerivationTest.scala @@ -0,0 +1,587 @@ +package sttp.tapir.json.pickler + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.tapir.DecodeResult.Value +import sttp.tapir.Schema.annotations.{default, encodedName} +import sttp.tapir.SchemaType.{SCoproduct, SProduct, SString} +import sttp.tapir.{DecodeResult, Schema, Validator} + +import java.util.UUID + +/** The codec half of a derived `Pickler`: the wire format for products, coproducts, enumerations, recursive types and non-structural roots, + * plus the interaction with user-supplied instances and the agreement between the codec and the schema. + */ +class CodecDerivationTest extends AnyFlatSpec with Matchers { + import CodecFixtures.* + + private def roundTrip[T](pickler: Pickler[T], value: T, expectedJson: String): Unit = { + val codec = pickler.toCodec + codec.encode(value) shouldBe expectedJson + val _ = codec.decode(expectedJson) shouldBe Value(value) + } + + behavior of "codec derivation for products" + + it should "encode and decode a flat case class" in { + roundTrip(Pickler.derived[FlatClass], FlatClass(44, "b_value"), """{"fieldA":44,"fieldB":"b_value"}""") + } + + it should "encode and decode nested case classes" in { + roundTrip( + Pickler.derived[TopClass], + TopClass("field_a_value", InnerClass(7954)), + """{"fieldA":"field_a_value","fieldB":{"fieldA11":7954}}""" + ) + } + + it should "apply a member-name transformation given as a local given" in { + // note `fieldA11 -> field_a11`, not `field_a_11` + given config: PicklerConfiguration = PicklerConfiguration.default.withSnakeCaseMemberNames + roundTrip( + Pickler.derived[TopClass], + TopClass("field_a_value", InnerClass(7954)), + """{"field_a":"field_a_value","field_b":{"field_a11":7954}}""" + ) + } + + it should "apply a member-name transformation given as an arbitrary evaluable function" in { + given PicklerConfiguration = PicklerConfiguration.default.withToEncodedName(_.toUpperCase()) + roundTrip(Pickler.derived[FlatClass], FlatClass(1, "x"), """{"FIELDA":1,"FIELDB":"x"}""") + } + + it should "honour @encodedName on a field, which beats the configured transformation" in { + given PicklerConfiguration = PicklerConfiguration.default.withSnakeCaseMemberNames + roundTrip( + Pickler.derived[TopClass2], + TopClass2("field_a_value", AnnotatedInnerClass("f-a-value", "f-b-value")), + """{"field_a":"field_a_value","field_b":{"encoded_field-a":"f-a-value","field_b":"f-b-value"}}""" + ) + } + + it should "keep field renames local to their class (design: one JsonCodecMaker.make per class)" in { + // `Outer.name` and `Inner.name` share a Scala name but not an encoding. A single graph-wide + // `fieldNameMapper` could not express this; per-class configurations can. + roundTrip( + Pickler.derived[SameFieldNameOuter], + SameFieldNameOuter("o", SameFieldNameInner("i")), + """{"name":"o","inner":{"inner_name":"i"}}""" + ) + } + + it should "encode and decode Options, omitting None by default" in { + val flat = Pickler.derived[FlatClassWithOption].toCodec + val nested = Pickler.derived[NestedClassWithOption].toCodec + flat.encode(FlatClassWithOption("fieldA value", Some(-4018), true)) shouldBe + """{"fieldA":"fieldA value","fieldB":-4018,"fieldC":true}""" + nested.encode(NestedClassWithOption(Some(FlatClassWithOption("fieldA value2", None, true)))) shouldBe + """{"innerField":{"fieldA":"fieldA value2","fieldC":true}}""" + flat.encode(FlatClassWithOption("fieldA value", None, true)) shouldBe """{"fieldA":"fieldA value","fieldC":true}""" + flat.decode("""{"fieldA":"x","fieldC":false}""") shouldBe Value(FlatClassWithOption("x", None, false)) + flat.decode("""{"fieldA":"x","fieldB":null,"fieldC":false}""") shouldBe Value(FlatClassWithOption("x", None, false)) + } + + it should "encode None as null when transientNone is off" in { + given PicklerConfiguration = PicklerConfiguration.default.withTransientNone(false) + Pickler.derived[FlatClassWithOption].toCodec.encode(FlatClassWithOption("fieldA value2", None, true)) shouldBe + """{"fieldA":"fieldA value2","fieldB":null,"fieldC":true}""" + } + + it should "encode empty collections as [] and read a missing collection as empty" in { + val flat = Pickler.derived[FlatClassWithList].toCodec + val nested = Pickler.derived[NestedClassWithList].toCodec + flat.encode(FlatClassWithList("fieldA value", List(64, -5))) shouldBe """{"fieldA":"fieldA value","fieldB":[64,-5]}""" + nested.encode(NestedClassWithList(List(FlatClassWithList("a2", Nil), FlatClassWithList("a3", List(8, 9))))) shouldBe + """{"innerField":[{"fieldA":"a2","fieldB":[]},{"fieldA":"a3","fieldB":[8,9]}]}""" + flat.decode("""{"fieldA":"a"}""") shouldBe Value(FlatClassWithList("a", Nil)) + } + + it should "encode and decode a Map with String keys" in { + // insertion order is preserved by an immutable Map with <= 4 entries + roundTrip( + Pickler.derived[ClassWithMap], + ClassWithMap(Map("keyB" -> SimpleTestResult("result1"), "keyA" -> SimpleTestResult("result2"))), + """{"field":{"keyB":{"msg":"result1"},"keyA":{"msg":"result2"}}}""" + ) + } + + it should "unwrap AnyVal value classes, in the codec and in the schema alike" in { + val pickler = Pickler.derived[ClassWithValues] + val id = UUID.fromString("550e8400-e29b-41d4-a716-446655440000") + roundTrip( + pickler, + ClassWithValues(UserId(id), UserName("Alan"), 65), + """{"id":"550e8400-e29b-41d4-a716-446655440000","name":"Alan","age":65}""" + ) + val fields = pickler.schema.schemaType.asInstanceOf[SProduct[ClassWithValues]].fields + fields.find(_.name.name == "name").get.schema.schemaType shouldBe SString() + } + + it should "honour Scala default parameters when decoding, and still write fields equal to them" in { + val codec = Pickler.derived[ClassWithScalaDefault].toCodec + codec.encode(ClassWithScalaDefault("field-a-user-value", "msg104")) shouldBe """{"fieldA":"field-a-user-value","fieldB":"msg104"}""" + codec.encode(ClassWithScalaDefault("field-a-default", "text b")) shouldBe """{"fieldA":"field-a-default","fieldB":"text b"}""" + codec.decode("""{"fieldB":"msg205"}""") shouldBe Value(ClassWithScalaDefault("field-a-default", "msg205")) + } + + it should "NOT fill a missing field from tapir's @default annotation (documented limitation)" in { + // jsoniter has no hook for anything but Scala default parameters, so a missing field with only a tapir @default + // is a required-field error. The annotation is still reflected in the schema. + val pickler = Pickler.derived[ClassWithDefault] + pickler.toCodec.decode("""{"fieldB":"x"}""") shouldBe a[DecodeResult.Error] + pickler.schema.schemaType.asInstanceOf[SProduct[ClassWithDefault]].fields.head.schema.default.map(_._1) shouldBe + Some("field-a-default") + } + + it should "report an unknown field as skipped rather than failing" in { + Pickler.derived[FlatClass].toCodec.decode("""{"fieldA":1,"fieldB":"x","extra":true}""") shouldBe Value(FlatClass(1, "x")) + } + + behavior of "codec derivation for coproducts" + + it should "handle a mixed sealed hierarchy with the default $type discriminator" in { + val codec = Pickler.derived[MyCaseClass].toCodec + codec.encode(MyCaseClass(ErrorTimeout, "msg18")) shouldBe """{"fieldA":{"$type":"ErrorTimeout"},"fieldB":"msg18"}""" + codec.encode(MyCaseClass(CustomError("customErrMsg"), "msg18")) shouldBe + """{"fieldA":{"$type":"CustomError","msg":"customErrMsg"},"fieldB":"msg18"}""" + codec.decode("""{"fieldA":{"$type":"CustomError","msg":"customErrMsg"},"fieldB":"msg18"}""") shouldBe + Value(MyCaseClass(CustomError("customErrMsg"), "msg18")) + codec.decode("""{"fieldA":{"$type":"ErrorTimeout"},"fieldB":"msg18"}""") shouldBe Value(MyCaseClass(ErrorTimeout, "msg18")) + } + + it should "apply the member-name transformation to leaf fields too" in { + given PicklerConfiguration = PicklerConfiguration.default.withToEncodedName(_.toUpperCase()) + val codec = Pickler.derived[MyCaseClass].toCodec + codec.encode(MyCaseClass(ErrorTimeout, "msg18")) shouldBe """{"FIELDA":{"$type":"ErrorTimeout"},"FIELDB":"msg18"}""" + codec.encode(MyCaseClass(CustomError("customErrMsg"), "msg18")) shouldBe + """{"FIELDA":{"$type":"CustomError","MSG":"customErrMsg"},"FIELDB":"msg18"}""" + } + + it should "apply a custom discriminator name" in { + given PicklerConfiguration = PicklerConfiguration.default.withDiscriminator("kind") + val codec = Pickler.derived[MyCaseClass].toCodec + roundTrip( + Pickler.derived[MyCaseClass], + MyCaseClass(CustomError("customErrMsg2"), "msg19"), + """{"fieldA":{"kind":"CustomError","msg":"customErrMsg2"},"fieldB":"msg19"}""" + ) + codec.encode(MyCaseClass(ErrorNotFound, "")) shouldBe """{"fieldA":{"kind":"ErrorNotFound"},"fieldB":""}""" + } + + it should "accept the discriminator anywhere in the object" in { + Pickler.derived[MyCaseClass].toCodec.decode("""{"fieldA":{"msg":"late","$type":"CustomError"},"fieldB":"b"}""") shouldBe + Value(MyCaseClass(CustomError("late"), "b")) + } + + it should "use full kebab-case discriminator values" in { + given PicklerConfiguration = PicklerConfiguration.default.withFullKebabCaseDiscriminatorValues + roundTrip( + Pickler.derived[StatusResponse], + StatusResponse(StatusBadRequest(65)), + """{"status":{"$type":"sttp.tapir.json.pickler.codec-fixtures.status-bad-request","bF":65}}""" + ) + } + + it should "use short snake-case discriminator values" in { + given PicklerConfiguration = PicklerConfiguration.default.withSnakeCaseDiscriminatorValues + roundTrip(Pickler.derived[StatusResponse], StatusResponse(StatusBadRequest(1)), """{"status":{"$type":"status_bad_request","bF":1}}""") + roundTrip(Pickler.derived[StatusResponse], StatusResponse(StatusInternalError), """{"status":{"$type":"status_internal_error"}}""") + } + + it should "encode a leaf on its own with its discriminator" in { + // `alwaysEmitDiscriminator`: a member of a sealed hierarchy carries its tag whenever it is written, not only + // through the parent, so that the two encodings agree. + roundTrip(Pickler.derived[StatusBadRequest], StatusBadRequest(7), """{"$type":"StatusBadRequest","bF":7}""") + Pickler.derived[StatusBadRequest].toCodec.decode("""{"bF":7}""") shouldBe Value(StatusBadRequest(7)) + } + + it should "encode an all-singleton sealed trait as a bare string" in { + roundTrip(Pickler.derived[SealedVariantContainer], SealedVariantContainer(VariantA), """{"v":"VariantA"}""") + } + + it should "encode a Scala 3 enum as a bare string, dropping parameters" in { + roundTrip(Pickler.derived[Response], Response(ColorEnum.Pink, "pink!!"), """{"color":"Pink","description":"pink!!"}""") + roundTrip(Pickler.derived[RichColorResponse], RichColorResponse(RichColorEnum.Cyan), """{"color":"Cyan"}""") + // no alphabetical reordering of cases + Pickler.derived[NotAlphabetical].toCodec.encode(NotAlphabetical.Xyz) shouldBe "\"Xyz\"" + } + + it should "encode a Scala 3 enum with case-class cases as discriminated objects" in { + roundTrip(Pickler.derived[Entity], Entity.Business("221B Baker Street"), """{"$type":"Business","address":"221B Baker Street"}""") + } + + it should "treat a hierarchy mixing objects and case classes as discriminated objects" in { + val codec = Pickler.derived[NotAllSealedVariant].toCodec + codec.encode(NotAllSealedVariantA) shouldBe """{"$type":"NotAllSealedVariantA"}""" + codec.encode(NotAllSealedVariantB(3)) shouldBe """{"$type":"NotAllSealedVariantB","innerField":3}""" + } + + it should "flatten nested sealed hierarchies to their leaves" in { + val codec = Pickler.derived[Animal].toCodec + codec.encode(Hamster("h")) shouldBe """{"$type":"Hamster","name":"h"}""" + codec.decode("""{"$type":"Hamster","name":"h"}""") shouldBe Value(Hamster("h")) + } + + behavior of "codec derivation for recursive types" + + it should "handle self-recursion through a collection" in { + roundTrip( + Pickler.derived[Tree], + Tree(1, List(Tree(2, Nil), Tree(3, List(Tree(4, Nil))))), + """{"value":1,"children":[{"value":2,"children":[]},{"value":3,"children":[{"value":4,"children":[]}]}]}""" + ) + } + + it should "handle mutual recursion between two case classes" in { + roundTrip( + Pickler.derived[MutualA], + MutualA(Some(MutualB(Some(MutualA(None, 3)), 2)), 1), + """{"b":{"a":{"id":3},"id":2},"id":1}""" + ) + } + + it should "handle recursion through a sealed hierarchy" in { + roundTrip( + Pickler.derived[Node], + Edge(1, Edge(2, SimpleNode(3))), + """{"$type":"Edge","id":1,"source":{"$type":"Edge","id":2,"source":{"$type":"SimpleNode","id":3}}}""" + ) + } + + behavior of "codec derivation for non-structural roots" + + it should "derive codecs for primitives, Options, collections and Maps at the root" in { + Pickler.derived[Int].toCodec.encode(5) shouldBe "5" + Pickler.derived[String].toCodec.encode("x") shouldBe "\"x\"" + Pickler.derived[Option[Int]].toCodec.encode(Some(1)) shouldBe "1" + Pickler.derived[List[FlatClass]].toCodec.encode(List(FlatClass(1, "a"))) shouldBe """[{"fieldA":1,"fieldB":"a"}]""" + Pickler.derived[Map[String, Int]].toCodec.encode(Map("a" -> 1)) shouldBe """{"a":1}""" + } + + behavior of "user-supplied instances for nested types" + + it should "use a given Pickler for a nested type, for both the schema and the codec" in { + given Pickler[SimpleTestResult] = + Pickler.derived[SimpleTestResult](using PicklerConfiguration.default.withScreamingSnakeCaseMemberNames) + val pickler = Pickler.derived[ClassWithMap] + + pickler.toCodec.encode(ClassWithMap(Map("k" -> SimpleTestResult("r")))) shouldBe """{"field":{"k":{"MSG":"r"}}}""" + val valueSchema = pickler.schema.schemaType + .asInstanceOf[SProduct[ClassWithMap]] + .fields + .head + .schema + .schemaType + .asInstanceOf[sttp.tapir.SchemaType.SOpenProduct[?, SimpleTestResult]] + .valueSchema + valueSchema.schemaType.asInstanceOf[SProduct[SimpleTestResult]].fields.map(_.name.encodedName) shouldBe List("MSG") + } + + it should "still derive structurally when only generic.auto is in scope (the re-entrancy guard)" in { + // With `auto.*` imported, `summon[Pickler[InnerClass]]` inside the macro has a candidate: our own macro. It must + // abort quietly so that the search fails and the type is derived structurally -- not loop, not error. + import sttp.tapir.json.pickler.generic.auto.* + val pickler = summon[Pickler[TopClass]] + pickler.toCodec.encode(TopClass("a", InnerClass(1))) shouldBe """{"fieldA":"a","fieldB":{"fieldA11":1}}""" + + // and the same with a recursive type, where a nested derivation would never terminate + summon[Pickler[Tree]].toCodec.encode(Tree(1, List(Tree(2, Nil)))) shouldBe """{"value":1,"children":[{"value":2,"children":[]}]}""" + } + + it should "refuse a given JsonValueCodec for a nested case class when no Pickler accompanies it" in { + // jsoniter would honour the codec while the schema is still derived from the class -- exactly the drift the + // single-expansion design exists to prevent. The user has to supply a Pickler, which carries both. + assertDoesNotCompile(""" + given com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec[SimpleTestResult] = + new com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec[SimpleTestResult] { + def nullValue: SimpleTestResult = null + def decodeValue(in: com.github.plokhotnyuk.jsoniter_scala.core.JsonReader, default: SimpleTestResult): SimpleTestResult = + SimpleTestResult(in.readString(null)) + def encodeValue(x: SimpleTestResult, out: com.github.plokhotnyuk.jsoniter_scala.core.JsonWriter): Unit = out.writeVal(x.msg) + } + Pickler.derived[ClassWithMap] + """) + } + + it should "refuse a given JsonValueCodec for the root type too" in { + // `JsonCodecMaker.make[A]` never looks its own type up, so such a codec would be silently ignored. + assertDoesNotCompile(""" + given com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec[FlatClass] = null + Pickler.derived[FlatClass] + """) + } + + behavior of "oneOfUsingField" + + it should "set discriminator values using oneOfUsingField" in { + val picklerOk = Pickler.derived[StatusOk] + val picklerBadRequest = Pickler.derived[StatusBadRequest] + val picklerInternalError = Pickler.derived[StatusInternalError.type] + + given statusPickler: Pickler[Status] = Pickler.oneOfUsingField[Status, Int](_.code, codeInt => s"code-$codeInt")( + 200 -> picklerOk, + 400 -> picklerBadRequest, + 500 -> picklerInternalError + ) + val picklerResponse = Pickler.derived[StatusResponse] + + roundTrip(picklerResponse, StatusResponse(StatusBadRequest(54)), """{"status":{"$type":"code-400","bF":54}}""") + roundTrip(picklerResponse, StatusResponse(StatusInternalError), """{"status":{"$type":"code-500"}}""") + + // The schema documents the same values, on the discriminator field the codec actually writes (`$type`, not the + // extractor's `code`, which core's `Schema.oneOfUsingField` would have documented). + val discriminator = statusPickler.schema.schemaType.asInstanceOf[SCoproduct[Status]].discriminator.get + discriminator.name.encodedName shouldBe "$type" + discriminator.mapping.keySet shouldBe Set("code-200", "code-400", "code-500") + statusPickler.schema.schemaType.asInstanceOf[SCoproduct[Status]].subtypes.flatMap(_.name) should contain( + Pickler.derived[StatusOk].schema.name.get + ) + } + + it should "set discriminator values with oneOfUsingField for a deeper hierarchy" in { + sealed trait Status: + def code: Int + sealed trait DeeperStatus extends Status + sealed trait DeeperStatus2 extends Status + case class StatusOk(oF: Int) extends DeeperStatus { + def code = 200 + } + case class StatusBadRequest(bF: Int) extends DeeperStatus2 { + def code = 400 + } + case class Response(status: Status) + val picklerOk = Pickler.derived[StatusOk] + val picklerBadRequest = Pickler.derived[StatusBadRequest] + + given statusPickler: Pickler[Status] = Pickler.oneOfUsingField[Status, Int](_.code, codeInt => s"code-$codeInt")( + 200 -> picklerOk, + 400 -> picklerBadRequest + ) + roundTrip(Pickler.derived[Response], Response(StatusOk(818)), """{"status":{"$type":"code-200","oF":818}}""") + } + + it should "reject oneOfUsingField on a case class" in { + assertDoesNotCompile("""Pickler.oneOfUsingField[FlatClass, Int](_.fieldA, _.toString)(1 -> Pickler.derived[FlatClass])""") + } + + it should "derive the children of oneOfUsingField under the outer configuration, whatever the mapped picklers were derived with" in { + // The mapped picklers only say which leaf a value selects. Had their schemas been taken as-is, the schema would + // document `o_f` while the codec writes `oF`. + val snake = Pickler.derived[StatusOk](using PicklerConfiguration.default.withSnakeCaseMemberNames) + val pickler = Pickler.oneOfUsingField[Status, Int](_.code, code => s"code-$code")( + 200 -> snake, + 400 -> Pickler.derived[StatusBadRequest], + 500 -> Pickler.derived[StatusInternalError.type] + ) + pickler.toCodec.encode(StatusOk(1)) shouldBe """{"$type":"code-200","oF":1}""" + val ok = pickler.schema.schemaType.asInstanceOf[SCoproduct[Status]].subtypes.find(_.name.exists(_.fullName.endsWith("StatusOk"))).get + ok.schemaType.asInstanceOf[SProduct[?]].fields.map(_.name.encodedName) shouldBe List("oF", "$type") + SchemaJsonAgreement.mismatches(pickler.schema, ujson.read(pickler.toCodec.encode(StatusOk(1)))) shouldBe Nil + } + + it should "reject an incomplete or ambiguous oneOfUsingField mapping" in { + scala.compiletime.testing + .typeCheckErrors("""Pickler.oneOfUsingField[Status, Int](_.code, code => s"code-$code")(200 -> Pickler.derived[StatusOk])""") + .map(_.message) + .mkString should include("does not map every case of the hierarchy; missing: ") + scala.compiletime.testing + .typeCheckErrors("""Pickler.oneOfUsingField[Status, Int](_.code, code => s"code-$code")( + 200 -> Pickler.derived[StatusOk], 200 -> Pickler.derived[StatusBadRequest], 500 -> Pickler.derived[StatusInternalError.type])""") + .map(_.message) + .mkString should include("several cases map to 'code-200'") + } + + behavior of "schema/codec agreement" + + it should "document the discriminator values the codec writes" in { + given PicklerConfiguration = PicklerConfiguration.default.withFullKebabCaseDiscriminatorValues + val pickler = Pickler.derived[Status] + val documented = pickler.schema.schemaType.asInstanceOf[SCoproduct[Status]].discriminator.get.mapping.keySet + documented shouldBe Set( + "sttp.tapir.json.pickler.codec-fixtures.status-ok", + "sttp.tapir.json.pickler.codec-fixtures.status-bad-request", + "sttp.tapir.json.pickler.codec-fixtures.status-internal-error" + ) + pickler.toCodec.encode(StatusInternalError) shouldBe + """{"$type":"sttp.tapir.json.pickler.codec-fixtures.status-internal-error"}""" + } + + it should "name every kind of leaf the way jsoniter does (canary for jsoniter upgrades)" in { + // The leaf-name mapper has no fallback: a leaf whose jsoniter name we predicted wrongly makes `JsonCodecMaker` fail + // compilation. Nested case classes/objects, enum cases with and without parameters, and leaves below an + // intermediate trait are all covered here. + given PicklerConfiguration = PicklerConfiguration.default.withFullDiscriminatorValues + val prefix = "sttp.tapir.json.pickler.CodecFixtures" + roundTrip(Pickler.derived[Status], StatusInternalError, s"""{"$$type":"$prefix.StatusInternalError"}""") + roundTrip(Pickler.derived[Status], StatusOk(1), s"""{"$$type":"$prefix.StatusOk","oF":1}""") + roundTrip(Pickler.derived[Entity], Entity.Person("a", 1), s"""{"$$type":"$prefix.Entity.Person","first":"a","age":1}""") + roundTrip(Pickler.derived[NotAllSealedVariant], NotAllSealedVariantA, s"""{"$$type":"$prefix.NotAllSealedVariantA"}""") + roundTrip(Pickler.derived[Animal], Hamster("h"), s"""{"$$type":"$prefix.Hamster","name":"h"}""") + roundTrip(Pickler.derived[RichColorEnum], RichColorEnum.Cyan, "\"Cyan\"") + roundTrip(Pickler.derived[ColorEnum], ColorEnum.Pink, "\"Pink\"") + } + + it should "document an all-singleton hierarchy as a string enumeration, matching the bare-string encoding" in { + val pickler = Pickler.derived[SealedVariant] + pickler.schema.schemaType shouldBe SString() + pickler.schema.validator shouldBe a[Validator.Enumeration[?]] + pickler.schema.validator.asInstanceOf[Validator.Enumeration[SealedVariant]].encode.flatMap(_(VariantB)) shouldBe Some("VariantB") + pickler.toCodec.encode(VariantB) shouldBe "\"VariantB\"" + } + + it should "document field names the codec writes, with @encodedName and the configured transformation" in { + given PicklerConfiguration = PicklerConfiguration.default.withKebabCaseMemberNames + val pickler = Pickler.derived[AnnotatedInnerClass] + val documented = pickler.schema.schemaType.asInstanceOf[SProduct[AnnotatedInnerClass]].fields.map(_.name.encodedName) + documented shouldBe List("encoded_field-a", "field-b") + pickler.toCodec.encode(AnnotatedInnerClass("a", "b")) shouldBe """{"encoded_field-a":"a","field-b":"b"}""" + } + + it should "treat a type alias and the aliased type as one type" in { + // Memoisation, user-pickler lookup and the codec vals are keyed by the *dealiased* type: a `given Pickler[UUID]` + // must apply to a field typed with an alias of it, and a hierarchy referenced both ways gets one codec. + given Pickler[UUID] = Pickler.fromSchemaAndCodec( + Schema.string[UUID], + new com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec[UUID] { + def nullValue: UUID = null + def decodeValue(in: com.github.plokhotnyuk.jsoniter_scala.core.JsonReader, default: UUID): UUID = + UUID.fromString(in.readString(null).stripPrefix("id:")) + def encodeValue(x: UUID, out: com.github.plokhotnyuk.jsoniter_scala.core.JsonWriter): Unit = out.writeVal(s"id:$x") + } + ) + val id = UUID.fromString("550e8400-e29b-41d4-a716-446655440000") + roundTrip( + Pickler.derived[WithAliases], + WithAliases(id, id, List(StatusOk(1)), StatusOk(2)), + s"""{"a":"id:$id","b":"id:$id","statuses":[{"$$type":"StatusOk","oF":1}],"status":{"$$type":"StatusOk","oF":2}}""" + ) + // the schema names the aliased types, not the aliases + Pickler.derived[List[Id]].schema.name shouldBe Pickler.derived[List[UUID]].schema.name + } + + it should "name parameterised types with flattened, fully qualified type arguments" in { + Pickler.derived[Map[String, List[Option[FlatClass]]]].schema.name shouldBe Some( + Schema.SName("Map", List("scala.collection.immutable.List", "scala.Option", "sttp.tapir.json.pickler.CodecFixtures.FlatClass")) + ) + Pickler.derived[Boxed[List[Int]]].schema.name shouldBe Some( + Schema.SName("sttp.tapir.json.pickler.CodecFixtures.Boxed", List("scala.collection.immutable.List", "scala.Int")) + ) + } + + it should "agree with the schema-only entry point" in { + Pickler.schemaFor[Status] shouldBe Pickler.derived[Status].schema + } + + it should "compute every name once, so an arbitrary name function cannot give the schema and the JSON different names" in { + // Both halves splice the same literal, computed at expansion time; neither re-evaluates the function at runtime. + given PicklerConfiguration = + PicklerConfiguration.default + .withToEncodedName(n => n.toUpperCase.concat("_")) + .withDiscriminator("k") + .withFullSnakeCaseDiscriminatorValues + val pickler = Pickler.derived[Status] + val leaf = pickler.schema.schemaType.asInstanceOf[SCoproduct[Status]].subtypes.find(_.name.exists(_.fullName.endsWith("StatusOk"))).get + leaf.schemaType.asInstanceOf[SProduct[?]].fields.map(_.name.encodedName) shouldBe List("OF_", "k") + val discriminator = pickler.schema.schemaType.asInstanceOf[SCoproduct[Status]].discriminator.get + discriminator.name.encodedName shouldBe "k" + pickler.toCodec.encode(StatusOk(1)) shouldBe """{"k":"sttp.tapir.json.pickler.codec_fixtures.status_ok","OF_":1}""" + discriminator.mapping.keySet should contain("sttp.tapir.json.pickler.codec_fixtures.status_ok") + Pickler.schemaFor[Status] shouldBe pickler.schema + } + + it should "explain a name function that cannot be evaluated at compile time" in { + // `.reverse` goes through the `StringOps` implicit conversion, which Hearth's evaluator cannot interpret. + scala.compiletime.testing + .typeCheckErrors(""" + given reversing: PicklerConfiguration = PicklerConfiguration.default.withToEncodedName(_.reverse) + Pickler.derived[FlatClass] + """) + .map(_.message) + .mkString should include("`toEncodedName` could not be evaluated at compile time") + } +} + +object CodecFixtures { + case class FlatClass(fieldA: Int, fieldB: String) + case class TopClass(fieldA: String, fieldB: InnerClass) + case class InnerClass(fieldA11: Int) + case class TopClass2(fieldA: String, fieldB: AnnotatedInnerClass) + case class AnnotatedInnerClass(@encodedName("encoded_field-a") fieldA: String, fieldB: String) + case class SameFieldNameInner(@encodedName("inner_name") name: String) + case class SameFieldNameOuter(name: String, inner: SameFieldNameInner) + case class FlatClassWithOption(fieldA: String, fieldB: Option[Int], fieldC: Boolean) + case class NestedClassWithOption(innerField: Option[FlatClassWithOption]) + case class FlatClassWithList(fieldA: String, fieldB: List[Int]) + case class NestedClassWithList(innerField: List[FlatClassWithList]) + case class SimpleTestResult(msg: String) + case class ClassWithMap(field: Map[String, SimpleTestResult]) + case class UserId(value: UUID) extends AnyVal + case class UserName(name: String) extends AnyVal + case class ClassWithValues(id: UserId, name: UserName, age: Int) + case class ClassWithScalaDefault(fieldA: String = "field-a-default", fieldB: String) + case class ClassWithDefault(@default("field-a-default") fieldA: String, fieldB: String) + + sealed trait ErrorCode + case object ErrorNotFound extends ErrorCode + case object ErrorTimeout extends ErrorCode + case class CustomError(msg: String) extends ErrorCode + case class MyCaseClass(fieldA: ErrorCode, fieldB: String) + + sealed trait Status: + def code: Int + case class StatusOk(oF: Int) extends Status { + def code = 200 + } + case class StatusBadRequest(bF: Int) extends Status { + def code = 400 + } + case object StatusInternalError extends Status { + def code = 500 + } + case class StatusResponse(status: Status) + + sealed trait SealedVariant + case object VariantA extends SealedVariant + case object VariantB extends SealedVariant + case object VariantC extends SealedVariant + case class SealedVariantContainer(v: SealedVariant) + + sealed trait NotAllSealedVariant + case object NotAllSealedVariantA extends NotAllSealedVariant + case class NotAllSealedVariantB(innerField: Int) extends NotAllSealedVariant + + enum ColorEnum: + case Green, Pink + case class Response(color: ColorEnum, description: String) + + enum RichColorEnum(val code: Int): + case Cyan extends RichColorEnum(3) + case Magenta extends RichColorEnum(18) + case class RichColorResponse(color: RichColorEnum) + + enum NotAlphabetical: + case Xyz + case Fgh + + enum Entity: + case Person(first: String, age: Int) + case Business(address: String) + + sealed trait Animal + sealed trait Rodent extends Animal + case class Hamster(name: String) extends Rodent + case class Dog(name: String) extends Animal + + case class Tree(value: Int, children: List[Tree]) + case class MutualA(b: Option[MutualB], id: Int) + case class MutualB(a: Option[MutualA], id: Int) + + sealed trait Node + case class Edge(id: Long, source: Node) extends Node + case class SimpleNode(id: Long) extends Node + + type Id = UUID + type Statuses = List[Status] + case class WithAliases(a: Id, b: UUID, statuses: Statuses, status: Status) + case class Boxed[T](value: T) +} diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/Fixtures.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/Fixtures.scala index 167de23212..4a7c9dddad 100644 --- a/json/pickler/src/test/scala/sttp/tapir/json/pickler/Fixtures.scala +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/Fixtures.scala @@ -5,6 +5,9 @@ import sttp.tapir.Schema.annotations.description import java.util.UUID import sttp.tapir.Schema.annotations.encodedName +/** Shared fixtures. Several assertions depend on these types' fully-qualified names (discriminator values, `SName`s), so moving or renaming + * them changes expected strings across the suites. + */ object Fixtures: enum ColorEnum: case Green, Pink diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/Generators.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/Generators.scala new file mode 100644 index 0000000000..9ae3392811 --- /dev/null +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/Generators.scala @@ -0,0 +1,108 @@ +package sttp.tapir.json.pickler + +import org.scalacheck.{Arbitrary, Gen} + +import java.util.UUID + +/** ScalaCheck generators for the fixtures, shared by `SchemaCodecAgreementTest` and `DifferentialOracleTest`. + * + * Strings mix plain ASCII with characters that need escaping and non-BMP code points, so that both suites exercise the writer's escaping + * paths; numbers cover the full range of each type. + */ +/** Fixtures that exist only for the property-based suites. */ +object PropertyFixtures { + case class ErrorCodeHolder(fieldA: Fixtures.ErrorCode, fieldB: String) +} + +object Generators { + import Fixtures.* + import CodecFixtures.{Edge, MutualA, MutualB, Node, SimpleNode, Tree} + import PropertyFixtures.* + + val genString: Gen[String] = Gen.frequency( + 5 -> Gen.alphaNumStr, + 2 -> Gen.asciiPrintableStr, + 1 -> Gen.oneOf("zażółć", "日本語", "😀 emoji", "tab\tnewline\n", "quote\"backslash\\", "\u0001control", ""), + 1 -> Arbitrary.arbString.arbitrary // not `arbitrary[String]`: that would be the (lazy) given below, i.e. this very generator + ) + + given Arbitrary[String] = Arbitrary(genString) + + given Arbitrary[UUID] = Arbitrary(Gen.uuid) + + given Arbitrary[FlatClass] = Arbitrary(for (a <- Arbitrary.arbitrary[Int]; b <- genString) yield FlatClass(a, b)) + given Arbitrary[InnerClass] = Arbitrary(Arbitrary.arbitrary[Int].map(InnerClass(_))) + given Arbitrary[TopClass] = Arbitrary(for (a <- genString; b <- Arbitrary.arbitrary[InnerClass]) yield TopClass(a, b)) + given Arbitrary[AnnotatedInnerClass] = Arbitrary(for (a <- genString; b <- genString) yield AnnotatedInnerClass(a, b)) + given Arbitrary[TopClass2] = Arbitrary(for (a <- genString; b <- Arbitrary.arbitrary[AnnotatedInnerClass]) yield TopClass2(a, b)) + given Arbitrary[FlatClassWithOption] = Arbitrary( + for (a <- genString; b <- Gen.option(Arbitrary.arbitrary[Int]); c <- Arbitrary.arbitrary[Boolean]) yield FlatClassWithOption(a, b, c) + ) + given Arbitrary[NestedClassWithOption] = Arbitrary(Gen.option(Arbitrary.arbitrary[FlatClassWithOption]).map(NestedClassWithOption(_))) + given Arbitrary[FlatClassWithList] = Arbitrary( + for (a <- genString; b <- Gen.listOf(Arbitrary.arbitrary[Int])) yield FlatClassWithList(a, b) + ) + given Arbitrary[NestedClassWithList] = Arbitrary(Gen.listOf(Arbitrary.arbitrary[FlatClassWithList]).map(NestedClassWithList(_))) + given Arbitrary[SimpleTestResult] = Arbitrary(genString.map(SimpleTestResult(_))) + given Arbitrary[ClassWithMap] = Arbitrary(Gen.mapOf(Gen.zip(Gen.alphaNumStr, Arbitrary.arbitrary[SimpleTestResult])).map(ClassWithMap(_))) + given Arbitrary[ClassWithEither] = Arbitrary( + for (a <- genString; b <- Gen.either(genString, Arbitrary.arbitrary[SimpleTestResult])) yield ClassWithEither(a, b) + ) + given Arbitrary[ClassWithValues] = Arbitrary( + for (id <- Gen.uuid; name <- genString; age <- Arbitrary.arbitrary[Int]) yield ClassWithValues(UserId(id), UserName(name), age) + ) + given Arbitrary[ClassWithScalaDefault] = Arbitrary(for (a <- genString; b <- genString) yield ClassWithScalaDefault(a, b)) + + given Arbitrary[ErrorCode] = Arbitrary( + Gen.oneOf(Gen.const(ErrorNotFound), Gen.const(ErrorTimeout), genString.map(CustomError(_))) + ) + given Arbitrary[ErrorCodeHolder] = Arbitrary(for (a <- Arbitrary.arbitrary[ErrorCode]; b <- genString) yield ErrorCodeHolder(a, b)) + given Arbitrary[Status] = Arbitrary( + Gen.oneOf(Arbitrary.arbitrary[Int].map(StatusOk(_)), Arbitrary.arbitrary[Int].map(StatusBadRequest(_)), Gen.const(StatusInternalError)) + ) + given Arbitrary[StatusResponse] = Arbitrary(Arbitrary.arbitrary[Status].map(StatusResponse(_))) + given Arbitrary[SealedVariant] = Arbitrary(Gen.oneOf(VariantA, VariantB, VariantC)) + given Arbitrary[SealedVariantContainer] = Arbitrary(Arbitrary.arbitrary[SealedVariant].map(SealedVariantContainer(_))) + given Arbitrary[NotAllSealedVariant] = Arbitrary( + Gen.oneOf(Gen.const(NotAllSealedVariantA), Arbitrary.arbitrary[Int].map(NotAllSealedVariantB(_))) + ) + given Arbitrary[ColorEnum] = Arbitrary(Gen.oneOf(ColorEnum.values.toSeq)) + given Arbitrary[Response] = Arbitrary(for (c <- Arbitrary.arbitrary[ColorEnum]; d <- genString) yield Response(c, d)) + given Arbitrary[RichColorEnum] = Arbitrary(Gen.oneOf(RichColorEnum.values.toSeq)) + given Arbitrary[RichColorResponse] = Arbitrary(Arbitrary.arbitrary[RichColorEnum].map(RichColorResponse(_))) + given Arbitrary[Entity] = Arbitrary( + Gen.oneOf( + for (f <- genString; a <- Arbitrary.arbitrary[Int]) yield Entity.Person(f, a), + genString.map(Entity.Business(_)) + ) + ) + + given Arbitrary[Tree] = Arbitrary(Gen.sized { size => + def tree(depth: Int): Gen[Tree] = + for { + value <- Arbitrary.arbitrary[Int] + children <- if (depth <= 0) Gen.const(Nil) else Gen.resize(size / 2, Gen.listOf(Gen.lzy(tree(depth - 1)))) + } yield Tree(value, children) + tree(3) + }) + + given Arbitrary[Node] = Arbitrary { + def node(depth: Int): Gen[Node] = + if (depth <= 0) Arbitrary.arbitrary[Long].map(SimpleNode(_)) + else + Gen.oneOf( + Arbitrary.arbitrary[Long].map(SimpleNode(_)), + for (id <- Arbitrary.arbitrary[Long]; s <- Gen.lzy(node(depth - 1))) yield Edge(id, s) + ) + node(4) + } + + given Arbitrary[MutualA] = Arbitrary { + def a(depth: Int): Gen[MutualA] = + for (id <- Arbitrary.arbitrary[Int]; b <- if (depth <= 0) Gen.const(None) else Gen.option(Gen.lzy(b(depth - 1)))) yield MutualA(b, id) + def b(depth: Int): Gen[MutualB] = + for (id <- Arbitrary.arbitrary[Int]; a0 <- if (depth <= 0) Gen.const(None) else Gen.option(Gen.lzy(a(depth - 1)))) + yield MutualB(a0, id) + a(3) + } +} diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerBasicTest.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerBasicTest.scala index 179795ab7f..3687535e66 100644 --- a/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerBasicTest.scala +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerBasicTest.scala @@ -1,34 +1,35 @@ package sttp.tapir.json.pickler -import _root_.upickle.{default => udefault} -import magnolia1.SealedTrait +import com.github.plokhotnyuk.jsoniter_scala.core.{JsonReader, JsonValueCodec, JsonWriter} import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import sttp.tapir.DecodeResult.Value -import sttp.tapir.Schema.annotations.{default, encodedName} import sttp.tapir.{Schema, SchemaType} -import upickle.AttributeTagged -import upickle.core.{ObjVisitor, Visitor} import java.util.{TimeZone, UUID} import Fixtures.* +/** Basic derivation: `derives`, nested case classes, user-supplied codecs, `Option`/collection/`Either`/`Map` fields, value classes. */ class PicklerBasicTest extends AnyFlatSpec with Matchers { behavior of "Pickler derivation" - it should "build from an existing Schema and upickle.default.ReadWriter" in { - // given schema and reader / writer in scope - given Schema[FlatClass] = Schema.derived[FlatClass] - given rw: _root_.upickle.default.ReadWriter[FlatClass] = _root_.upickle.default.macroRW[FlatClass] - - // when - val derived = Pickler.derived[FlatClass] - val obj = derived.toCodec.decode("""{"fieldA": 654, "fieldB": "field_b_value"}""") + it should "build from an existing Schema and JsonValueCodec" in { + val codec: JsonValueCodec[FlatClass] = new JsonValueCodec[FlatClass] { + def nullValue: FlatClass = null + def decodeValue(in: JsonReader, default: FlatClass): FlatClass = { + val s = in.readString(null) + val Array(a, b) = s.split('|') + FlatClass(a.toInt, b) + } + def encodeValue(x: FlatClass, out: JsonWriter): Unit = out.writeVal(s"${x.fieldA}|${x.fieldB}") + } + val pickler = Pickler.fromSchemaAndCodec(Schema.string[FlatClass], codec) - // then - obj shouldBe Value(FlatClass(654, "field_b_value")) + pickler.toCodec.encode(FlatClass(654, "field_b_value")) shouldBe "\"654|field_b_value\"" + pickler.toCodec.decode("\"654|field_b_value\"") shouldBe Value(FlatClass(654, "field_b_value")) + pickler.schema.schemaType shouldBe SchemaType.SString() } it should "work with `derives`" in { @@ -66,34 +67,43 @@ class PicklerBasicTest extends AnyFlatSpec with Matchers { resultObj shouldBe Value(TopClass("field_a_value_2", InnerClass(-321))) } - object CustomPickle extends AttributeTagged { - def getReader: udefault.Reader[FlatClass] = udefault.macroR[FlatClass] - def getWriter: this.Writer[FlatClass] = new Writer[FlatClass] { - override def write0[V](out: Visitor[?, V], v: FlatClass): V = out.visitString(s"custom-${v.fieldA}", 1) + it should "work with a user-provided codec for a nested type" in { + val customCodec: JsonValueCodec[FlatClass] = new JsonValueCodec[FlatClass] { + def nullValue: FlatClass = null + def decodeValue(in: JsonReader, default: FlatClass): FlatClass = FlatClass(in.readString(null).stripPrefix("custom-").toInt, "") + def encodeValue(x: FlatClass, out: JsonWriter): Unit = out.writeVal(s"custom-${x.fieldA}") } + given Pickler[FlatClass] = Pickler.fromSchemaAndCodec(Schema.string[FlatClass], customCodec) + case class Wrapper(f: FlatClass) + + val pickler = Pickler.derived[Wrapper] + pickler.toCodec.encode(Wrapper(FlatClass(5, "txt"))) shouldBe """{"f":"custom-5"}""" + pickler.toCodec.decode("""{"f":"custom-7"}""") shouldBe Value(Wrapper(FlatClass(7, ""))) + // the schema follows the pickler too + pickler.schema.schemaType.asInstanceOf[SchemaType.SProduct[Wrapper]].fields.head.schema.schemaType shouldBe SchemaType.SString() } - it should "work with provided own readers and writers" in { - given Schema[FlatClass] = Schema.derived[FlatClass] - given udefault.Reader[FlatClass] = CustomPickle.getReader - given CustomPickle.Writer[FlatClass] = CustomPickle.getWriter - - Pickler.derived[FlatClass].toCodec.encode(FlatClass(5, "txt")) shouldBe """"custom-5"""" - } - - it should "work with provider uPickle ReadWriter on a non-mirrored type" in { - given Schema[TimeZone] = Schema(SchemaType.SString()) - given udefault.ReadWriter[TimeZone] = upickle.default.readwriter[String].bimap[TimeZone](_.getID, TimeZone.getTimeZone) - val ptz: Pickler[TimeZone] = Pickler.derived + it should "work with a user-provided codec for a non-structural type" in { + val tzCodec: JsonValueCodec[TimeZone] = new JsonValueCodec[TimeZone] { + def nullValue: TimeZone = null + def decodeValue(in: JsonReader, default: TimeZone): TimeZone = TimeZone.getTimeZone(in.readString(null)) + def encodeValue(x: TimeZone, out: JsonWriter): Unit = out.writeVal(x.getID) + } + given ptz: Pickler[TimeZone] = Pickler.fromSchemaAndCodec(Schema(SchemaType.SString()), tzCodec) ptz.toCodec.encode(TimeZone.getTimeZone("America/Los_Angeles")) shouldBe "\"America/Los_Angeles\"" + + // and, nested, jsoniter picks the codec up through the given pickler + case class Meeting(tz: TimeZone) + Pickler.derived[Meeting].toCodec.encode(Meeting(TimeZone.getTimeZone("UTC"))) shouldBe """{"tz":"UTC"}""" } - it should "fail to derive a Pickler when there's a Schema but missing ReadWriter" in { - assertDoesNotCompile(""" - given givenSchemaForCc: Schema[FlatClass] = Schema.derived[FlatClass] - Pickler.derived[FlatClass] - """) + it should "derive structurally even when a Schema for the class is in scope" in { + // A bare Schema is not an override (a Pickler is -- see the test above); a bare JsonValueCodec for a nested class + // is refused (CodecDerivationTest). + given givenSchemaForCc: Schema[FlatClass] = Schema.string[FlatClass] + Pickler.derived[FlatClass].toCodec.encode(FlatClass(1, "a")) shouldBe """{"fieldA":1,"fieldB":"a"}""" + Pickler.derived[FlatClass].schema.schemaType shouldBe a[SchemaType.SProduct[?]] } it should "derive picklers for Option fields" in { @@ -184,6 +194,7 @@ class PicklerBasicTest extends AnyFlatSpec with Matchers { pickler2.schema shouldBe Schema.derived[NestedClassWithArray] } } + it should "derive picklers for Either fields" in { import generic.auto.* // for Pickler auto-derivation @@ -195,9 +206,11 @@ class PicklerBasicTest extends AnyFlatSpec with Matchers { val jsonStr1 = codec.encode(obj1) val jsonStr2 = codec.encode(obj2) - // then - jsonStr1 shouldBe """{"fieldA":"fieldA 1","fieldB":[0,"err1"]}""" - jsonStr2 shouldBe """{"fieldA":"fieldA 2","fieldB":[1,{"msg":"it is fine"}]}""" + // then -- untagged, as tapir core's `Codec.eitherRight` + jsonStr1 shouldBe """{"fieldA":"fieldA 1","fieldB":"err1"}""" + jsonStr2 shouldBe """{"fieldA":"fieldA 2","fieldB":{"msg":"it is fine"}}""" + codec.decode(jsonStr1) shouldBe Value(obj1) + codec.decode(jsonStr2) shouldBe Value(obj2) { import sttp.tapir.generic.auto.* pickler.schema shouldBe Schema.derived[ClassWithEither] @@ -213,8 +226,10 @@ class PicklerBasicTest extends AnyFlatSpec with Matchers { val obj = ClassWithMap(Map(("keyB", SimpleTestResult("result1")), ("keyA", SimpleTestResult("result2")))) val jsonStr = codec.encode(obj) - // then - jsonStr shouldBe """{"field":{"keyB":{"msg":"result1"},"keyA":{"msg":"result2"}}}""" + // then -- order-insensitive + jsonStr should (be("""{"field":{"keyB":{"msg":"result1"},"keyA":{"msg":"result2"}}}""") or + be("""{"field":{"keyA":{"msg":"result2"},"keyB":{"msg":"result1"}}}""")) + codec.decode(jsonStr) shouldBe Value(obj) { import sttp.tapir.generic.auto.* pickler.schema shouldBe Schema.derived[ClassWithMap] @@ -225,16 +240,19 @@ class PicklerBasicTest extends AnyFlatSpec with Matchers { import generic.auto.* // for Pickler auto-derivation // when - given picklerMap: Pickler[Map[UUID, SimpleTestResult]] = Pickler.picklerForMap(_.toString) + given picklerMap: Pickler[Map[UUID, SimpleTestResult]] = Pickler.picklerForMap(_.toString, UUID.fromString) val pickler = Pickler.derived[ClassWithMapCustomKey] - val uuid1: UUID = UUID.randomUUID() - val uuid2: UUID = UUID.randomUUID() + // fixed rather than random: `UUID.randomUUID()` has no Scala.js implementation (SecureRandom) + val uuid1: UUID = UUID.fromString("2c2b1cf3-5f2e-4a0b-9d3a-7d1a4e0b1c01") + val uuid2: UUID = UUID.fromString("9e0e3b8d-1d51-4c3f-8f0e-6a2c1b7d2f02") val codec = pickler.toCodec val obj = ClassWithMapCustomKey(Map((uuid1, SimpleTestResult("result3")), (uuid2, SimpleTestResult("result4")))) val jsonStr = codec.encode(obj) - // then - jsonStr shouldBe s"""{"field":{"$uuid1":{"msg":"result3"},"$uuid2":{"msg":"result4"}}}""" + // then -- order-insensitive + jsonStr should (be(s"""{"field":{"$uuid1":{"msg":"result3"},"$uuid2":{"msg":"result4"}}}""") or + be(s"""{"field":{"$uuid2":{"msg":"result4"},"$uuid1":{"msg":"result3"}}}""")) + codec.decode(jsonStr) shouldBe Value(obj) { import sttp.tapir.generic.auto.* picklerMap.schema shouldBe Schema.schemaForMap[UUID, SimpleTestResult](_.toString) diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerCoproductTest.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerCoproductTest.scala index 48dfd509ba..a3f7a13c99 100644 --- a/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerCoproductTest.scala +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerCoproductTest.scala @@ -1,15 +1,12 @@ package sttp.tapir.json.pickler -import magnolia1.SealedTrait import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import sttp.tapir.DecodeResult.Value -import sttp.tapir.Schema.annotations.{default, encodedName} -import sttp.tapir.{Schema, SchemaType} -import upickle.core.{ObjVisitor, Visitor} import Fixtures.* +/** Sealed hierarchies and enums with parameters: discriminator field and value configuration, `oneOfUsingField`. */ class PicklerCoproductTest extends AnyFlatSpec with Matchers { behavior of "Pickler derivation for coproducts" diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerCustomizationTest.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerCustomizationTest.scala index 10693c7dd7..dc438a31d4 100644 --- a/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerCustomizationTest.scala +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerCustomizationTest.scala @@ -1,15 +1,15 @@ package sttp.tapir.json.pickler -import magnolia1.SealedTrait import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import sttp.tapir.DecodeResult.Value -import sttp.tapir.Schema.annotations.{default, encodedName} -import sttp.tapir.{Schema, SchemaType} -import upickle.core.{ObjVisitor, Visitor} +import sttp.tapir.{DecodeResult, SchemaType} import Fixtures.* +/** Field-name configuration and annotations. Note that tapir's `@default` annotation is documentation only: Scala default parameters drive + * decoding. + */ class PicklerCustomizationTest extends AnyFlatSpec with Matchers { behavior of "Pickler customization" @@ -39,7 +39,7 @@ class PicklerCustomizationTest extends AnyFlatSpec with Matchers { jsonStr shouldBe """{"fieldA":"field_a_value","fieldB":{"encoded_field-a":"f-a-value","fieldB":"f-b-value"}}""" } - it should "Decode in a Reader using custom encodedName" in { + it should "decode using custom encodedName" in { // given import generic.auto.* // for Pickler auto-derivation given config: PicklerConfiguration = PicklerConfiguration.default.withSnakeCaseMemberNames @@ -53,31 +53,33 @@ class PicklerCustomizationTest extends AnyFlatSpec with Matchers { obj shouldBe Value(TopClass("field_a_value", InnerClass(7954))) } - it should "apply defaults from annotations" in { - // given + it should "document @default in the schema but not use it to fill missing fields" in { import generic.auto.* // for Pickler auto-derivation // when - val codecCc1 = Pickler.derived[ClassWithDefault].toCodec + val pickler1 = Pickler.derived[ClassWithDefault] + val codecCc1 = pickler1.toCodec val codecCc2 = Pickler.derived[ClassWithDefault2].toCodec val codecCc3 = Pickler.derived[ClassWithDefault3].toCodec val jsonStrCc11 = codecCc1.encode(ClassWithDefault("field-a-user-value", "msg104")) - val object12 = codecCc1.decode("""{"fieldB":"msg105"}""") - val object2 = codecCc2.decode("""{"fieldA":"msgCc12"}""") - val object3 = - codecCc3.decode( - """{"fieldA":{"$type":"ErrorNotFound"}, "fieldC": {"fieldInner": "deeper field inner"}}""" - ) - // then + // then: encoding is unaffected jsonStrCc11 shouldBe """{"fieldA":"field-a-user-value","fieldB":"msg104"}""" - object12 shouldBe Value(ClassWithDefault("field-a-default", "msg105")) - object2 shouldBe Value(ClassWithDefault2("msgCc12", ErrorTimeout)) - object3 shouldBe Value(ClassWithDefault3(ErrorNotFound, InnerCaseClass("def-field", 65), InnerCaseClass("deeper field inner", 4))) + // the annotation lands in the schema + pickler1.schema.schemaType.asInstanceOf[SchemaType.SProduct[ClassWithDefault]].fields.head.schema.default.map(_._1) shouldBe + Some("field-a-default") + // but a missing field with only a tapir @default is a decode error + codecCc1.decode("""{"fieldB":"msg105"}""") shouldBe a[DecodeResult.Error] + codecCc2.decode("""{"fieldA":"msgCc12"}""") shouldBe a[DecodeResult.Error] + codecCc3.decode("""{"fieldA":{"$type":"ErrorNotFound"}, "fieldC": {"fieldInner": "deeper field inner"}}""") shouldBe + a[DecodeResult.Error] + // while a fully specified object decodes as before + codecCc3.decode( + """{"fieldA":{"$type":"ErrorNotFound"},"fieldB":{"fieldInner":"b","fieldInnerInt":1},"fieldC":{"fieldInner":"c","fieldInnerInt":2}}""" + ) shouldBe Value(ClassWithDefault3(ErrorNotFound, InnerCaseClass("b", 1), InnerCaseClass("c", 2))) } - it should "apply defaults from class fields, then annotations" in { - // given + it should "apply Scala default parameters, ignoring a competing @default" in { import generic.auto.* // for Pickler auto-derivation // when @@ -92,6 +94,6 @@ class PicklerCustomizationTest extends AnyFlatSpec with Matchers { jsonStrCc11 shouldBe """{"fieldA":"field-a-user-value","fieldB":"msg104"}""" jsonStrCc12 shouldBe """{"fieldA":"field-a-default","fieldB":"text b"}""" object12 shouldBe Value(ClassWithScalaDefault("field-a-default", "msg205")) - object2 shouldBe Value(ClassWithScalaAndTapirDefault("field-a-tapir-default", "msgCc22", 55)) + object2 shouldBe Value(ClassWithScalaAndTapirDefault("field-a-scala-default", "msgCc22", 55)) } } diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerEdgeCasesTest.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerEdgeCasesTest.scala new file mode 100644 index 0000000000..e89322c1ff --- /dev/null +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerEdgeCasesTest.scala @@ -0,0 +1,156 @@ +package sttp.tapir.json.pickler + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.tapir.DecodeResult +import sttp.tapir.DecodeResult.Error.JsonDecodeException +import sttp.tapir.DecodeResult.Value +import sttp.tapir.SchemaType.{SOption, SProduct} + +import scala.compiletime.testing.typeCheckErrors + +/** Edge cases: numeric values, string escaping, nested `Option`s, `Char`, tuples, and decode failures. */ +class PicklerEdgeCasesTest extends AnyFlatSpec with Matchers { + import EdgeFixtures.* + + private def roundTrip[T](pickler: Pickler[T], value: T, expectedJson: String): Unit = { + val codec = pickler.toCodec + codec.encode(value) shouldBe expectedJson + val _ = codec.decode(expectedJson) shouldBe Value(value) + } + + behavior of "numeric values" + + it should "write and read every numeric type, including extremes" in { + roundTrip( + Pickler.derived[Numbers], + Numbers( + Byte.MinValue, + Short.MaxValue, + Int.MinValue, + Long.MaxValue, + 1.5f, + 2.25, + BigInt("123456789012345678901234567890"), + BigDecimal("1.000000000000000000001") + ), + """{"b":-128,"s":32767,"i":-2147483648,"l":9223372036854775807,"f":1.5,"d":2.25,"bi":123456789012345678901234567890,"bd":1.000000000000000000001}""" + ) + } + + it should "write java.math.BigDecimal / BigInteger through the hand-written leaf codecs" in { + roundTrip( + Pickler.derived[JavaNumbers], + JavaNumbers(new java.math.BigDecimal("12.50"), new java.math.BigInteger("-7")), + """{"bd":12.50,"bi":-7}""" + ) + } + + it should "reject a non-numeric value for a numeric field" in { + val result = Pickler.derived[Numbers].toCodec.decode("""{"b":1,"s":1,"i":"one","l":1,"f":1,"d":1,"bi":1,"bd":1}""") + result shouldBe a[DecodeResult.Error] + val error = result.asInstanceOf[DecodeResult.Error].error.asInstanceOf[JsonDecodeException] + error.errors.map(_.msg).mkString should include("illegal number") + } + + behavior of "strings" + + it should "escape quotes, backslashes and control characters" in { + roundTrip(Pickler.derived[Text], Text("a\"b\\c\n\t\u0001"), """{"s":"a\"b\\c\n\t\u0001"}""") + } + + it should "pass non-ASCII and surrogate pairs through unescaped" in { + roundTrip(Pickler.derived[Text], Text("zażółć 日本 😀"), """{"s":"zażółć 日本 😀"}""") + } + + it should "read escaped non-ASCII" in { + Pickler.derived[Text].toCodec.decode("""{"s":"\u0105\ud83d\ude00"}""") shouldBe Value(Text("ą😀")) + } + + it should "write a Char as a one-character string, and document it as a string" in { + val pickler = Pickler.derived[WithChar] + roundTrip(pickler, WithChar('x'), """{"c":"x"}""") + pickler.schema.schemaType.asInstanceOf[SProduct[WithChar]].fields.head.schema.schemaType shouldBe sttp.tapir.SchemaType.SString() + pickler.toCodec.decode("""{"c":"xy"}""") shouldBe a[DecodeResult.Error] + } + + behavior of "nested Options" + + it should "flatten Option[Option[X]] to a nullable X, as the schema documents" in { + val pickler = Pickler.derived[WithOptOpt] + val codec = pickler.toCodec + codec.encode(WithOptOpt(Some(Some(1)))) shouldBe """{"o":1}""" + codec.encode(WithOptOpt(Some(None))) shouldBe """{"o":null}""" + codec.encode(WithOptOpt(None)) shouldBe """{}""" + codec.decode("""{"o":1}""") shouldBe Value(WithOptOpt(Some(Some(1)))) + codec.decode("""{"o":null}""") shouldBe Value(WithOptOpt(None)) + codec.decode("""{}""") shouldBe Value(WithOptOpt(None)) + + val fieldSchema = pickler.schema.schemaType.asInstanceOf[SProduct[WithOptOpt]].fields.head.schema + fieldSchema.isOptional shouldBe true + fieldSchema.schemaType shouldBe a[SOption[?, ?]] + } + + it should "flatten Option[Option[X]] at the root too" in { + val codec = Pickler.derived[Option[Option[String]]].toCodec + codec.encode(Some(Some("a"))) shouldBe "\"a\"" + codec.decode("\"a\"") shouldBe Value(Some(Some("a"))) + codec.decode("null") shouldBe Value(None) + } + + behavior of "tuples" + + it should "be rejected with an explanation, nested and at the root" in { + typeCheckErrors("Pickler.derived[WithTuple]").map(_.message).mkString should include("tuples have no JSON schema") + typeCheckErrors("Pickler.derived[(Int, String)]").map(_.message).mkString should include("tuples have no JSON schema") + } + + behavior of "decode failures" + + it should "report malformed JSON as a DecodeResult.Error carrying a JsonDecodeException with jsoniter's message" in { + val result = Pickler.derived[Text].toCodec.decode("not json") + result shouldBe a[DecodeResult.Error] + val DecodeResult.Error(original, error: JsonDecodeException) = result: @unchecked + original shouldBe "not json" + error.errors.map(_.msg).mkString should include("expected '{'") + error.underlying shouldBe a[com.github.plokhotnyuk.jsoniter_scala.core.JsonReaderException] + } + + it should "report a missing required field" in { + val result = Pickler.derived[Numbers].toCodec.decode("""{"b":1}""") + result shouldBe a[DecodeResult.Error] + result.asInstanceOf[DecodeResult.Error].error.asInstanceOf[JsonDecodeException].errors.map(_.msg).mkString should include( + "missing required field" + ) + } + + it should "report an unknown discriminator value" in { + val result = Pickler.derived[Fixtures.Status].toCodec.decode("""{"$type":"Nope"}""") + result shouldBe a[DecodeResult.Error] + result.asInstanceOf[DecodeResult.Error].error.asInstanceOf[JsonDecodeException].errors.map(_.msg).mkString should include( + "illegal value of discriminator" + ) + } + + it should "report an unknown enumeration value" in { + Pickler.derived[Fixtures.ColorEnum].toCodec.decode("\"Mauve\"") shouldBe a[DecodeResult.Error] + Pickler + .derivedEnumeration[Fixtures.ColorEnum] + .customStringBased(_.ordinal.toString) + .toCodec + .decode("\"9\"") shouldBe a[DecodeResult.Error] + } + + it should "report trailing garbage" in { + Pickler.derived[Text].toCodec.decode("""{"s":"a"} extra""") shouldBe a[DecodeResult.Error] + } +} + +object EdgeFixtures { + case class Numbers(b: Byte, s: Short, i: Int, l: Long, f: Float, d: Double, bi: BigInt, bd: BigDecimal) + case class JavaNumbers(bd: java.math.BigDecimal, bi: java.math.BigInteger) + case class Text(s: String) + case class WithChar(c: Char) + case class WithOptOpt(o: Option[Option[Int]]) + case class WithTuple(t: (Int, String)) +} diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerEnumTest.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerEnumTest.scala index ac3ef40c17..5e3a0be437 100644 --- a/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerEnumTest.scala +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerEnumTest.scala @@ -3,11 +3,12 @@ package sttp.tapir.json.pickler import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import sttp.tapir.DecodeResult.Value -import sttp.tapir.{Schema, SchemaType} -import upickle.core.ObjVisitor + +import scala.compiletime.testing.typeCheckErrors import Fixtures.* +/** Enumerations: Scala 3 enums and all-object sealed hierarchies, encoded as bare strings. */ class PicklerEnumTest extends AnyFlatSpec with Matchers { behavior of "Pickler derivation for enumerations" @@ -90,22 +91,13 @@ class PicklerEnumTest extends AnyFlatSpec with Matchers { } it should "Reject oneOfUsingField for enums" in { - // given - assertCompiles(""" - import Fixtures.* - val picklerCyan = Pickler.derived[RichColorEnum.Cyan.type] - val picklerMagenta = Pickler.derived[RichColorEnum.Magenta.type]""") - // when - assertDoesNotCompile(""" - import Fixtures.* - val picklerCyan = Pickler.derived[RichColorEnum.Cyan.type] - val picklerMagenta = Pickler.derived[RichColorEnum.Magenta.type] - - given picklerRichColor: Pickler[RichColorEnum] = + val errors = typeCheckErrors(""" + given picklerRichColor: Pickler[RichColorEnum] = Pickler.oneOfUsingField[RichColorEnum, Int](_.code, codeInt => s"code-$codeInt")( - 3 -> picklerCyan, - 18 -> picklerMagenta - )""") + 3 -> (null: Pickler[RichColorEnum.Cyan.type]), + 18 -> (null: Pickler[RichColorEnum.Magenta.type]) + )""").map(_.message).mkString + errors should include("derivedEnumeration") } it should "encode and decode an enum where the cases are not alphabetically sorted" in { diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerFacadeTest.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerFacadeTest.scala new file mode 100644 index 0000000000..8848fc2e22 --- /dev/null +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerFacadeTest.scala @@ -0,0 +1,245 @@ +package sttp.tapir.json.pickler + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.tapir.DecodeResult.Value +import sttp.tapir.SchemaType.{SCoproduct, SOpenProduct, SProduct, SString} +import sttp.tapir.{Schema, Validator} + +import java.util.UUID +import scala.compiletime.testing.typeCheckErrors + +/** The public surface of `Pickler` beyond `derived`: `derivedEnumeration`, `picklerForMap`, `asOption` / `asIterable` / `asArray`, the + * `JsonCodec` bridge, and `Either`. + */ +class PicklerFacadeTest extends AnyFlatSpec with Matchers { + import CodecFixtures.* + import FacadeFixtures.* + + private def roundTrip[T](pickler: Pickler[T], value: T, expectedJson: String): Unit = { + val codec = pickler.toCodec + codec.encode(value) shouldBe expectedJson + val _ = codec.decode(expectedJson) shouldBe Value(value) + } + + behavior of "derivedEnumeration" + + it should "encode with a custom function (ordinal), for a nested enum" in { + given Pickler[ColorEnum] = Pickler.derivedEnumeration[ColorEnum].customStringBased(_.ordinal.toString) + roundTrip(Pickler.derived[Response], Response(ColorEnum.Pink, "pink!!"), """{"color":"1","description":"pink!!"}""") + } + + it should "encode with a custom function over a parameterised enum case" in { + given picklerColorEnum: Pickler[RichColorEnum] = + Pickler.derivedEnumeration[RichColorEnum].customStringBased(enumValue => s"color-number-${enumValue.code}") + roundTrip(Pickler.derived[RichColorResponse], RichColorResponse(RichColorEnum.Cyan), """{"color":"color-number-3"}""") + } + + it should "document the custom encoding in the schema's enumeration validator" in { + val pickler = Pickler.derivedEnumeration[ColorEnum].customStringBased(_.ordinal.toString) + pickler.schema.schemaType shouldBe SString() + pickler.schema.name shouldBe Pickler.derived[ColorEnum].schema.name + val validator = pickler.schema.validator.asInstanceOf[Validator.Enumeration[ColorEnum]] + validator.possibleValues shouldBe List(ColorEnum.Green, ColorEnum.Pink) + validator.encode.flatMap(_(ColorEnum.Pink)) shouldBe Some("1") + // the OpenAPI interpreter emits a named component for a *named* enumeration validator, as with `Pickler.derived` + validator.name shouldBe pickler.schema.name + validator.name shouldBe Pickler.derived[ColorEnum].schema.validator.asInstanceOf[Validator.Enumeration[ColorEnum]].name + } + + it should "be equivalent to Pickler.derived when defaultStringBased" in { + val viaBuilder = Pickler.derivedEnumeration[ColorEnum].defaultStringBased + val viaDerived = Pickler.derived[ColorEnum] + // Two expansions, two validator lambdas: compare structurally rather than with `shouldBe`. + viaBuilder.schema.copy(validator = Validator.pass) shouldBe viaDerived.schema.copy(validator = Validator.pass) + val encode = viaBuilder.schema.validator.asInstanceOf[Validator.Enumeration[ColorEnum]].encode.get + ColorEnum.values.toList.map(encode) shouldBe ColorEnum.values.toList.map( + viaDerived.schema.validator.asInstanceOf[Validator.Enumeration[ColorEnum]].encode.get + ) + viaBuilder.toCodec.encode(ColorEnum.Pink) shouldBe viaDerived.toCodec.encode(ColorEnum.Pink) + viaBuilder.toCodec.encode(ColorEnum.Pink) shouldBe "\"Pink\"" + } + + it should "work for a sealed hierarchy of objects, not only Scala 3 enums" in { + given Pickler[SealedVariant] = Pickler.derivedEnumeration[SealedVariant].customStringBased(_.toString.toLowerCase) + roundTrip(Pickler.derived[SealedVariantContainer], SealedVariantContainer(VariantB), """{"v":"variantb"}""") + } + + it should "reject an unknown string on decode" in { + val codec = Pickler.derivedEnumeration[ColorEnum].customStringBased(_.ordinal.toString).toCodec + codec.decode("\"7\"") should not be a[Value[?]] + } + + it should "fail fast when the encoding is not injective" in { + an[IllegalArgumentException] should be thrownBy Pickler.derivedEnumeration[ColorEnum].customStringBased(_ => "same") + } + + it should "reject a hierarchy with non-singleton cases" in { + typeCheckErrors("Pickler.derivedEnumeration[ErrorCode]").map(_.message).mkString should include("cases with fields: ") + typeCheckErrors("Pickler.derivedEnumeration[FlatClass]").map(_.message).mkString should include("is not one") + } + + it should "reject oneOfUsingField for enums" in { + // jsoniter cannot build a codec for a single parameterised enum case (`JsonCodecMaker.make[RichColorEnum.Cyan.type]` + // fails to type-check), so the children here are only typed, which is all the rejection needs. + val errors = typeCheckErrors(""" + given picklerRichColor: Pickler[RichColorEnum] = + Pickler.oneOfUsingField[RichColorEnum, Int](_.code, codeInt => s"code-$codeInt")( + 3 -> (null: Pickler[RichColorEnum.Cyan.type]), + 18 -> (null: Pickler[RichColorEnum.Magenta.type]) + )""").map(_.message).mkString + errors should include("derivedEnumeration") + // A case object on its own, outside its hierarchy, is an empty object -- which is what its schema (an `SProduct` + // with no fields) documents. Only as a member of an all-singleton hierarchy is it a bare string. + Pickler.derived[VariantA.type].toCodec.encode(VariantA) shouldBe "{}" + Pickler.derived[VariantA.type].schema.schemaType shouldBe SProduct[VariantA.type](Nil) + } + + behavior of "picklerForMap" + + it should "derive picklers for Map with non-String key" in { + import sttp.tapir.json.pickler.generic.auto.* + given picklerMap: Pickler[Map[UUID, SimpleTestResult]] = Pickler.picklerForMap(_.toString, UUID.fromString) + val pickler = Pickler.derived[ClassWithMapCustomKey] + val uuid1: UUID = UUID.fromString("2c2b1cf3-5f2e-4a0b-9d3a-7d1a4e0b1c01") // fixed: no `randomUUID` on Scala.js + val uuid2: UUID = UUID.fromString("9e0e3b8d-1d51-4c3f-8f0e-6a2c1b7d2f02") + val obj = ClassWithMapCustomKey(Map((uuid1, SimpleTestResult("result3")), (uuid2, SimpleTestResult("result4")))) + + roundTrip(pickler, obj, s"""{"field":{"$uuid1":{"msg":"result3"},"$uuid2":{"msg":"result4"}}}""") + { + import sttp.tapir.generic.auto.* + picklerMap.schema shouldBe Schema.schemaForMap[UUID, SimpleTestResult](_.toString) + given Schema[Map[UUID, SimpleTestResult]] = picklerMap.schema + pickler.schema shouldBe Schema.derived[ClassWithMapCustomKey] + } + } + + it should "decode an empty map and reject a malformed one" in { + val codec = Pickler.picklerForMap[Int, String](_.toString, _.toInt)(using Pickler.derived[String]).toCodec + codec.decode("{}") shouldBe Value(Map.empty[Int, String]) + codec.encode(Map(1 -> "a", 2 -> "b")) shouldBe """{"1":"a","2":"b"}""" + codec.decode("""{"x":"a"}""") should not be a[Value[?]] + } + + it should "report a key the parser rejects as a decoding error with a message, not as an escaped exception" in { + val codec = Pickler.picklerForMap[UUID, Int](_.toString, UUID.fromString)(using Pickler.derived[Int]).toCodec + val result = codec.decode("""{"not-a-uuid":1}""") + val error = result.asInstanceOf[sttp.tapir.DecodeResult.Error].error.asInstanceOf[sttp.tapir.DecodeResult.Error.JsonDecodeException] + error.errors.map(_.msg).mkString should include("illegal map key 'not-a-uuid'") + } + + it should "still require picklerForMap for a non-String key in structural derivation" in { + assertDoesNotCompile("""Pickler.derived[ClassWithMapCustomKey]""") + } + + behavior of "asOption / asIterable / asArray" + + it should "wrap a pickler in Option, with null for None" in { + val pickler = Pickler.derived[FlatClass].asOption + pickler.schema shouldBe Pickler.derived[FlatClass].schema.asOption + roundTrip(pickler, Some(FlatClass(1, "a")), """{"fieldA":1,"fieldB":"a"}""") + // The jsoniter codec writes `null`; tapir's `Codec.json` then maps `None` of an optional schema to an empty body. + com.github.plokhotnyuk.jsoniter_scala.core.writeToString(None: Option[FlatClass])(using pickler.codec) shouldBe "null" + pickler.toCodec.encode(None) shouldBe "" + pickler.toCodec.decode("") shouldBe Value(None) + pickler.toCodec.decode("null") shouldBe Value(None) + } + + it should "wrap a pickler in a collection" in { + val pickler = Pickler.derived[FlatClass].asIterable[Vector] + pickler.schema shouldBe Pickler.derived[FlatClass].schema.asIterable[Vector] + roundTrip(pickler, Vector(FlatClass(1, "a"), FlatClass(2, "b")), """[{"fieldA":1,"fieldB":"a"},{"fieldA":2,"fieldB":"b"}]""") + roundTrip(pickler, Vector.empty, "[]") + } + + it should "wrap a pickler in an Array" in { + val pickler = Pickler.derived[Int].asArray + pickler.schema shouldBe Pickler.derived[Int].schema.asArray + pickler.toCodec.encode(Array(1, 2, 3)) shouldBe "[1,2,3]" + pickler.toCodec.decode("[1,2,3]").map(_.toList) shouldBe Value(List(1, 2, 3)) + } + + it should "agree with structural derivation of the same wrapper" in { + val wrapped = Pickler.derived[FlatClass].asIterable[List] + val structural = Pickler.derived[List[FlatClass]] + val value = List(FlatClass(1, "a")) + wrapped.toCodec.encode(value) shouldBe structural.toCodec.encode(value) + wrapped.schema.schemaType shouldBe structural.schema.schemaType + } + + behavior of "the JsonCodec bridge" + + it should "make jsonBody resolve from a given Pickler" in { + given Pickler[FlatClass] = Pickler.derived[FlatClass] + val body = jsonBody[FlatClass] + body.codec.encode(FlatClass(1, "a")) shouldBe """{"fieldA":1,"fieldB":"a"}""" + body.codec.schema shouldBe summon[Pickler[FlatClass]].schema + summon[sttp.tapir.Codec.JsonCodec[FlatClass]].encode(FlatClass(1, "a")) shouldBe """{"fieldA":1,"fieldB":"a"}""" + } + + it should "make core's customCodecJsonBody resolve too, through the implicit codec" in { + import sttp.tapir.json.pickler.generic.auto.* + val body = sttp.tapir.customCodecJsonBody[TopClass] + body.codec.encode(TopClass("a", InnerClass(1))) shouldBe """{"fieldA":"a","fieldB":{"fieldA11":1}}""" + jsonQuery[FlatClass]("q").codec.encode(FlatClass(1, "a")) shouldBe List("""{"fieldA":1,"fieldB":"a"}""") + } + + behavior of "Either" + + it should "encode Either fields untagged, as tapir core's Codec.eitherRight does" in { + val pickler = Pickler.derived[ClassWithEither] + roundTrip(pickler, ClassWithEither("fieldA 1", Left("err1")), """{"fieldA":"fieldA 1","fieldB":"err1"}""") + roundTrip( + pickler, + ClassWithEither("fieldA 2", Right(SimpleTestResult("it is fine"))), + """{"fieldA":"fieldA 2","fieldB":{"msg":"it is fine"}}""" + ) + { + import sttp.tapir.generic.auto.* + pickler.schema shouldBe Schema.derived[ClassWithEither] + } + } + + it should "document Either as an untagged coproduct of both sides" in { + val schema = Pickler.derived[ClassWithEither].schema.schemaType.asInstanceOf[SProduct[ClassWithEither]].fields(1).schema + val coproduct = schema.schemaType.asInstanceOf[SCoproduct[?]] + coproduct.discriminator shouldBe None + coproduct.subtypes.map(_.schemaType) shouldBe List(SString(), Pickler.derived[SimpleTestResult].schema.schemaType) + } + + it should "derive Either as a root, with structural sides" in { + val pickler = Pickler.derived[Either[List[Int], Status]] + roundTrip(pickler, Left(List(1, 2)), "[1,2]") + roundTrip(pickler, Right(StatusOk(200)), """{"$type":"StatusOk","oF":200}""") + roundTrip(pickler, Right(StatusInternalError), """{"$type":"StatusInternalError"}""") + } + + it should "prefer Right when both sides accept the value" in { + Pickler.derived[Either[String, String]].toCodec.decode("\"x\"") shouldBe Value(Right("x")) + } + + it should "fall back to Left when the Right codec fails with something other than a JsonReaderException" in { + // `UUID.fromString` throws an `IllegalArgumentException`, which is not jsoniter's exception type. + given Pickler[Map[UUID, Int]] = Pickler.picklerForMap[UUID, Int](_.toString, UUID.fromString)(using Pickler.derived[Int]) + val codec = Pickler.derived[Either[Map[String, Int], Map[UUID, Int]]].toCodec + codec.decode("""{"k":1}""") shouldBe Value(Left(Map("k" -> 1))) + codec.decode("""{"2c2b1cf3-5f2e-4a0b-9d3a-7d1a4e0b1c01":1}""") shouldBe + Value(Right(Map(UUID.fromString("2c2b1cf3-5f2e-4a0b-9d3a-7d1a4e0b1c01") -> 1))) + } + + it should "honour a user pickler for one side" in { + given Pickler[SimpleTestResult] = + Pickler.derived[SimpleTestResult](using PicklerConfiguration.default.withScreamingSnakeCaseMemberNames) + roundTrip( + Pickler.derived[ClassWithEither], + ClassWithEither("a", Right(SimpleTestResult("r"))), + """{"fieldA":"a","fieldB":{"MSG":"r"}}""" + ) + } +} + +object FacadeFixtures { + import CodecFixtures.SimpleTestResult + case class ClassWithEither(fieldA: String, fieldB: Either[String, SimpleTestResult]) + case class ClassWithMapCustomKey(field: Map[UUID, SimpleTestResult]) +} diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerScaffoldingTest.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerScaffoldingTest.scala new file mode 100644 index 0000000000..b551674759 --- /dev/null +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/PicklerScaffoldingTest.scala @@ -0,0 +1,58 @@ +package sttp.tapir.json.pickler + +import org.scalatest.OptionValues +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.tapir.SchemaType.SProduct +import sttp.tapir.{FieldName, Schema} + +/** Structural acceptance test for the macro skeleton. + * + * Behavioural coverage of the schema half lives in `SchemaDerivationTest`. What this file pins down is the *plumbing*, which that suite + * exercises only incidentally: that the bundle is constructed, the cross-quotes plugin is active, the shared `ValDefsCache` produces + * well-scoped `def`s (a cross-splice staging bug would fail compilation here rather than at runtime), and that both entry points — + * `Pickler.derived` and `Pickler.schemaFor` — agree. + */ +class PicklerScaffoldingTest extends AnyFlatSpec with Matchers with OptionValues { + + case class Simple(fieldA: Int, fieldB: String) + case class Nested(first: Simple, second: Simple) + + behavior of "the Pickler derivation skeleton" + + it should "expand the macro and produce a Pickler instance" in { + val pickler = Pickler.derived[Simple] + pickler should not be null + pickler.schema should not be null + pickler.codec should not be null + } + + it should "expose a fully derived schema" in { + val schema: Schema[Simple] = Pickler.derived[Simple].schema + schema.name.value.fullName should endWith("Simple") + schema.schemaType shouldBe a[SProduct[?]] + schema.schemaType.asInstanceOf[SProduct[Simple]].fields.map(_.name) shouldBe List( + FieldName("fieldA"), + FieldName("fieldB") + ) + } + + it should "derive the same schema through the schema-only entry point" in { + // The two entry points share `derivePicklerCore`, so a divergence here means the schema branch is sensitive to + // whether the codec halves are also being derived -- exactly the kind of coupling the single-expansion design + // exists to prevent. + Pickler.schemaFor[Simple] shouldBe Pickler.derived[Simple].schema + } + + it should "hoist derived schemas rather than inlining them at every occurrence" in { + // `Nested` mentions `Simple` twice; both must resolve to the same cached `lazy val`. + val schema = Pickler.schemaFor[Nested].schemaType.asInstanceOf[SProduct[Nested]] + val fieldSchemas = schema.fields.map(_.schema) + fieldSchemas.head should be theSameInstanceAs fieldSchemas(1) + } + + it should "build a tapir codec from the pickler" in { + val codec = Pickler.derived[Simple].toCodec + codec should not be null + } +} diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaCodecAgreementTest.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaCodecAgreementTest.scala new file mode 100644 index 0000000000..4f0ae618a9 --- /dev/null +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaCodecAgreementTest.scala @@ -0,0 +1,126 @@ +package sttp.tapir.json.pickler + +import com.github.plokhotnyuk.jsoniter_scala.core.{readFromString, writeToString} +import org.scalacheck.Arbitrary +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks + +/** For generated values of every fixture, under every configuration: the JSON the codec writes has the shape the schema documents + * (`SchemaJsonAgreement`), and decodes back to the value. This is the entire reason for deriving the two together -- the property the + * string assertions in the other suites only sample. + */ +class SchemaCodecAgreementTest extends AnyFlatSpec with Matchers with ScalaCheckPropertyChecks { + import CodecFixtures.{MutualA, Node, Tree} + import Fixtures.* + import PropertyFixtures.* + import Generators.given + + /** The jsoniter codec is used directly: the tapir `Codec` on top of it maps `None` of an optional root to an empty body + * (`Codec.anyString`), which is not JSON and not this module's doing. + */ + private def agree[T: Arbitrary](pickler: Pickler[T]): Unit = { + given com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec[T] = pickler.codec + forAll { (value: T) => + val json = writeToString(value) + val mismatches = SchemaJsonAgreement.mismatches(pickler.schema, ujson.read(json)) + withClue(s"value $value encoded as $json under schema ${pickler.schema}:\n") { + mismatches shouldBe Nil + } + readFromString[T](json) shouldBe value + } + } + + /** Every configuration knob that changes the wire format; the schema has to follow each one. */ + private inline def agreeUnderEveryConfiguration[T: Arbitrary]: Unit = { + agree(Pickler.derived[T](using PicklerConfiguration.default)) + agree(Pickler.derived[T](using PicklerConfiguration.default.withSnakeCaseMemberNames)) + agree(Pickler.derived[T](using PicklerConfiguration.default.withScreamingSnakeCaseMemberNames)) + agree(Pickler.derived[T](using PicklerConfiguration.default.withKebabCaseMemberNames)) + agree(Pickler.derived[T](using PicklerConfiguration.default.withToEncodedName(_.toUpperCase))) + agree(Pickler.derived[T](using PicklerConfiguration.default.withDiscriminator("kind"))) + agree(Pickler.derived[T](using PicklerConfiguration.default.withSnakeCaseDiscriminatorValues)) + agree(Pickler.derived[T](using PicklerConfiguration.default.withKebabCaseDiscriminatorValues)) + agree(Pickler.derived[T](using PicklerConfiguration.default.withScreamingSnakeCaseDiscriminatorValues)) + agree(Pickler.derived[T](using PicklerConfiguration.default.withFullDiscriminatorValues)) + agree(Pickler.derived[T](using PicklerConfiguration.default.withFullSnakeCaseDiscriminatorValues)) + agree(Pickler.derived[T](using PicklerConfiguration.default.withFullKebabCaseDiscriminatorValues)) + agree(Pickler.derived[T](using PicklerConfiguration.default.withTransientNone(false))) + agree( + Pickler.derived[T](using + PicklerConfiguration.default.withSnakeCaseMemberNames.withDiscriminator("t").withFullKebabCaseDiscriminatorValues + ) + ) + } + + behavior of "schema/codec agreement" + + it should "hold for products" in { + agreeUnderEveryConfiguration[FlatClass] + agreeUnderEveryConfiguration[TopClass] + agreeUnderEveryConfiguration[TopClass2] + agreeUnderEveryConfiguration[ClassWithValues] + agreeUnderEveryConfiguration[ClassWithScalaDefault] + } + + it should "hold for optional fields" in { + agreeUnderEveryConfiguration[FlatClassWithOption] + agreeUnderEveryConfiguration[NestedClassWithOption] + } + + it should "hold for collections and maps" in { + agreeUnderEveryConfiguration[FlatClassWithList] + agreeUnderEveryConfiguration[NestedClassWithList] + agreeUnderEveryConfiguration[ClassWithMap] + } + + it should "hold for Either" in { + agreeUnderEveryConfiguration[ClassWithEither] + } + + it should "hold for sealed hierarchies" in { + agreeUnderEveryConfiguration[ErrorCodeHolder] + agreeUnderEveryConfiguration[StatusResponse] + agreeUnderEveryConfiguration[Status] + agreeUnderEveryConfiguration[NotAllSealedVariant] + agreeUnderEveryConfiguration[Entity] + } + + it should "hold for enumerations" in { + agreeUnderEveryConfiguration[SealedVariantContainer] + agreeUnderEveryConfiguration[Response] + agreeUnderEveryConfiguration[RichColorResponse] + agree(Pickler.derivedEnumeration[ColorEnum].customStringBased(_.ordinal.toString)) + agree(Pickler.derivedEnumeration[RichColorEnum].customStringBased(c => s"color-${c.code}")) + } + + it should "hold for recursive types" in { + agreeUnderEveryConfiguration[Tree] + agreeUnderEveryConfiguration[Node] + agreeUnderEveryConfiguration[MutualA] + } + + it should "hold for non-structural roots" in { + agree(Pickler.derived[List[FlatClass]]) + agree(Pickler.derived[Option[Status]]) + agree(Pickler.derived[Map[String, Tree]]) + agree(Pickler.derived[Either[List[Int], Status]]) + } + + it should "hold for a user pickler for a nested type" in { + given Pickler[SimpleTestResult] = + Pickler.derived[SimpleTestResult](using PicklerConfiguration.default.withScreamingSnakeCaseMemberNames) + agree(Pickler.derived[ClassWithMap]) + agree(Pickler.derived[ClassWithEither]) + } + + it should "hold for oneOfUsingField" in { + given Pickler[Status] = Pickler.oneOfUsingField[Status, Int](_.code, code => s"code-$code")( + 200 -> Pickler.derived[StatusOk], + 400 -> Pickler.derived[StatusBadRequest], + 500 -> Pickler.derived[StatusInternalError.type] + ) + agree(summon[Pickler[Status]]) + agree(Pickler.derived[StatusResponse]) + } +} diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaDerivationTest.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaDerivationTest.scala index 7e4d3e0b30..6f37a78105 100644 --- a/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaDerivationTest.scala +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaDerivationTest.scala @@ -15,7 +15,7 @@ import java.math.{BigDecimal => JBigDecimal, BigInteger => JBigInteger} class SchemaDerivationTest extends AsyncFlatSpec with Matchers with Inside { import SchemaDerivationTest._ - import generic.auto._ + import generic.auto.* def implicitlySchema[T: Pickler]: Schema[T] = summon[Pickler[T]].schema "Schema auto derivation" should "find schema for simple types" in { @@ -208,7 +208,10 @@ class SchemaDerivationTest extends AsyncFlatSpec with Matchers with Inside { val schema = implicitlySchema[Test1] // when - schema.name shouldBe Some(SName("sttp.tapir.json.pickler.SchemaDerivationTest..Test1")) + // No `` segment for a class defined inside a method: tapir core's + // `SNameMacros.typeFullNameFromTpe` skips synthetic `<...>` owners, and emitting them would leak a compiler-internal + // marker into OpenAPI component names. + schema.name shouldBe Some(SName("sttp.tapir.json.pickler.SchemaDerivationTest.Test1")) schema.schemaType shouldBe SProduct[Test1]( List( field(FieldName("f1"), implicitlySchema[String]), @@ -490,7 +493,7 @@ class SchemaDerivationTest extends AsyncFlatSpec with Matchers with Inside { } object SchemaDerivationTest { - import generic.auto._ + import generic.auto.* def implicitlySchema[A: Pickler]: Schema[A] = summon[Pickler[A]].schema private[json] val stringSchema = implicitlySchema[String] diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaJsonAgreement.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaJsonAgreement.scala new file mode 100644 index 0000000000..643829cc52 --- /dev/null +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaJsonAgreement.scala @@ -0,0 +1,140 @@ +package sttp.tapir.json.pickler + +import sttp.tapir.Schema.SName +import sttp.tapir.SchemaType.* +import sttp.tapir.{Schema, Validator} + +/** Checks that a JSON document has the shape a tapir `Schema` advertises: field names, optionality, discriminator field and values, + * enumeration values, primitive kinds. Returns every mismatch found, with a JSON path. + * + * This is the property the module exists for -- the codec and the schema come from one expansion, so the JSON the former writes must + * always be the JSON the latter documents -- and the one thing no per-fixture string assertion can establish in general. + */ +object SchemaJsonAgreement { + + def mismatches(schema: Schema[?], json: ujson.Value): List[String] = + check(schema, json, "$", collectNamed(schema, Map.empty)) + + /** Every named schema reachable from `schema`, so that an `SRef` can be followed. */ + private def collectNamed(schema: Schema[?], acc: Map[SName, Schema[?]]): Map[SName, Schema[?]] = { + val withSelf = schema.name match { + case Some(name) if acc.contains(name) => return acc + case Some(name) => acc + (name -> schema) + case None => acc + } + schema.schemaType match { + case p: SProduct[?] => p.fields.foldLeft(withSelf)((m, f) => collectNamed(f.schema, m)) + case c: SCoproduct[?] => c.subtypes.foldLeft(withSelf)((m, s) => collectNamed(s, m)) + case o: SOption[?, ?] => collectNamed(o.element, withSelf) + case a: SArray[?, ?] => collectNamed(a.element, withSelf) + case o: SOpenProduct[?, ?] => o.fields.foldLeft(collectNamed(o.valueSchema, withSelf))((m, f) => collectNamed(f.schema, m)) + case _ => withSelf + } + } + + private def check(s: Schema[?], j: ujson.Value, path: String, named: Map[SName, Schema[?]]): List[String] = + s.schemaType match { + case SRef(name) => + named.get(name) match { + case Some(target) => check(target, j, path, named) + case None => List(s"$path: unresolved SRef($name)") + } + case _ if j == ujson.Null => + if (s.isOptional) Nil else List(s"$path: null, but the schema is not optional") + case o: SOption[?, ?] => check(o.element, j, path, named) + case p: SProduct[?] => + j match { + case obj: ujson.Obj => + val fields = p.fields.map(f => f.name.encodedName -> f).toMap + val unknown = obj.value.keys.toList.filterNot(fields.contains).map(k => s"$path.$k: written but not in the schema") + val missing = fields.toList.collect { + case (n, f) if !f.schema.isOptional && !obj.value.contains(n) => s"$path.$n: required by the schema but absent" + } + val nested = obj.value.toList.flatMap { case (k, v) => + fields.get(k).toList.flatMap(f => check(f.schema, v, s"$path.$k", named)) + } + unknown ++ missing ++ nested + case other => List(s"$path: expected an object for SProduct, got ${kind(other)}") + } + case c: SCoproduct[?] => + c.discriminator match { + case Some(d) => + j match { + case obj: ujson.Obj => + obj.value.get(d.name.encodedName) match { + case Some(ujson.Str(value)) => + d.mapping.get(value) match { + case Some(ref) => + c.subtypes.find(_.name.contains(ref.name)) match { + case Some(sub) => check(sub, j, path, named) + case None => List(s"$path: discriminator '$value' maps to ${ref.name}, which is not a subtype") + } + case None => List(s"$path: discriminator value '$value' not in the documented mapping ${d.mapping.keySet}") + } + case _ => List(s"$path: discriminator field '${d.name.encodedName}' missing or not a string") + } + case other => List(s"$path: expected an object for a discriminated SCoproduct, got ${kind(other)}") + } + case None => + if (c.subtypes.exists(sub => check(sub, j, path, named).isEmpty)) Nil + else List(s"$path: matches none of the ${c.subtypes.size} subtypes of an untagged SCoproduct") + } + case a: SArray[?, ?] => + j match { + case arr: ujson.Arr => arr.value.toList.zipWithIndex.flatMap { case (e, i) => check(a.element, e, s"$path[$i]", named) } + case other => List(s"$path: expected an array for SArray, got ${kind(other)}") + } + case o: SOpenProduct[?, ?] => + j match { + case obj: ujson.Obj => + val fixed = o.fields.map(f => f.name.encodedName -> f).toMap + obj.value.toList.flatMap { case (k, v) => + fixed.get(k) match { + case Some(f) => check(f.schema, v, s"$path.$k", named) + case None => check(o.valueSchema, v, s"$path.$k", named) + } + } + case other => List(s"$path: expected an object for SOpenProduct, got ${kind(other)}") + } + case SString() => + j match { + case ujson.Str(str) => + enumerations(s.validator).flatMap { e => + val allowed = e.possibleValues.map(v => e.encode.flatMap(_.apply(v)).map(_.toString).getOrElse(v.toString)) + if (allowed.contains(str)) Nil else List(s"$path: '$str' is not one of the documented enumeration values $allowed") + } + case other => List(s"$path: expected a string for SString, got ${kind(other)}") + } + case SInteger() | SNumber() => + j match { + case _: ujson.Num => Nil + case other => List(s"$path: expected a number, got ${kind(other)}") + } + case SBoolean() => + j match { + case _: ujson.Bool => Nil + case other => List(s"$path: expected a boolean, got ${kind(other)}") + } + case SBinary() | SDate() | SDateTime() => + j match { + case _: ujson.Str => Nil + case other => List(s"$path: expected a string, got ${kind(other)}") + } + } + + private def enumerations(v: Validator[?]): List[Validator.Enumeration[Any]] = v match { + case e: Validator.Enumeration[?] => List(e.asInstanceOf[Validator.Enumeration[Any]]) + case Validator.All(vs) => vs.toList.flatMap(enumerations) + case Validator.Any(vs) => vs.toList.flatMap(enumerations) + case _ => Nil + } + + private def kind(j: ujson.Value): String = j match { + case _: ujson.Obj => "object" + case _: ujson.Arr => "array" + case _: ujson.Str => "string" + case _: ujson.Num => "number" + case _: ujson.Bool => "boolean" + case ujson.Null => "null" + } +} diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaRecursionTest.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaRecursionTest.scala new file mode 100644 index 0000000000..a486050a38 --- /dev/null +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaRecursionTest.scala @@ -0,0 +1,145 @@ +package sttp.tapir.json.pickler + +import org.scalatest.OptionValues +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.tapir.Schema.SName +import sttp.tapir.SchemaType.{SArray, SCoproduct, SOption, SProduct, SRef} +import sttp.tapir.{FieldName, Schema, ValidationError, Validator} + +/** Recursive-type support for the schema half of the derivation. The expectations are modelled on tapir core's own `Schema.derived`. + * + * ==The invariant under test== + * A recursive occurrence becomes `Schema(SRef(name))`. That is only useful if the `name` is *exactly* the `SName` carried by an enclosing + * schema, because `Schema.applyValidation` resolves a reference by looking it up in a `Map[SName, Schema]` accumulated during traversal. A + * merely *stable* name is not enough — a mismatch degrades silently to "no validation" rather than raising an error, which is why + * `should "resolve ..."` below is the load-bearing test in this file. + */ +class SchemaRecursionTest extends AnyFlatSpec with Matchers with OptionValues { + + // -- Fixtures --------------------------------------------------------------------------------------------------- + + case class RecList(children: List[RecList], value: Int) + case class RecOpt(child: Option[RecOpt], value: Int) + case class RecWrapper(data: Option[RecOpt]) + + case class MutualA(b: Option[MutualB], id: Int) + case class MutualB(a: Option[MutualA], id: Int) + + case class RecName(name: String, subNames: List[RecName]) + + // -- Helpers ---------------------------------------------------------------------------------------------------- + + private def productOf[T](schema: Schema[T]): SProduct[T] = + schema.schemaType.asInstanceOf[SProduct[T]] + + private def fieldSchema[T](schema: Schema[T], name: String): Schema[?] = + productOf(schema).fields.find(_.name.name == name).value.schema + + /** The element schema of a `List`/`Option` field. */ + private def elementOf(schema: Schema[?]): Schema[?] = schema.schemaType match { + case SArray(element) => element + case SOption(element) => element + case other => fail(s"expected a collection or option schema, got $other") + } + + private def refNameOf(schema: Schema[?]): SName = schema.schemaType match { + case SRef(name) => name + case other => fail(s"expected an SRef, got $other") + } + + // -- Structure -------------------------------------------------------------------------------------------------- + + behavior of "schema derivation for recursive types" + + it should "terminate and emit an SRef for a type that recurses through a collection" in { + val schema = Pickler.schemaFor[RecList] + + schema.name.value.fullName should endWith("RecList") + // The recursive occurrence must be a reference, not an inlined copy -- inlining cannot terminate. + refNameOf(elementOf(fieldSchema(schema, "children"))) shouldBe schema.name.value + } + + it should "terminate and emit an SRef for a type that recurses through an Option" in { + val schema = Pickler.schemaFor[RecOpt] + + refNameOf(elementOf(fieldSchema(schema, "child"))) shouldBe schema.name.value + } + + it should "emit an SRef whose SName is exactly the enclosing schema's name" in { + // Stated separately because this is the property `applyValidation` depends on, and a near-miss (a stable but + // different name) would pass every other assertion in this file while silently disabling validation. + val schema = Pickler.schemaFor[RecList] + val ref = refNameOf(elementOf(fieldSchema(schema, "children"))) + + ref shouldBe SName("sttp.tapir.json.pickler.SchemaRecursionTest.RecList") + ref shouldBe schema.name.value + } + + it should "handle mutual recursion between two case classes" in { + val schema = Pickler.schemaFor[MutualA] + + // B is expanded in full inside A ... + val b = elementOf(fieldSchema(schema, "b")) + b.name.value.fullName should endWith("MutualB") + // ... and only the second occurrence of A collapses to a reference. + refNameOf(elementOf(fieldSchema(b, "a"))) shouldBe schema.name.value + } + + it should "handle recursion through a sealed hierarchy" in { + val schema = Pickler.schemaFor[RecNode] + val subtypes = schema.schemaType.asInstanceOf[SCoproduct[RecNode]].subtypes + + subtypes.flatMap(_.name.map(_.fullName.split('.').last)) should contain theSameElementsAs List("RecEdge", "RecSimpleNode") + + val edge = subtypes.find(_.name.exists(_.fullName.endsWith("RecEdge"))).value + // `RecEdge.source: RecNode` points back at the coproduct itself. + refNameOf(fieldSchema(edge, "source")) shouldBe schema.name.value + } + + it should "derive a non-recursive type that merely contains a recursive one" in { + val schema = Pickler.schemaFor[RecWrapper] + val inner = elementOf(fieldSchema(schema, "data")) + + // Entering from outside the cycle, `RecOpt` is still expanded in full; only its own back-edge is a reference. + inner.name.value.fullName should endWith("RecOpt") + refNameOf(elementOf(fieldSchema(inner, "child"))) shouldBe inner.name.value + } + + it should "agree between the schema-only and full-pickler entry points" in { + Pickler.schemaFor[RecList] shouldBe Pickler.derived[RecList].schema + } + + // -- Validation ------------------------------------------------------------------------------------------------- + + it should "resolve the SRef when applying validation at depth" in { + // Mirrors core's own recursion test (core/src/test/scala/sttp/tapir/SchemaApplyValidationTest.scala:113-129): + // a validator on `String` must fire however deeply the recursive structure is nested. This only works if the + // SRef's SName matches an ancestor's -- otherwise `objects.get(name)` misses and validation quietly returns Nil. + implicit val stringSchema: Schema[String] = Schema.schemaForString.validate(Validator.minLength(1)) + val schema = Pickler.schemaFor[RecName] + + schema.applyValidation(RecName("x", Nil)) shouldBe Nil + + schema.applyValidation(RecName("", Nil)) shouldBe List( + ValidationError(Validator.minLength(1), "", List(FieldName("name"))) + ) + + schema.applyValidation(RecName("x", List(RecName("x", Nil)))) shouldBe Nil + + schema.applyValidation(RecName("x", List(RecName("", Nil)))) shouldBe List( + ValidationError(Validator.minLength(1), "", List(FieldName("subNames"), FieldName("name"))) + ) + + schema.applyValidation(RecName("x", List(RecName("x", List(RecName("", Nil)))))) shouldBe List( + ValidationError(Validator.minLength(1), "", List(FieldName("subNames"), FieldName("subNames"), FieldName("name"))) + ) + } +} + +// Declared at the top level: a sealed hierarchy nested inside the test class would make the fixture's own SName +// depend on the enclosing-class naming rules, which is `SchemaDerivationTest`'s business, not this file's. Prefixed +// `Rec` because that file declares a `Node`/`Edge` pair of its own in the same package. +sealed trait RecNode +case class RecEdge(id: Long, source: RecNode) extends RecNode +case class RecSimpleNode(id: Long) extends RecNode diff --git a/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaValidationTest.scala b/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaValidationTest.scala new file mode 100644 index 0000000000..c4a918feba --- /dev/null +++ b/json/pickler/src/test/scala/sttp/tapir/json/pickler/SchemaValidationTest.scala @@ -0,0 +1,87 @@ +package sttp.tapir.json.pickler + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.tapir.Schema.annotations.{encodedName, validate} +import sttp.tapir.SchemaType.SCoproduct +import sttp.tapir.{FieldName, Schema, ValidationError, Validator} + +/** `Schema.applyValidation` on derived coproducts, which is what `Codec.json` runs on every decoded body. + * + * Validation reaches a leaf's fields only through `SCoproduct.subtypeSchema`, the dispatch from a value to its leaf schema. The pickler + * generates it as a type-test `match`; a runtime class-name comparison would silently return `None` (and hence "valid") for every case + * below, because `getClass.getName` is `$`-mangled for nested classes and identical for all parameterless enum cases. + */ +class SchemaValidationTest extends AnyFlatSpec with Matchers { + import ValidationFixtures.* + + private val tooShort = ValidationError(Validator.minLength(3), "ab", List(FieldName("name"))) + + behavior of "coproduct validation" + + it should "reach the fields of a leaf nested in an object" in { + val schema = Pickler.derived[Pet].schema + schema.applyValidation(Dog("abc")) shouldBe Nil + schema.applyValidation(Dog("ab")) shouldBe List(tooShort) + schema.applyValidation(Cat("ab", 1)) shouldBe List(tooShort) + } + + it should "reach the fields of a leaf below an intermediate sealed trait" in { + val schema = Pickler.derived[Animal].schema + schema.applyValidation(Hamster("ab")) shouldBe List(tooShort) + schema.applyValidation(Hamster("abc")) shouldBe Nil + } + + it should "tell parameterless Scala 3 enum cases apart from case-class cases" in { + val schema = Pickler.derived[Shape].schema + schema.applyValidation(Shape.Circle(0)) shouldBe List(ValidationError(Validator.min(1), 0, List(FieldName("radius")))) + schema.applyValidation(Shape.Circle(2)) shouldBe Nil + schema.applyValidation(Shape.Unknown) shouldBe Nil + // and dispatch each value to its own leaf + val coproduct = schema.schemaType.asInstanceOf[SCoproduct[Shape]] + coproduct.subtypeSchema(Shape.Unknown).flatMap(_.schema.name).map(_.fullName.split('.').last) shouldBe Some("Unknown") + coproduct.subtypeSchema(Shape.Circle(1)).flatMap(_.schema.name).map(_.fullName.split('.').last) shouldBe Some("Circle") + } + + it should "dispatch to a leaf renamed with a type-level @encodedName" in { + val schema = Pickler.derived[Pet].schema + schema.schemaType.asInstanceOf[SCoproduct[Pet]].subtypeSchema(Fish("ab")).flatMap(_.schema.name) shouldBe Some(Schema.SName("Goldfish")) + schema.applyValidation(Fish("ab")) shouldBe List(tooShort) + } + + it should "hold for oneOfUsingField as well" in { + val schema = Pickler + .oneOfUsingField[Pet, Int](_.legs, legs => s"legs-$legs")( + 4 -> Pickler.derived[Dog], + 3 -> Pickler.derived[Cat], + 0 -> Pickler.derived[Fish] + ) + .schema + schema.applyValidation(Dog("ab")) shouldBe List(tooShort) + schema.applyValidation(Cat("abc", 1)) shouldBe Nil + } + + it should "validate through a coproduct field of a product" in { + val schema = Pickler.derived[Owner].schema + schema.applyValidation(Owner(Dog("ab"))) shouldBe List(tooShort.copy(path = List(FieldName("pet"), FieldName("name")))) + } +} + +object ValidationFixtures { + sealed trait Pet { def legs: Int } + case class Dog(@validate(Validator.minLength(3)) name: String) extends Pet { def legs = 4 } + case class Cat(@validate(Validator.minLength(3)) name: String, lives: Int) extends Pet { def legs = 3 } + @encodedName("Goldfish") + case class Fish(@validate(Validator.minLength(3)) name: String) extends Pet { def legs = 0 } + case class Owner(pet: Pet) + + sealed trait Animal + sealed trait Rodent extends Animal + case class Hamster(@validate(Validator.minLength(3)) name: String) extends Rodent + case class Bird(name: String) extends Animal + + enum Shape: + case Circle(@validate(Validator.min(1)) radius: Int) + case Square(side: Int) + case Unknown +} diff --git a/project/Versions.scala b/project/Versions.scala index d245271ed3..6fbed6309a 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -20,7 +20,7 @@ object Versions { val pekkoStreams = "1.7.0" val swaggerUi = "5.32.14" val upickle = "4.4.3" - val upickle3 = "3.3.1" + val hearth = "0.4.2" val playJson = "3.0.1" val play29Json = "3.0.6" val finatra = "24.2.0"