From 8875747d2677b00002b7128ebf92ff07ad0a3c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20M=C3=A9lois?= Date: Fri, 10 Jul 2026 00:08:48 +0100 Subject: [PATCH] Support @structurePattern targeting unions (#1978) * Support @structurePattern targeting unions Extend SchemaVisitorPatternDecoder and SchemaVisitorPatternEncoder to handle union schemas using the magic {label} and {value} pattern identifiers, as specified by the upcoming alloy change (disneystreaming/alloy#280). Also bump alloy to a dev-SNAPSHOT that relaxes the structurePattern validator to accept unions as targets, and exclude transitive alloy from smithytranslate to avoid version conflicts. Co-Authored-By: Claude Opus 4.6 * Add documentation and changelog for union structurePattern support Co-Authored-By: Claude Opus 4.6 * bump alloy to 0.3.40 * format and fix scala 2.12 compilation --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Jeff Lewis --- CHANGELOG.md | 4 + .../smithy4s/example/TestUnionPattern.scala | 18 +++ .../example/TestUnionPatternTarget.scala | 68 ++++++++++ .../generated/smithy4s/example/package.scala | 1 + ...ructurePatternRefinementProviderSpec.scala | 41 +++++++ .../SchemaVisitorPatternDecoder.scala | 116 +++++++++++++----- .../SchemaVisitorPatternEncoder.scala | 49 ++++++++ .../04-simple-rest-json/01-overview.md | 20 ++- project/Dependencies.scala | 6 +- sampleSpecs/structure_pattern.smithy | 8 ++ 10 files changed, 295 insertions(+), 36 deletions(-) create mode 100644 modules/bootstrapped/src/generated/smithy4s/example/TestUnionPattern.scala create mode 100644 modules/bootstrapped/src/generated/smithy4s/example/TestUnionPatternTarget.scala diff --git a/CHANGELOG.md b/CHANGELOG.md index f46aff5c4b..d472bbaba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ When adding entries, please treat them as if they could end up in a release any Thank you! +# 0.19.8 + +- Support `@structurePattern` targeting unions using `{label}` and `{value}` magic identifiers in [#1978](https://github.com/disneystreaming/smithy4s/pull/1978) + # 0.19.7 - codegen: Add a way to opt out of coursier's default repositories (e.g. Maven Central, ivy2Local) when resolving codegen dependencies. Sbt: set `smithy4sAllowDefaultRepositories := false`. Mill: override `def smithy4sAllowDefaultRepositories = false`. CLI: pass `--no-default-repositories` to `generate`/`dump-model`. Useful for users behind an internal repository proxy that mirrors Maven Central. Defaults to `true` for backwards compatibility. See [#1969](https://github.com/disneystreaming/smithy4s/issues/1969). diff --git a/modules/bootstrapped/src/generated/smithy4s/example/TestUnionPattern.scala b/modules/bootstrapped/src/generated/smithy4s/example/TestUnionPattern.scala new file mode 100644 index 0000000000..2cd4112c41 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/TestUnionPattern.scala @@ -0,0 +1,18 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Newtype +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.internals.StructurePatternRefinementProvider._ +import smithy4s.schema.Schema.bijection +import smithy4s.schema.Schema.string + +object TestUnionPattern extends Newtype[TestUnionPatternTarget] { + val id: ShapeId = ShapeId("smithy4s.example", "TestUnionPattern") + val hints: Hints = Hints( + Hints.dynamic(ShapeId("alloy", "structurePattern"), smithy4s.Document.obj("pattern" -> smithy4s.Document.fromString("{label}:{value}"), "target" -> smithy4s.Document.fromString("smithy4s.example#TestUnionPatternTarget"))), + ) + val underlyingSchema: Schema[TestUnionPatternTarget] = string.refined[TestUnionPatternTarget](alloy.StructurePattern(pattern = "{label}:{value}", target = smithy4s.ShapeId(namespace = "smithy4s.example", name = "TestUnionPatternTarget"))).withId(id).addHints(hints) + implicit val schema: Schema[TestUnionPattern] = bijection(underlyingSchema, asBijection) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/TestUnionPatternTarget.scala b/modules/bootstrapped/src/generated/smithy4s/example/TestUnionPatternTarget.scala new file mode 100644 index 0000000000..5bfef862d8 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/TestUnionPatternTarget.scala @@ -0,0 +1,68 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.schema.Schema.bijection +import smithy4s.schema.Schema.int +import smithy4s.schema.Schema.string +import smithy4s.schema.Schema.union + +sealed trait TestUnionPatternTarget extends scala.Product with scala.Serializable { self => + @inline final def widen: TestUnionPatternTarget = this + def $ordinal: Int + + object project { + def one: Option[String] = TestUnionPatternTarget.OneCase.alt.project.lift(self).map(_.one) + def two: Option[Int] = TestUnionPatternTarget.TwoCase.alt.project.lift(self).map(_.two) + } + + def accept[A](visitor: TestUnionPatternTarget.Visitor[A]): A = this match { + case value: TestUnionPatternTarget.OneCase => visitor.one(value.one) + case value: TestUnionPatternTarget.TwoCase => visitor.two(value.two) + } +} +object TestUnionPatternTarget extends ShapeTag.Companion[TestUnionPatternTarget] { + + def one(one: String): TestUnionPatternTarget = OneCase(one) + def two(two: Int): TestUnionPatternTarget = TwoCase(two) + + val id: ShapeId = ShapeId("smithy4s.example", "TestUnionPatternTarget") + + val hints: Hints = Hints.empty + + final case class OneCase(one: String) extends TestUnionPatternTarget { final def $ordinal: Int = 0 } + final case class TwoCase(two: Int) extends TestUnionPatternTarget { final def $ordinal: Int = 1 } + + object OneCase { + val hints: Hints = Hints.empty + val schema: Schema[TestUnionPatternTarget.OneCase] = bijection(string.addHints(hints), TestUnionPatternTarget.OneCase(_), _.one) + val alt = schema.oneOf[TestUnionPatternTarget]("one") + } + object TwoCase { + val hints: Hints = Hints.empty + val schema: Schema[TestUnionPatternTarget.TwoCase] = bijection(int.addHints(hints), TestUnionPatternTarget.TwoCase(_), _.two) + val alt = schema.oneOf[TestUnionPatternTarget]("two") + } + + trait Visitor[A] { + def one(value: String): A + def two(value: Int): A + } + + object Visitor { + trait Default[A] extends Visitor[A] { + def default: A + def one(value: String): A = default + def two(value: Int): A = default + } + } + + implicit val schema: Schema[TestUnionPatternTarget] = union[TestUnionPatternTarget]( + TestUnionPatternTarget.OneCase.alt, + TestUnionPatternTarget.TwoCase.alt, + ){ + _.$ordinal + }.withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/package.scala b/modules/bootstrapped/src/generated/smithy4s/example/package.scala index 6a92258d16..d49edc153c 100644 --- a/modules/bootstrapped/src/generated/smithy4s/example/package.scala +++ b/modules/bootstrapped/src/generated/smithy4s/example/package.scala @@ -129,6 +129,7 @@ package object example { type TestIdRefValueMap = smithy4s.example.TestIdRefValueMap.Type type TestString = smithy4s.example.TestString.Type type TestStructurePattern = smithy4s.example.TestStructurePattern.Type + type TestUnionPattern = smithy4s.example.TestUnionPattern.Type type UVIndex = smithy4s.example.UVIndex.Type type UnicodeRegexString = smithy4s.example.UnicodeRegexString.Type type UnwrappedFancyList = smithy4s.example.UnwrappedFancyList.Type diff --git a/modules/bootstrapped/test/src/smithy4s/internals/StructurePatternRefinementProviderSpec.scala b/modules/bootstrapped/test/src/smithy4s/internals/StructurePatternRefinementProviderSpec.scala index 07329297ed..bfea9f46ed 100644 --- a/modules/bootstrapped/test/src/smithy4s/internals/StructurePatternRefinementProviderSpec.scala +++ b/modules/bootstrapped/test/src/smithy4s/internals/StructurePatternRefinementProviderSpec.scala @@ -18,6 +18,7 @@ package smithy4s.internals import munit._ import smithy4s._ +import smithy4s.schema.Alt import smithy4s.schema.Schema._ import java.util.UUID import smithy4s.example.OpenEnumTest @@ -156,6 +157,46 @@ final class StructurePatternRefinementProviderSpec extends FunSuite { runEncode(pattern, in, expect) } + sealed trait TestUnion extends Product with Serializable { + def $ordinal: Int + } + object TestUnion { + case class OneCase(value: String) extends TestUnion { + def $ordinal: Int = 0 + } + case class TwoCase(value: Int) extends TestUnion { + def $ordinal: Int = 1 + } + + val oneAlt: Alt[TestUnion, String] = + string.oneOf[TestUnion]("one", OneCase(_)) { case OneCase(v) => v } + val twoAlt: Alt[TestUnion, Int] = + int.oneOf[TestUnion]("two", TwoCase(_)) { case TwoCase(v) => v } + + implicit val schema: Schema[TestUnion] = + union(oneAlt, twoAlt) { _.$ordinal } + } + + test("union encoding") { + val one: TestUnion = TestUnion.OneCase("hello") + val two: TestUnion = TestUnion.TwoCase(42) + runEncode("{label}:{value}", one, "one:hello") + runEncode("{label}:{value}", two, "two:42") + runEncode("[{label}]={value}", one, "[one]=hello") + runEncode("{label}/{value}!", two, "two/42!") + } + + test("union decoding") { + val one: TestUnion = TestUnion.OneCase("hello") + val two: TestUnion = TestUnion.TwoCase(42) + runDecode("{label}:{value}", "one:hello", one) + runDecode("{label}:{value}", "two:42", two) + runDecode("[{label}]={value}", "[one]=hello", one) + runDecode("{label}/{value}!", "two/42!", two) + runDecode("{label}:{value}", "unknown:foo", one, shouldFail = true) + runDecode("{label}:{value}", "one:", one, shouldFail = true) + } + private def runEncode[A](pattern: String, input: A, expect: String)(implicit sch: Schema[A], loc: Location diff --git a/modules/core/src/smithy4s/internals/SchemaVisitorPatternDecoder.scala b/modules/core/src/smithy4s/internals/SchemaVisitorPatternDecoder.scala index 40c57f5b17..1b73b8324a 100644 --- a/modules/core/src/smithy4s/internals/SchemaVisitorPatternDecoder.scala +++ b/modules/core/src/smithy4s/internals/SchemaVisitorPatternDecoder.scala @@ -108,39 +108,7 @@ private[internals] final class SchemaVisitorPatternDecoder( ) } PatternDecode.from { input => - val (fieldStrings, leftOverInput) = - segments.foldLeft((Map.empty[String, String], input)) { - case ((acc, remainingInput), segment) => - segment match { - case PatternSegment.StaticSegment(value) => - val length = value.length - val taken = remainingInput.take(length) - if (taken != value) - throw StructurePatternError( - s"Incorrect pattern, expected '$value' but found '$taken'" - ) - else (acc, remainingInput.drop(length)) - case PatternSegment.ParameterSegment( - paramName, - terminationChar - ) => - val paramValue = - remainingInput.takeWhile(i => !terminationChar.contains(i)) - if (paramValue.isEmpty) - throw StructurePatternError( - "Empty parameter value encountered" - ) - ( - acc + (paramName -> paramValue), - remainingInput.drop(paramValue.length) - ) - } - } - - if (leftOverInput.nonEmpty) - throw StructurePatternError( - s"Extra characters found in input string '$leftOverInput'" - ) + val fieldStrings = extractMemberStrings(input) val decodedFields = fieldDecoders.map { case (fieldLabel, fieldDecoder) => fieldStrings.get(fieldLabel) match { @@ -154,6 +122,88 @@ private[internals] final class SchemaVisitorPatternDecoder( } } + override def union[U]( + shapeId: ShapeId, + hints: Hints, + alternatives: Vector[Alt[U, _]], + dispatch: Alt.Dispatcher[U] + ): MaybePatternDecode[U] = { + val altsByLabel: Map[String, Alt[U, _]] = { + val builder = Map.newBuilder[String, Alt[U, _]] + alternatives.foreach(a => builder += (a.label -> a)) + builder.result() + } + PatternDecode.from { input => + val fieldStrings = extractMemberStrings(input) + val label = fieldStrings.getOrElse( + "label", + throw StructurePatternError( + "Union pattern must contain a {label} parameter" + ) + ) + val value = fieldStrings.getOrElse( + "value", + throw StructurePatternError( + "Union pattern must contain a {value} parameter" + ) + ) + val alt = altsByLabel.getOrElse( + label, + throw StructurePatternError( + s"Unknown union alternative '$label'" + ) + ) + decodeAlt(alt, value) + } + } + + private def decodeAlt[U, A](alt: Alt[U, A], value: String): U = { + val decoder = self(alt.schema).getOrElse( + throw StructurePatternError( + s"Unable to create decoder for alternative '${alt.label}'" + ) + ) + alt.inject(decoder.decode(value)) + } + + private def extractMemberStrings(input: String): Map[String, String] = { + val (fieldStrings, leftOverInput) = + segments.foldLeft((Map.empty[String, String], input)) { + case ((acc, remainingInput), segment) => + segment match { + case PatternSegment.StaticSegment(value) => + val length = value.length + val taken = remainingInput.take(length) + if (taken != value) + throw StructurePatternError( + s"Incorrect pattern, expected '$value' but found '$taken'" + ) + else (acc, remainingInput.drop(length)) + case PatternSegment.ParameterSegment( + paramName, + terminationChar + ) => + val paramValue = + remainingInput.takeWhile(i => !terminationChar.contains(i)) + if (paramValue.isEmpty) + throw StructurePatternError( + "Empty parameter value encountered" + ) + ( + acc + (paramName -> paramValue), + remainingInput.drop(paramValue.length) + ) + } + } + + if (leftOverInput.nonEmpty) + throw StructurePatternError( + s"Extra characters found in input string '$leftOverInput'" + ) + + fieldStrings + } + override def biject[A, B]( schema: Schema[A], bijection: Bijection[A, B] diff --git a/modules/core/src/smithy4s/internals/SchemaVisitorPatternEncoder.scala b/modules/core/src/smithy4s/internals/SchemaVisitorPatternEncoder.scala index d7e4d0e10c..27c35f3f19 100644 --- a/modules/core/src/smithy4s/internals/SchemaVisitorPatternEncoder.scala +++ b/modules/core/src/smithy4s/internals/SchemaVisitorPatternEncoder.scala @@ -91,6 +91,55 @@ private[internals] final class SchemaVisitorPatternEncoder( } } + override def union[U]( + shapeId: ShapeId, + hints: Hints, + alternatives: Vector[Alt[U, _]], + dispatch: Alt.Dispatcher[U] + ): MaybePathEncode[U] = { + type Writer = U => List[String] + + def compileAlt[A](alt: Alt[U, A]): Option[A => List[String]] = { + self(alt.schema).map(_.encode) + } + + val altEncoders: Option[Map[Int, Any => List[String]]] = { + val entries = alternatives.zipWithIndex.map { case (alt, idx) => + compileAlt(alt).map(enc => idx -> enc.asInstanceOf[Any => List[String]]) + } + entries.traverse(identity).map(_.toMap) + } + + def compile1(path: PatternSegment): Option[Writer] = path match { + case PatternSegment.StaticSegment(value) => + Some(Function.const(List(value))) + case PatternSegment.ParameterSegment("label", _) => + Some { (u: U) => + val ord = dispatch.ordinal(u) + List(alternatives(ord).label) + } + case PatternSegment.ParameterSegment("value", _) => + altEncoders.map { encoders => (u: U) => + val ord = dispatch.ordinal(u) + val projected = alternatives(ord).project(u) + encoders(ord)(projected) + } + case PatternSegment.ParameterSegment(_, _) => + None + } + + def compilePath(path: Vector[PatternSegment]): Option[Vector[Writer]] = + path.traverse(compile1(_)) + + for { + writers <- compilePath(segments.toVector) + } yield new PathEncode[U] { + override def encode(u: U): List[String] = + writers.flatMap(_.apply(u)).toList + override def encodeGreedy(u: U): List[String] = Nil + } + } + override def biject[A, B]( schema: Schema[A], bijection: Bijection[A, B] diff --git a/modules/docs/resources/markdown/03-protocols/04-simple-rest-json/01-overview.md b/modules/docs/resources/markdown/03-protocols/04-simple-rest-json/01-overview.md index c8b0d5fff7..cdc003a56f 100644 --- a/modules/docs/resources/markdown/03-protocols/04-simple-rest-json/01-overview.md +++ b/modules/docs/resources/markdown/03-protocols/04-simple-rest-json/01-overview.md @@ -140,4 +140,22 @@ structure FooBar { } ``` -Now wherever `FooBarString` is used, it will really be parsing the string into the structure `FooBar`. As such, the generated code will replace instances of `FooBarString` with `FooBar` such that the parsing logic is abstracted away from your implementation. See the [alloy documentation](https://github.com/disneystreaming/alloy#alloystructurepattern) for more information. +Now wherever `FooBarString` is used, it will really be parsing the string into the structure `FooBar`. As such, the generated code will replace instances of `FooBarString` with `FooBar` such that the parsing logic is abstracted away from your implementation. + +### Union targets + +As of smithy4s version `0.19.8`, `@structurePattern` also supports targeting unions. When the target is a union, the pattern must use the magic identifiers `{label}` (for the discriminator) and `{value}` (for the payload). For example: + +```smithy +@structurePattern(pattern: "{label}:{value}", target: MyUnion) +string MyUnionString + +union MyUnion { + name: String + age: Integer +} +``` + +A string like `"name:John"` will be parsed into the `name` alternative of `MyUnion` with value `"John"`, and `"age:30"` into the `age` alternative with value `30`. + +See the [alloy documentation](https://github.com/disneystreaming/alloy#alloystructurepattern) for more information. diff --git a/project/Dependencies.scala b/project/Dependencies.scala index e9ffb7aaf5..db290976c3 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -32,7 +32,7 @@ object Dependencies { val Alloy = new { val org = "com.disneystreaming.alloy" - val alloyVersion = "0.3.39" + val alloyVersion = "0.3.40" val core = org % "alloy-core" % alloyVersion val openapi = org %% "alloy-openapi" % alloyVersion val protobuf = org % "alloy-protobuf" % alloyVersion @@ -42,7 +42,9 @@ object Dependencies { val Smithytranslate = new { val org = "com.disneystreaming.smithy" val smithyTranslateVersion = "0.7.6" - val proto = org %% "smithytranslate-proto" % smithyTranslateVersion + val proto = + (org %% "smithytranslate-proto" % smithyTranslateVersion) + .excludeAll(ExclusionRule(organization = "com.disneystreaming.alloy")) } val Cats = new { diff --git a/sampleSpecs/structure_pattern.smithy b/sampleSpecs/structure_pattern.smithy index 8bb91cfd51..a7dd3098a6 100644 --- a/sampleSpecs/structure_pattern.smithy +++ b/sampleSpecs/structure_pattern.smithy @@ -14,3 +14,11 @@ structure TestStructurePatternTarget { @required two: Integer } + +@structurePattern(pattern: "{label}:{value}", target: TestUnionPatternTarget) +string TestUnionPattern + +union TestUnionPatternTarget { + one: String + two: Integer +}