Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
116 changes: 83 additions & 33 deletions modules/core/src/smithy4s/internals/SchemaVisitorPatternDecoder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 4 additions & 2 deletions project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Loading
Loading