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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 17 additions & 4 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading