diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b0e8fa3..343cba19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ typeclasses such as `cats.Show`, `cats.Eq` or any others you may be using. See https://github.com/disneystreaming/smithy4s/pull/912 +### Smithy4s Optics Instances + +When the smithy4sRenderOptics setting is enabled, Lenses and Prisms will be rendered in the companion objects of the generated code when appropriate. + +See https://github.com/disneystreaming/smithy4s/pull/1103 + # 0.17.5 This release is backward binary-compatible with the previous releases from the 0.17.x lineage. diff --git a/build.sbt b/build.sbt index 9cee9ccbc..503c111c3 100644 --- a/build.sbt +++ b/build.sbt @@ -133,7 +133,8 @@ lazy val docs = Dependencies.Http4s.emberClient.value, Dependencies.Http4s.emberServer.value, Dependencies.Decline.effect.value, - Dependencies.AwsSpecSummary.value + Dependencies.AwsSpecSummary.value, + Dependencies.Monocle.core.value ) ) .settings(Smithy4sBuildPlugin.doNotPublishArtifact) diff --git a/modules/bootstrapped/src/generated/smithy4s/example/EchoInput.scala b/modules/bootstrapped/src/generated/smithy4s/example/EchoInput.scala index 396b282f4..7149a232b 100644 --- a/modules/bootstrapped/src/generated/smithy4s/example/EchoInput.scala +++ b/modules/bootstrapped/src/generated/smithy4s/example/EchoInput.scala @@ -11,9 +11,7 @@ final case class EchoInput(pathParam: String, body: EchoBody, queryParam: Option object EchoInput extends ShapeTag.Companion[EchoInput] { val id: ShapeId = ShapeId("smithy4s.example", "EchoInput") - val hints: Hints = Hints( - smithy.api.Input(), - ) + val hints: Hints = Hints.empty implicit val schema: Schema[EchoInput] = struct( string.validated(smithy.api.Length(min = Some(10L), max = None)).required[EchoInput]("pathParam", _.pathParam).addHints(smithy.api.Required(), smithy.api.HttpLabel()), diff --git a/modules/bootstrapped/src/generated/smithy4s/example/ForecastResult.scala b/modules/bootstrapped/src/generated/smithy4s/example/ForecastResult.scala index 608e37893..a6a698cd9 100644 --- a/modules/bootstrapped/src/generated/smithy4s/example/ForecastResult.scala +++ b/modules/bootstrapped/src/generated/smithy4s/example/ForecastResult.scala @@ -4,6 +4,7 @@ import smithy4s.Hints import smithy4s.Schema import smithy4s.ShapeId import smithy4s.ShapeTag +import smithy4s.optics.Prism import smithy4s.schema.Schema.bijection import smithy4s.schema.Schema.union @@ -16,6 +17,11 @@ object ForecastResult extends ShapeTag.Companion[ForecastResult] { val hints: Hints = Hints.empty + object optics { + val rain: Prism[ForecastResult, ChanceOfRain] = Prism.partial[ForecastResult, ChanceOfRain]{ case RainCase(t) => t }(RainCase.apply) + val sun: Prism[ForecastResult, UVIndex] = Prism.partial[ForecastResult, UVIndex]{ case SunCase(t) => t }(SunCase.apply) + } + final case class RainCase(rain: ChanceOfRain) extends ForecastResult { final def _ordinal: Int = 0 } def rain(rain:ChanceOfRain): ForecastResult = RainCase(rain) final case class SunCase(sun: UVIndex) extends ForecastResult { final def _ordinal: Int = 1 } diff --git a/modules/bootstrapped/src/generated/smithy4s/example/GetCityInput.scala b/modules/bootstrapped/src/generated/smithy4s/example/GetCityInput.scala index ec176f0be..e131b2c80 100644 --- a/modules/bootstrapped/src/generated/smithy4s/example/GetCityInput.scala +++ b/modules/bootstrapped/src/generated/smithy4s/example/GetCityInput.scala @@ -4,6 +4,7 @@ import smithy4s.Hints import smithy4s.Schema import smithy4s.ShapeId import smithy4s.ShapeTag +import smithy4s.optics.Lens import smithy4s.schema.Schema.struct final case class GetCityInput(cityId: CityId) @@ -12,6 +13,10 @@ object GetCityInput extends ShapeTag.Companion[GetCityInput] { val hints: Hints = Hints.empty + object optics { + val cityId: Lens[GetCityInput, CityId] = Lens[GetCityInput, CityId](_.cityId)(n => a => a.copy(cityId = n)) + } + implicit val schema: Schema[GetCityInput] = struct( CityId.schema.required[GetCityInput]("cityId", _.cityId).addHints(smithy.api.Required()), ){ diff --git a/modules/bootstrapped/src/generated/smithy4s/example/GetForecastOutput.scala b/modules/bootstrapped/src/generated/smithy4s/example/GetForecastOutput.scala index 8dc2322df..8b68e4336 100644 --- a/modules/bootstrapped/src/generated/smithy4s/example/GetForecastOutput.scala +++ b/modules/bootstrapped/src/generated/smithy4s/example/GetForecastOutput.scala @@ -4,6 +4,7 @@ import smithy4s.Hints import smithy4s.Schema import smithy4s.ShapeId import smithy4s.ShapeTag +import smithy4s.optics.Lens import smithy4s.schema.Schema.struct final case class GetForecastOutput(forecast: Option[ForecastResult] = None) @@ -12,6 +13,10 @@ object GetForecastOutput extends ShapeTag.Companion[GetForecastOutput] { val hints: Hints = Hints.empty + object optics { + val forecast: Lens[GetForecastOutput, Option[ForecastResult]] = Lens[GetForecastOutput, Option[ForecastResult]](_.forecast)(n => a => a.copy(forecast = n)) + } + implicit val schema: Schema[GetForecastOutput] = struct( ForecastResult.schema.optional[GetForecastOutput]("forecast", _.forecast), ){ diff --git a/modules/bootstrapped/src/generated/smithy4s/example/OpticsEnum.scala b/modules/bootstrapped/src/generated/smithy4s/example/OpticsEnum.scala new file mode 100644 index 000000000..d5ebb80ec --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/OpticsEnum.scala @@ -0,0 +1,40 @@ +package smithy4s.example + +import smithy4s.Enumeration +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.optics.Prism +import smithy4s.schema.EnumTag +import smithy4s.schema.Schema.enumeration + +sealed abstract class OpticsEnum(_value: String, _name: String, _intValue: Int, _hints: Hints) extends Enumeration.Value { + override type EnumType = OpticsEnum + override val value: String = _value + override val name: String = _name + override val intValue: Int = _intValue + override val hints: Hints = _hints + override def enumeration: Enumeration[EnumType] = OpticsEnum + @inline final def widen: OpticsEnum = this +} +object OpticsEnum extends Enumeration[OpticsEnum] with ShapeTag.Companion[OpticsEnum] { + val id: ShapeId = ShapeId("smithy4s.example", "OpticsEnum") + + val hints: Hints = Hints.empty + + object optics { + val A: Prism[OpticsEnum, OpticsEnum.A.type] = Prism.partial[OpticsEnum, OpticsEnum.A.type]{ case OpticsEnum.A => OpticsEnum.A }(identity) + val B: Prism[OpticsEnum, OpticsEnum.B.type] = Prism.partial[OpticsEnum, OpticsEnum.B.type]{ case OpticsEnum.B => OpticsEnum.B }(identity) + } + + case object A extends OpticsEnum("A", "A", 0, Hints()) + case object B extends OpticsEnum("B", "B", 1, Hints()) + + val values: List[OpticsEnum] = List( + A, + B, + ) + val tag: EnumTag = EnumTag.StringEnum + implicit val schema: Schema[OpticsEnum] = enumeration(tag, values).withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/OpticsStructure.scala b/modules/bootstrapped/src/generated/smithy4s/example/OpticsStructure.scala new file mode 100644 index 000000000..f948ecd72 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/OpticsStructure.scala @@ -0,0 +1,25 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.optics.Lens +import smithy4s.schema.Schema.struct + +final case class OpticsStructure(two: Option[OpticsEnum] = None) +object OpticsStructure extends ShapeTag.Companion[OpticsStructure] { + val id: ShapeId = ShapeId("smithy4s.example", "OpticsStructure") + + val hints: Hints = Hints.empty + + object optics { + val two: Lens[OpticsStructure, Option[OpticsEnum]] = Lens[OpticsStructure, Option[OpticsEnum]](_.two)(n => a => a.copy(two = n)) + } + + implicit val schema: Schema[OpticsStructure] = struct( + OpticsEnum.schema.optional[OpticsStructure]("two", _.two), + ){ + OpticsStructure.apply + }.withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/OpticsUnion.scala b/modules/bootstrapped/src/generated/smithy4s/example/OpticsUnion.scala new file mode 100644 index 000000000..cc4e810c7 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/OpticsUnion.scala @@ -0,0 +1,38 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.optics.Prism +import smithy4s.schema.Schema.bijection +import smithy4s.schema.Schema.union + +sealed trait OpticsUnion extends scala.Product with scala.Serializable { + @inline final def widen: OpticsUnion = this + def _ordinal: Int +} +object OpticsUnion extends ShapeTag.Companion[OpticsUnion] { + val id: ShapeId = ShapeId("smithy4s.example", "OpticsUnion") + + val hints: Hints = Hints.empty + + object optics { + val one: Prism[OpticsUnion, OpticsStructure] = Prism.partial[OpticsUnion, OpticsStructure]{ case OneCase(t) => t }(OneCase.apply) + } + + final case class OneCase(one: OpticsStructure) extends OpticsUnion { final def _ordinal: Int = 0 } + def one(one:OpticsStructure): OpticsUnion = OneCase(one) + + object OneCase { + val hints: Hints = Hints.empty + val schema: Schema[OneCase] = bijection(OpticsStructure.schema.addHints(hints), OneCase(_), _.one) + val alt = schema.oneOf[OpticsUnion]("one") + } + + implicit val schema: Schema[OpticsUnion] = union( + OneCase.alt, + ){ + _._ordinal + }.withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/PersonContactInfo.scala b/modules/bootstrapped/src/generated/smithy4s/example/PersonContactInfo.scala index 300675ff9..d43fb8a28 100644 --- a/modules/bootstrapped/src/generated/smithy4s/example/PersonContactInfo.scala +++ b/modules/bootstrapped/src/generated/smithy4s/example/PersonContactInfo.scala @@ -5,6 +5,7 @@ import smithy4s.Schema import smithy4s.ShapeId import smithy4s.ShapeTag import smithy4s.interopcats.SchemaVisitorHash +import smithy4s.optics.Prism import smithy4s.schema.Schema.bijection import smithy4s.schema.Schema.union @@ -19,6 +20,11 @@ object PersonContactInfo extends ShapeTag.Companion[PersonContactInfo] { smithy4s.example.Hash(), ) + object optics { + val email: Prism[PersonContactInfo, PersonEmail] = Prism.partial[PersonContactInfo, PersonEmail]{ case EmailCase(t) => t }(EmailCase.apply) + val phone: Prism[PersonContactInfo, PersonPhoneNumber] = Prism.partial[PersonContactInfo, PersonPhoneNumber]{ case PhoneCase(t) => t }(PhoneCase.apply) + } + final case class EmailCase(email: PersonEmail) extends PersonContactInfo { final def _ordinal: Int = 0 } def email(email:PersonEmail): PersonContactInfo = EmailCase(email) final case class PhoneCase(phone: PersonPhoneNumber) extends PersonContactInfo { final def _ordinal: Int = 1 } diff --git a/modules/bootstrapped/src/generated/smithy4s/example/Podcast.scala b/modules/bootstrapped/src/generated/smithy4s/example/Podcast.scala index a2c722369..bfba07485 100644 --- a/modules/bootstrapped/src/generated/smithy4s/example/Podcast.scala +++ b/modules/bootstrapped/src/generated/smithy4s/example/Podcast.scala @@ -4,6 +4,8 @@ import smithy4s.Hints import smithy4s.Schema import smithy4s.ShapeId import smithy4s.ShapeTag +import smithy4s.optics.Lens +import smithy4s.optics.Prism import smithy4s.schema.Schema.long import smithy4s.schema.Schema.string import smithy4s.schema.Schema.struct @@ -18,6 +20,11 @@ object Podcast extends ShapeTag.Companion[Podcast] { val hints: Hints = Hints.empty + object optics { + val video: Prism[Podcast, Video] = Prism.partial[Podcast, Video]{ case t: Video => t }(identity) + val audio: Prism[Podcast, Audio] = Prism.partial[Podcast, Audio]{ case t: Audio => t }(identity) + } + final case class Video(title: Option[String] = None, url: Option[String] = None, durationMillis: Option[Long] = None) extends Podcast { def _ordinal: Int = 0 } @@ -26,6 +33,12 @@ object Podcast extends ShapeTag.Companion[Podcast] { val hints: Hints = Hints.empty + object optics { + val title: Lens[Video, Option[String]] = Lens[Video, Option[String]](_.title)(n => a => a.copy(title = n)) + val url: Lens[Video, Option[String]] = Lens[Video, Option[String]](_.url)(n => a => a.copy(url = n)) + val durationMillis: Lens[Video, Option[Long]] = Lens[Video, Option[Long]](_.durationMillis)(n => a => a.copy(durationMillis = n)) + } + val schema: Schema[Video] = struct( string.optional[Video]("title", _.title), string.optional[Video]("url", _.url), @@ -44,6 +57,12 @@ object Podcast extends ShapeTag.Companion[Podcast] { val hints: Hints = Hints.empty + object optics { + val title: Lens[Audio, Option[String]] = Lens[Audio, Option[String]](_.title)(n => a => a.copy(title = n)) + val url: Lens[Audio, Option[String]] = Lens[Audio, Option[String]](_.url)(n => a => a.copy(url = n)) + val durationMillis: Lens[Audio, Option[Long]] = Lens[Audio, Option[Long]](_.durationMillis)(n => a => a.copy(durationMillis = n)) + } + val schema: Schema[Audio] = struct( string.optional[Audio]("title", _.title), string.optional[Audio]("url", _.url), diff --git a/modules/bootstrapped/src/generated/smithy4s/example/TestBody.scala b/modules/bootstrapped/src/generated/smithy4s/example/TestBody.scala new file mode 100644 index 000000000..6f2ba1ae2 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/TestBody.scala @@ -0,0 +1,26 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.optics.Lens +import smithy4s.schema.Schema.string +import smithy4s.schema.Schema.struct + +final case class TestBody(data: Option[String] = None) +object TestBody extends ShapeTag.Companion[TestBody] { + val id: ShapeId = ShapeId("smithy4s.example", "TestBody") + + val hints: Hints = Hints.empty + + object optics { + val data: Lens[TestBody, Option[String]] = Lens[TestBody, Option[String]](_.data)(n => a => a.copy(data = n)) + } + + implicit val schema: Schema[TestBody] = struct( + string.validated(smithy.api.Length(min = Some(10L), max = None)).optional[TestBody]("data", _.data), + ){ + TestBody.apply + }.withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/TestInput.scala b/modules/bootstrapped/src/generated/smithy4s/example/TestInput.scala new file mode 100644 index 000000000..9c9655cf1 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/TestInput.scala @@ -0,0 +1,30 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.optics.Lens +import smithy4s.schema.Schema.string +import smithy4s.schema.Schema.struct + +final case class TestInput(pathParam: String, body: TestBody, queryParam: Option[String] = None) +object TestInput extends ShapeTag.Companion[TestInput] { + val id: ShapeId = ShapeId("smithy4s.example", "TestInput") + + val hints: Hints = Hints.empty + + object optics { + val pathParam: Lens[TestInput, String] = Lens[TestInput, String](_.pathParam)(n => a => a.copy(pathParam = n)) + val body: Lens[TestInput, TestBody] = Lens[TestInput, TestBody](_.body)(n => a => a.copy(body = n)) + val queryParam: Lens[TestInput, Option[String]] = Lens[TestInput, Option[String]](_.queryParam)(n => a => a.copy(queryParam = n)) + } + + implicit val schema: Schema[TestInput] = struct( + string.validated(smithy.api.Length(min = Some(10L), max = None)).required[TestInput]("pathParam", _.pathParam).addHints(smithy.api.Required(), smithy.api.HttpLabel()), + TestBody.schema.required[TestInput]("body", _.body).addHints(smithy.api.HttpPayload(), smithy.api.Required()), + string.validated(smithy.api.Length(min = Some(10L), max = None)).optional[TestInput]("queryParam", _.queryParam).addHints(smithy.api.HttpQuery("queryParam")), + ){ + TestInput.apply + }.withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/test/src/smithy4s/optics/CompositionSpec.scala b/modules/bootstrapped/test/src/smithy4s/optics/CompositionSpec.scala new file mode 100644 index 000000000..61d481f74 --- /dev/null +++ b/modules/bootstrapped/test/src/smithy4s/optics/CompositionSpec.scala @@ -0,0 +1,110 @@ +package smithy4s.optics + +import munit._ +import smithy4s.example._ + +final class CompositionSpec extends FunSuite { + + test("Lens transformation and composition") { + val input = TestInput("test", TestBody(Some("test body"))) + val lens = + TestInput.optics.body.andThen(TestBody.optics.data).some[String] + val resultGet = lens.project(input) + + val resultSet = + lens.replace("new body")(input) + + val updatedInput = TestInput("test", TestBody(Some("new body"))) + assertEquals(Option("test body"), resultGet) + assertEquals(updatedInput, resultSet) + } + + test("Prism transformation and composition") { + val input = Podcast.Video(Some("Pod Title")) + + val prism = + Podcast.optics.video + .andThen(Podcast.Video.optics.title) + .some[String] + val result = prism.replace("New Pod Title")(input) + + assertEquals(Podcast.Video(Some("New Pod Title")).widen, result) + } + + test("nested") { + val input = GetForecastOutput( + Some(ForecastResult.SunCase(UVIndex(6))) + ) + + val uvIndex = GetForecastOutput.optics.forecast + .some[ForecastResult] + .andThen(ForecastResult.optics.sun) + .value + val updated = uvIndex.replace(8)(input) + + val result = uvIndex.project(updated) + + assertEquals(Option(8), result) + } + + test("enum prisms") { + val input = OpticsStructure(Some(OpticsEnum.A)) + + val base = + OpticsStructure.optics.two + .some[OpticsEnum] + .andThen(OpticsEnum.optics.A) + val baseB = + OpticsStructure.optics.two + .some[OpticsEnum] + .andThen(OpticsEnum.optics.B) + + val result = base.project(input) + val result2 = baseB.project(input) + + assertEquals(Option(OpticsEnum.A), result) + assertEquals(Option.empty[OpticsEnum.B.type], result2) + } + + test("lens composition newtypes") { + val input = GetCityInput(CityId("test")) + + val cityName: Lens[GetCityInput, String] = + GetCityInput.optics.cityId.value + val updated = cityName.replace("Fancy New Name")(input) + + val result = cityName.project(updated) + + assertEquals(Option("Fancy New Name"), result) + } + + test("prism composition newtypes") { + val input = PersonContactInfo.EmailCase(PersonEmail("test@test.com")) + + val emailPrism: Prism[PersonContactInfo, String] = + PersonContactInfo.optics.email.value + val updated = emailPrism.replace("other@other.com")(input) + + val result = emailPrism.project(updated) + + assertEquals(Option("other@other.com"), result) + } + + test("optional composition newtypes") { + case class TopLevel(contact: PersonContactInfo) + val topLevel = Lens[TopLevel, PersonContactInfo](_.contact)(a => + s => s.copy(contact = a) + ) + val input = + TopLevel(PersonContactInfo.EmailCase(PersonEmail("test@test.com"))) + + val emailOpt: Optional[TopLevel, String] = + topLevel.andThen(PersonContactInfo.optics.email).value + val updated = emailOpt.replace("other@other.com")(input) + + val result = emailOpt.project(updated) + + assertEquals(Option("other@other.com"), result) + } + +} diff --git a/modules/bootstrapped/test/src/smithy4s/optics/LensSpec.scala b/modules/bootstrapped/test/src/smithy4s/optics/LensSpec.scala new file mode 100644 index 000000000..97c1aba04 --- /dev/null +++ b/modules/bootstrapped/test/src/smithy4s/optics/LensSpec.scala @@ -0,0 +1,94 @@ +package smithy4s.optics + +import munit._ +import smithy4s.example.TestBody + +// inspired by and adapted from https://www.optics.dev/Monocle/ under the MIT license +final class LensSpec extends FunSuite { + + test("get and replace") { + val lens = TestBody.optics.data + val e = TestBody(Some("test body")) + val result = lens.replace(lens.get(e))(e) + assertEquals(e, result) + } + + test("replace and get") { + val lens = TestBody.optics.data + val e = TestBody(Some("test body")) + val result = lens.get(lens.replace(Some("test body"))(e)) + assertEquals(e.data, result) + } + + test("replace idempotent") { + val lens = TestBody.optics.data + val data = Some("test body") + val e = TestBody(data) + val result = lens.replace(data)(lens.replace(data)(e)) + assertEquals(e, result) + } + + test("modify identity") { + val lens = TestBody.optics.data + val data = Some("test body") + val e = TestBody(data) + val result = lens.modify(identity)(e) + assertEquals(e, result) + } + + test("modify composition") { + val lens = TestBody.optics.data + val data = Some("test body") + val e = TestBody(data) + val f: Option[String] => Option[String] = _ => Some("test 2") + val g: Option[String] => Option[String] = _ => Some("test 3") + val resultOne = lens.modify(g)(lens.modify(f)(e)) + val resultTwo = lens.modify(g compose f)(e) + assertEquals(TestBody(Some("test 3")), resultOne) + assertEquals(resultOne, resultTwo) + } + + test("modify == replace") { + val lens = TestBody.optics.data + val data = Some("test body") + val data2 = Some("test body 2") + val e = TestBody(data) + val resultOne = lens.replace(data2)(e) + val resultTwo = lens.modify(_ => data2)(e) + assertEquals(TestBody(Some("test body 2")), resultOne) + assertEquals(resultOne, resultTwo) + } + + case class Point(x: Int, y: Int) + case class Example(s: String, p: Point) + val s = Lens[Example, String](_.s)(s => ex => ex.copy(s = s)) + val p = Lens[Example, Point](_.p)(p => ex => ex.copy(p = p)) + + val x = Lens[Point, Int](_.x)(x => p => p.copy(x = x)) + val y = Lens[Point, Int](_.y)(y => p => p.copy(y = y)) + val xy = Lens[Point, (Int, Int)](p => (p.x, p.y))(xy => + p => p.copy(x = xy._1, y = xy._2) + ) + + test("get") { + assertEquals(x.get(Point(5, 2)), 5) + } + + test("set") { + assertEquals(x.replace(5)(Point(9, 2)), Point(5, 2)) + } + + test("modify") { + assertEquals(x.modify(_ + 1)(Point(9, 2)), Point(10, 2)) + } + + test("some") { + case class SomeTest(x: Int, y: Option[Int]) + val obj = SomeTest(1, Some(2)) + + val lens = Lens((_: SomeTest).y)(newValue => _.copy(y = newValue)) + + assertEquals(lens.some[Int].project(obj), Some(2)) + } + +} diff --git a/modules/bootstrapped/test/src/smithy4s/optics/PrismSpec.scala b/modules/bootstrapped/test/src/smithy4s/optics/PrismSpec.scala new file mode 100644 index 000000000..3490332d3 --- /dev/null +++ b/modules/bootstrapped/test/src/smithy4s/optics/PrismSpec.scala @@ -0,0 +1,88 @@ +package smithy4s.optics + +import munit._ +import smithy4s.example.Podcast + +// inspired by and adapted from https://www.optics.dev/Monocle/ under the MIT license +final class PrismSpec extends FunSuite { + + test("round trip") { + val prism = Podcast.optics.video + val v = Podcast.Video(Some("My Title")) + val result = + prism.project(prism.inject(v)) + assertEquals(Option(v), result) + } + + test("round trip - empty") { + val prism = Podcast.optics.audio + val v: Podcast = Podcast.Video(Some("My Title")) + val result = + prism.project(v).map(prism.inject) + assertEquals(Option.empty[Podcast], result) + } + + test("modify identity") { + val prism = Podcast.optics.video + val v = Podcast.Video(Some("My Title")) + val result = + prism.modify(identity)(v) + assertEquals(v.widen, result) + } + + test("modify compose") { + val prism = Podcast.optics.video + val v = Podcast.Video(Some("My Title")) + val f: Podcast.Video => Podcast.Video = _.copy(title = Some("Title 2")) + val g: Podcast.Video => Podcast.Video = _.copy(title = Some("Title 3")) + val resultOne = + prism.modify(g)(prism.modify(f)(v)) + val resultTwo = prism.modify(g compose f)(v) + assertEquals(Podcast.Video(Some("Title 3")).widen, resultOne) + assertEquals(resultTwo, resultOne) + } + + test("modify == replace") { + val prism = Podcast.optics.video + val v = Podcast.Video(Some("My Title")) + val v2 = Podcast.Video(Some("My Title 2")) + val resultOne = + prism.modify(_ => v2)(v) + val resultTwo = prism.replace(v2)(v) + assertEquals(Podcast.Video(Some("My Title 2")).widen, resultOne) + assertEquals(resultTwo, resultOne) + } + + sealed trait IntOrString + case class I(i: Int) extends IntOrString + case class S(s: String) extends IntOrString + + val i = Prism + .partial[IntOrString, I] { case i: I => i }(identity) + val s = + Prism[IntOrString, String] { case S(s) => Some(s); case _ => None }(S.apply) + + test("project") { + assertEquals(i.project(I(1)), Option(I(1))) + assertEquals(i.project(S("")), None) + + assertEquals(s.project(S("hello")), Some("hello")) + assertEquals(s.project(I(10)), None) + } + + test("project") { + assertEquals(i.inject(I(3)), I(3)) + assertEquals(s.inject("Yop"), S("Yop")) + } + + test("some") { + case class SomeTest(y: Option[Int]) + val obj = SomeTest(Some(2)) + + val prism = + Prism[SomeTest, Option[Int]](i => Some(i.y))(SomeTest.apply) + + assertEquals(prism.some[Int].project(obj), Some(2)) + } + +} diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/build.sbt b/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/build.sbt new file mode 100644 index 000000000..3ada07e61 --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/build.sbt @@ -0,0 +1,10 @@ +lazy val root = (project in file(".")) + .enablePlugins(Smithy4sCodegenPlugin) + .settings( + scalaVersion := "2.13.10", + Compile / smithy4sRenderOptics := true, + libraryDependencies ++= Seq( + "com.disneystreaming.smithy4s" %% "smithy4s-core" % smithy4sVersion.value, + "com.disneystreaming.smithy4s" %% "smithy4s-dynamic" % smithy4sVersion.value + ) + ) diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/project/build.properties b/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/project/build.properties new file mode 100644 index 000000000..72413de15 --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.8.3 diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/project/plugins.sbt b/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/project/plugins.sbt new file mode 100644 index 000000000..b8589b92c --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/project/plugins.sbt @@ -0,0 +1,9 @@ +sys.props.get("plugin.version") match { + case Some(x) => + addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % x) + case _ => + sys.error( + """|The system property 'plugin.version' is not defined. + |Specify this property using the scriptedLaunchOpts -D.""".stripMargin + ) +} diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/src/main/scala/Main.scala b/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/src/main/scala/Main.scala new file mode 100644 index 000000000..090db1adf --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/src/main/scala/Main.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package demo + +object Main extends App { + try { + println( + smithy4s.example.TestOptics.optics.one + .get(smithy4s.example.TestOptics(Some("test one"))) + ) + } catch { + case _: java.lang.ExceptionInInitializerError => + println("failed") + sys.exit(1) + } +} diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/src/main/smithy/optics.smithy b/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/src/main/smithy/optics.smithy new file mode 100644 index 000000000..456c4e6e6 --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/src/main/smithy/optics.smithy @@ -0,0 +1,5 @@ +namespace smithy4s.example + +structure TestOptics { + one: String +} diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/test new file mode 100644 index 000000000..863293500 --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/optics-generate/test @@ -0,0 +1,2 @@ +# check if smithy4sCodegen works and everything compiles +> compile diff --git a/modules/codegen-plugin/src/smithy4s/codegen/Smithy4sCodegenPlugin.scala b/modules/codegen-plugin/src/smithy4s/codegen/Smithy4sCodegenPlugin.scala index 3e3751842..facec0b77 100644 --- a/modules/codegen-plugin/src/smithy4s/codegen/Smithy4sCodegenPlugin.scala +++ b/modules/codegen-plugin/src/smithy4s/codegen/Smithy4sCodegenPlugin.scala @@ -131,6 +131,11 @@ object Smithy4sCodegenPlugin extends AutoPlugin { "String value to use as wildcard argument in types in generated code" ) + val smithy4sRenderOptics = + taskKey[Boolean]( + "Boolean value to indicate whether or not to generate optics" + ) + val smithy4sGeneratedSmithyFiles = taskKey[Seq[File]]( "Generated smithy files" @@ -218,14 +223,15 @@ object Smithy4sCodegenPlugin extends AutoPlugin { case _ => "_" } }, + config / smithy4sRenderOptics := false, config / smithy4sGeneratedSmithyMetadataFile := { (config / sourceManaged).value / "smithy" / "generated-metadata.smithy" }, config / smithy4sGeneratedSmithyFiles := { val cacheFactory = (config / streams).value.cacheStoreFactory - val cached = Tracked.inputChanged[String, Seq[File]]( + val cached = Tracked.inputChanged[(String, Boolean), Seq[File]]( cacheFactory.make("smithy4sGeneratedSmithyFilesInput") - ) { case (changed, wildcardArg) => + ) { case (changed, (wildcardArg, shouldGenerateOptics)) => val lastOutput = Tracked.lastOutput[Boolean, Seq[File]]( cacheFactory.make("smithy4sGeneratedSmithyFilesOutput") ) { case (changed, prevResult) => @@ -235,6 +241,7 @@ object Smithy4sCodegenPlugin extends AutoPlugin { file, s"""$$version: "2" |metadata smithy4sWildcardArgument = "$wildcardArg" + |metadata smithy4sRenderOptics = $shouldGenerateOptics |""".stripMargin ) Seq(file) @@ -245,7 +252,8 @@ object Smithy4sCodegenPlugin extends AutoPlugin { lastOutput(changed) } val wildcardArg = (config / smithy4sWildcardArgument).value - cached(wildcardArg) + val generateOptics = (config / smithy4sRenderOptics).value + cached((wildcardArg, generateOptics)) }, config / sourceGenerators += (config / smithy4sCodegen).map( _.filter(_.ext == "scala") diff --git a/modules/codegen/src/smithy4s/codegen/internals/IR.scala b/modules/codegen/src/smithy4s/codegen/internals/IR.scala index 778db39d4..ac666fdc3 100644 --- a/modules/codegen/src/smithy4s/codegen/internals/IR.scala +++ b/modules/codegen/src/smithy4s/codegen/internals/IR.scala @@ -303,6 +303,7 @@ private[internals] object Hint { case class Typeclass(id: ShapeId, targetType: String, interpreter: String) extends Hint case object GenerateServiceProduct extends Hint + case object GenerateOptics extends Hint implicit val eq: Eq[Hint] = Eq.fromUniversalEquals } diff --git a/modules/codegen/src/smithy4s/codegen/internals/Renderer.scala b/modules/codegen/src/smithy4s/codegen/internals/Renderer.scala index 2e36869bc..09b0f26fa 100644 --- a/modules/codegen/src/smithy4s/codegen/internals/Renderer.scala +++ b/modules/codegen/src/smithy4s/codegen/internals/Renderer.scala @@ -40,7 +40,11 @@ private[internals] object Renderer { case class Result(namespace: String, name: String, content: String) - case class Config(errorsAsScala3Unions: Boolean, wildcardArgument: String) + case class Config( + errorsAsScala3Unions: Boolean, + wildcardArgument: String, + renderOptics: Boolean + ) object Config { def load(metadata: Map[String, Node]): Renderer.Config = { val errorsAsScala3Unions = metadata @@ -54,6 +58,12 @@ private[internals] object Renderer { .map(_.getValue()) .getOrElse("_") + val renderOptics = metadata + .get("smithy4sRenderOptics") + .flatMap(_.asBooleanNode().asScala) + .map(_.getValue()) + .getOrElse(false) + if (wildcardArgument != "?" && wildcardArgument != "_") { throw new IllegalArgumentException( s"`smithy4sWildcardArgument` possible values are: `?` or `_`. found `$wildcardArgument`." @@ -62,7 +72,8 @@ private[internals] object Renderer { Renderer.Config( errorsAsScala3Unions = errorsAsScala3Unions, - wildcardArgument = wildcardArgument + wildcardArgument = wildcardArgument, + renderOptics = renderOptics ) } } @@ -614,6 +625,22 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => if (result.isEmpty) Lines.empty else newline ++ Lines(result) } + private def renderLenses(product: Product, hints: List[Hint]): Lines = if ( + (compilationUnit.rendererConfig.renderOptics || hints.contains( + Hint.GenerateOptics + )) && product.fields.nonEmpty + ) { + val smithyLens = NameRef("smithy4s.optics.Lens") + val lenses = product.fields.map { field => + val fieldType = + if (field.required) Line.required(line"${field.tpe}", None) + else Line.optional(line"${field.tpe}") + line"val ${field.name}: $smithyLens[${product.nameRef}, $fieldType] = $smithyLens[${product.nameRef}, $fieldType](_.${field.name})(n => a => a.copy(${field.name} = n))" + } + obj(product.nameRef.copy(name = "optics"))(lenses) ++ + newline + } else Lines.empty + private def renderProductNonMixin( product: Product, adtParent: Option[NameRef], @@ -667,6 +694,7 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => renderHintsVal(hints), renderProtocol(product.nameRef, hints), newline, + renderLenses(product, hints), if (fields.nonEmpty) { val renderedFields = fields.map { case Field(fieldName, realName, tpe, required, hints) => @@ -858,6 +886,59 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => ) } + private def caseName(alt: Alt): NameRef = alt.member match { + case UnionMember.ProductCase(product) => NameRef(product.name) + case UnionMember.TypeCase(_) | UnionMember.UnitCase => + NameRef(alt.name.dropWhile(_ == '_').capitalize + "Case") + } + + private def renderPrisms( + unionName: NameRef, + alts: NonEmptyList[Alt], + hints: List[Hint] + ): Lines = if ( + compilationUnit.rendererConfig.renderOptics || hints.contains( + Hint.GenerateOptics + ) + ) { + val smithyPrism = NameRef("smithy4s.optics.Prism") + val altLines = alts.map { alt => + alt.member match { + case UnionMember.ProductCase(p) => + val (mat, tpe) = (p.nameDef, line"${p.name}") + line"val ${alt.name}: $smithyPrism[$unionName, $tpe] = $smithyPrism.partial[$unionName, $tpe]{ case t: $mat => t }(identity)" + case UnionMember.TypeCase(t) => + val (mat, tpe) = (caseName(alt), line"$t") + line"val ${alt.name}: $smithyPrism[$unionName, $tpe] = $smithyPrism.partial[$unionName, $tpe]{ case $mat(t) => t }($mat.apply)" + case UnionMember.UnitCase => + val (mat, tpe) = (caseName(alt), line"${caseName(alt)}") + line"val ${alt.name}: $smithyPrism[$unionName, $tpe.type] = $smithyPrism.partial[$unionName, $tpe.type]{ case t: $mat.type => t }(identity)" + } + } + + obj(unionName.copy(name = "optics"))(altLines) ++ + newline + } else Lines.empty + + private def renderPrismsEnum( + enumName: NameRef, + values: List[EnumValue], + hints: List[Hint] + ): Lines = if ( + compilationUnit.rendererConfig.renderOptics || hints.contains( + Hint.GenerateOptics + ) + ) { + val smithyPrism = NameRef("smithy4s.optics.Prism") + val valueLines = values.map { value => + val (mat, tpe) = (value.name, line"${value.name}") + line"val ${value.name}: $smithyPrism[$enumName, $enumName.$tpe.type] = $smithyPrism.partial[$enumName, $enumName.$tpe.type]{ case $enumName.$mat => $enumName.$mat }(identity)" + } + + obj(enumName.copy(name = "optics"))(valueLines) ++ + newline + } else Lines.empty + private def renderUnion( shapeId: ShapeId, name: NameRef, @@ -867,11 +948,6 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => hints: List[Hint], error: Boolean = false ): Lines = { - def caseName(alt: Alt): NameRef = alt.member match { - case UnionMember.ProductCase(product) => NameRef(product.name) - case UnionMember.TypeCase(_) | UnionMember.UnitCase => - NameRef(alt.name.dropWhile(_ == '_').capitalize + "Case") - } def smartConstructor(alt: Alt): Line = { val cn = caseName(alt).name val ident = NameDef(uncapitalise(alt.name)) @@ -909,6 +985,7 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => newline, renderHintsVal(hints), newline, + renderPrisms(name, alts, hints), alts.zipWithIndex.map { case (a @ Alt(_, realName, UnionMember.UnitCase, altHints), index) => val cn = caseName(a) @@ -1051,6 +1128,7 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => newline, renderHintsVal(hints), newline, + renderPrismsEnum(name, values, hints), values.map { case e @ EnumValue(value, intValue, _, hints) => val valueName = NameRef(e.name) val valueHints = line"$Hints_(${memberHints(e.hints)})" diff --git a/modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala b/modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala index 3e0474151..68de9f7c2 100644 --- a/modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala +++ b/modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala @@ -27,6 +27,7 @@ import smithy4s.meta.RefinementTrait import smithy4s.meta.VectorTrait import smithy4s.meta.AdtTrait import smithy4s.meta.GenerateServiceProductTrait +import smithy4s.meta.GenerateOpticsTrait import alloy.StructurePatternTrait import software.amazon.smithy.aws.traits.ServiceTrait import software.amazon.smithy.model.Model @@ -924,6 +925,8 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) { Hint.UniqueItems case _: GenerateServiceProductTrait => Hint.GenerateServiceProduct + case _: GenerateOpticsTrait => + Hint.GenerateOptics case t if t.toShapeId() == ShapeId.fromParts("smithy.api", "trait") => Hint.Trait case ConstraintTrait(tr) => Hint.Constraint(toTypeRef(tr), unfoldTrait(tr)) diff --git a/modules/core/src/smithy4s/optics/Lens.scala b/modules/core/src/smithy4s/optics/Lens.scala new file mode 100644 index 000000000..999b887a4 --- /dev/null +++ b/modules/core/src/smithy4s/optics/Lens.scala @@ -0,0 +1,98 @@ +/* + * Copyright 2021-2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s.optics + +import smithy4s.Bijection + +// inspired by and adapted from https://www.optics.dev/Monocle/ under the MIT license + +/** + * Lens implementation which can be used to abstract over accessing/updating + * a member of a product type + */ +trait Lens[S, A] extends Optional[S, A] { self => + + /** Retrieve the target of the [[Lens]] */ + def get(s: S): A + + /** Provides a function to replace the target of the [[Lens]] */ + def replace(a: A): S => S + + /** Retrieve the target of the [[Lens]] as an Optional (implemented to conform to [[Optional]]) */ + final def project(s: S): Option[A] = Some(get(s)) + + /** Modify the target of the [[Lens]] with a function from A => A */ + override final def modify(f: A => A): S => S = + s => replace(f(get(s)))(s) + + /** + * Compose this [[Lens]] with another [[Lens]]. + * The result will be a lens that starts with the source + * of the first lens and points to the target of the second + * lens. + */ + final def andThen[A0](that: Lens[A, A0]): Lens[S, A0] = + new Lens[S, A0] { + def get(s: S): A0 = + that.get(self.get(s)) + def replace(a: A0): S => S = + self.modify(that.replace(a)) + } + + /** + * Allows abstracting over an optional target by pointing to + * the inside of the optional value (the value inside of the [[Some]]). + */ + final override def some[A0](implicit + ev1: A =:= Option[A0] + ): Optional[S, A0] = + adapt[Option[A0]].andThen( + Prism[Option[A0], A0](identity)(Some(_)) + ) + + private[this] final def adapt[A0](implicit + @annotation.unused evA: A =:= A0 + ): Lens[S, A0] = + // safe due to A =:= A0 + this.asInstanceOf[Lens[S, A0]] + + /** + * Helper function for targeting the value inside of a [[smithy4s.Newtype]] + * or other type with an implicit [[Bijection]] available. + */ + final override def value[A0](implicit + bijection: Bijection[A0, A] + ): Lens[S, A0] = + new Lens[S, A0] { + def get(s: S): A0 = bijection.from(self.get(s)) + def replace(a: A0): S => S = self.replace(bijection.to(a)) + } +} + +object Lens { + + /** + * Construct a new [[Lens]] by providing functions for getting + * A from S and updating S given a new A. + */ + def apply[S, A](_get: S => A)(_replace: A => S => S): Lens[S, A] = + new Lens[S, A] { + def get(s: S): A = _get(s) + def replace(a: A): S => S = _replace(a) + } + +} diff --git a/modules/core/src/smithy4s/optics/Optional.scala b/modules/core/src/smithy4s/optics/Optional.scala new file mode 100644 index 000000000..93e261982 --- /dev/null +++ b/modules/core/src/smithy4s/optics/Optional.scala @@ -0,0 +1,78 @@ +/* + * Copyright 2021-2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s.optics + +import smithy4s.Bijection + +// inspired by and adapted from https://www.optics.dev/Monocle/ under the MIT license + +/** + * Optional can be seen as the weak intersection between a [[Lens]] + * and a [[Prism]]. It contains the same `replace` function as a [[Lens]] + * and the same `project` function of a [[Prism]]. + */ +trait Optional[S, A] { self => + + /** + * Returns a [[Some]] of A from S if it is able to obtain an A. + * Else returns [[None]]. + */ + def project(s: S): Option[A] + + /** Provides a function to replace the target of the [[Lens]] */ + def replace(a: A): S => S + + /** Modify the target of the [[Optional]] with a function from A => A */ + def modify(f: A => A): S => S = + s => project(s).fold(s)(a => replace(f(a))(s)) + + /** Compose this [[Optional]] with another [[Optional]]. */ + final def andThen[A0](that: Optional[A, A0]): Optional[S, A0] = + new Optional[S, A0] { + def project(s: S): Option[A0] = + self.project(s).flatMap(that.project) + def replace(a: A0): S => S = + self.modify(that.replace(a)) + } + + /** + * Allows abstracting over an optional target by pointing to + * the inside of the optional value (the value inside of the [[Some]]). + */ + def some[A0](implicit + ev1: A =:= Option[A0] + ): Optional[S, A0] = + adapt[Option[A0]].andThen( + Prism[Option[A0], A0](identity)(Some(_)) + ) + + private[this] final def adapt[A0](implicit + @annotation.unused evA: A =:= A0 + ): Optional[S, A0] = + // safe due to A =:= A0 + this.asInstanceOf[Optional[S, A0]] + + /** + * Helper function for targeting the value inside of a [[smithy4s.Newtype]] + * or other type with an implicit [[Bijection]] available. + */ + def value[A0](implicit bijection: Bijection[A0, A]): Optional[S, A0] = + new Optional[S, A0] { + def project(s: S): Option[A0] = self.project(s).map(bijection.from(_)) + def replace(a: A0): S => S = self.replace(bijection.to(a)) + } +} diff --git a/modules/core/src/smithy4s/optics/Prism.scala b/modules/core/src/smithy4s/optics/Prism.scala new file mode 100644 index 000000000..fc22cd503 --- /dev/null +++ b/modules/core/src/smithy4s/optics/Prism.scala @@ -0,0 +1,103 @@ +/* + * Copyright 2021-2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s.optics + +import smithy4s.Bijection + +// inspired by and adapted from https://www.optics.dev/Monocle/ under the MIT license + +/** + * Lens implementation which can be used to abstract over accessing/updating + * a member of a coproduct type + */ +trait Prism[S, A] extends Optional[S, A] { self => + + /** + * Returns a [[Some]] of A from S if it is able to obtain an A. + * Else returns [[None]]. + */ + def project(s: S): Option[A] + + /** Returns an S given an A */ + def inject(a: A): S + + /** Modify the target of the [[Prism]] with a function from A => A */ + override final def modify(f: A => A): S => S = + s => project(s).fold(s)(a => inject(f(a))) + + /** Provides a function to replace the target of the [[Prism]] */ + def replace(a: A): S => S = + modify(_ => a) + + /** Compose this [[Prism]] with another [[Prism]]. */ + final def andThen[A0](that: Prism[A, A0]): Prism[S, A0] = + new Prism[S, A0] { + def project(s: S): Option[A0] = + self.project(s).flatMap(that.project) + def inject(a: A0): S = + self.inject(that.inject(a)) + } + + /** + * Allows abstracting over an optional target by pointing to + * the inside of the optional value (the value inside of the [[Some]]). + */ + final override def some[A0](implicit + ev1: A =:= Option[A0] + ): Optional[S, A0] = + adapt[Option[A0]].andThen( + Prism[Option[A0], A0](identity)(Some(_)) + ) + + private[this] final def adapt[A0](implicit + @annotation.unused evA: A =:= A0 + ): Prism[S, A0] = + // safe due to A =:= A0 + this.asInstanceOf[Prism[S, A0]] + + /** + * Helper function for targeting the value inside of a [[smithy4s.Newtype]] + * or other type with an implicit [[Bijection]] available. + */ + final override def value[A0](implicit + bijection: Bijection[A0, A] + ): Prism[S, A0] = + new Prism[S, A0] { + def project(s: S): Option[A0] = self.project(s).map(bijection.from) + def inject(a: A0): S = self.inject(bijection.to(a)) + } +} + +object Prism { + + /** + * Construct a new [[Prism]] by providing functions for getting + * an Option[A] from S and getting an S given an A. + */ + def apply[S, A](_get: S => Option[A])(_inject: A => S): Prism[S, A] = + new Prism[S, A] { + def project(s: S): Option[A] = _get(s) + def inject(a: A): S = _inject(a) + } + + /** + * Construct a new [[Prism]] with a [[PartialFunction]] to avoid needing + * to exhaustively handle all possible `S` in the provided get function. + */ + def partial[S, A](get: PartialFunction[S, A])(inject: A => S): Prism[S, A] = + Prism[S, A](get.lift)(inject) +} diff --git a/modules/docs/markdown/04-codegen/01-customisation/11-optics.md b/modules/docs/markdown/04-codegen/01-customisation/11-optics.md new file mode 100644 index 000000000..e680176d4 --- /dev/null +++ b/modules/docs/markdown/04-codegen/01-customisation/11-optics.md @@ -0,0 +1,107 @@ +--- +sidebar_label: Wildcard types +title: Scala wildcard type arguments +--- + +Smithy4s has the ability to render optics (Lens/Prism) instances in the code it generates. + +If you're using Smithy4s via `mill` or `sbt`, then you can enable this functionality with the following keys: + +* in mill, task: `def smithy4sRenderOptics = true` +* in sbt, setting: `smithy4sRenderOptics := true` + +If you are using Smithy4s via the CLI, then they way to utilize this feature is through your Smithy specifications. The simplest approach is to add a file with the following content to your CLI invocation: + +```kotlin +$version: "2" + +metadata smithy4sRenderOptics = true +``` + +Alternatively, if you want to generate optics for only select shapes in your model, you can accomplish this using +the `smithy4s.meta#generateOptics` trait. This trait can be used on enum, intEnum, union, and structure shapes. + +```kotlin +use smithy4s.meta#generateOptics + +@generateOptics +structure MyStruct { + one: String +} +``` + +## Optics Usage + +Below is an example of using the lenses that smithy4s generates. By default, smithy4s will generate lenses for all structure shapes in your input smithy model(s). + +```scala mdoc:reset +import smithy4s.example._ + +val input = TestInput("test", TestBody(Some("test body"))) +val lens = TestInput.optics.body.andThen(TestBody.optics.data).some +val resultGet = lens.project(input) + +resultGet == Option("test body") // true + +val resultSet = + lens.replace("new body")(input) + +val updatedInput = TestInput("test", TestBody(Some("new body"))) + +resultSet == updatedInput // true +``` + +You can also compose prisms with lenses (and vice-versa) as in the example below: + +```scala mdoc:reset +import smithy4s.example._ + +val input = Podcast.Video(Some("Pod Title")) + +val prism = Podcast.optics.video.andThen(Podcast.Video.optics.title).some +val result = prism.replace("New Pod Title")(input) + +Podcast.Video(Some("New Pod Title")) == result // true +``` + +Smithy4s also provides a `value` function on Prisms and Lenses that can be used to abstract over NewTypes (similar to what `.some` does for Option types): + +```scala mdoc:reset +import smithy4s.example._ + +val input = GetCityInput(CityId("test")) + +val cityName: smithy4s.optics.Lens[GetCityInput, String] = GetCityInput.optics.cityId.value +val updated = cityName.replace("Fancy New Name")(input) + +val result = cityName.project(updated) + +Option("Fancy New Name") == result // true +``` + +## Using 3rd Party Optics Libraries + +If you'd like to use a third party optics library for more functionality, you can accomplish this by adding an object with a few conversion functions. Here is an example using [Monocle](https://www.optics.dev/Monocle/). + +```scala mdoc:reset +object MonocleConversions { + + implicit def smithy4sToMonocleLens[S, A]( + smithy4sLens: smithy4s.optics.Lens[S, A] + ): monocle.Lens[S, A] = + monocle.Lens[S, A](smithy4sLens.get)(smithy4sLens.replace) + + implicit def smithy4sToMonoclePrism[S, A]( + smithy4sPrism: smithy4s.optics.Prism[S, A] + ): monocle.Prism[S, A] = + monocle.Prism(smithy4sPrism.project)(smithy4sPrism.inject) + + implicit def smithy4sToMonocleOptional[S, A]( + smithy4sOptional: smithy4s.optics.Optional[S, A] + ): monocle.Optional[S, A] = + monocle.Optional(smithy4sOptional.project)(smithy4sOptional.replace) + +} +``` + +Then you can `import MonocleConversions._` at the top of any file you need to seamlessly convert smithy4s optics over to Monocle ones. diff --git a/modules/protocol/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService b/modules/protocol/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService index e879e46a2..8c2ade972 100644 --- a/modules/protocol/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService +++ b/modules/protocol/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService @@ -9,3 +9,4 @@ smithy4s.meta.UnwrapTrait$Provider smithy4s.meta.AdtTrait$Provider smithy4s.meta.TypeclassTrait$Provider smithy4s.meta.GenerateServiceProductTrait$Provider +smithy4s.meta.GenerateOpticsTrait$Provider diff --git a/modules/protocol/resources/META-INF/smithy/smithy4s.meta.smithy b/modules/protocol/resources/META-INF/smithy/smithy4s.meta.smithy index f60ff5b3d..c1e498c85 100644 --- a/modules/protocol/resources/META-INF/smithy/smithy4s.meta.smithy +++ b/modules/protocol/resources/META-INF/smithy/smithy4s.meta.smithy @@ -150,6 +150,12 @@ structure typeclass { @trait(selector: ":is(service)") structure generateServiceProduct {} +/// Placing this trait on a shape will cause the generated +/// code to have optics (Lenses or Prisms) in the companion +/// object. +@trait(selector: ":is(enum, intEnum, union, structure)") +structure generateOptics {} + /// Placing this trait on an error will cause the generated code to exclude the stacktrace /// via extending scala.util.control.NoStackTrace instead of Throwable. @trait(selector: "structure :is([trait|error])") diff --git a/modules/protocol/src/smithy4s/meta/GenerateOpticsTrait.java b/modules/protocol/src/smithy4s/meta/GenerateOpticsTrait.java new file mode 100644 index 000000000..769b18b4c --- /dev/null +++ b/modules/protocol/src/smithy4s/meta/GenerateOpticsTrait.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021-2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s.meta; + +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.AnnotationTrait; +import software.amazon.smithy.model.traits.AbstractTrait; + +public class GenerateOpticsTrait extends AnnotationTrait { + public static ShapeId ID = ShapeId.from("smithy4s.meta#generateOptics"); + + public GenerateOpticsTrait(ObjectNode node) { + super(ID, node); + } + + public GenerateOpticsTrait() { + super(ID, Node.objectNode()); + } + + public static final class Provider extends AbstractTrait.Provider { + public Provider() { + super(ID); + } + + @Override + public GenerateOpticsTrait createTrait(ShapeId target, Node node) { + return new GenerateOpticsTrait(node.expectObjectNode()); + } + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 71dab3758..f4519fc24 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -40,6 +40,11 @@ object Dependencies { Def.setting("org.typelevel" %%% "cats-core" % "2.9.0") } + val Monocle = new { + val core: Def.Initialize[ModuleID] = + Def.setting("dev.optics" %% "monocle-core" % "3.2.0") + } + object Circe { val circeVersion = "0.14.5" val core = Def.setting("io.circe" %%% "circe-core" % circeVersion) diff --git a/sampleSpecs/adtMember.smithy b/sampleSpecs/adtMember.smithy index 417429a4a..08fe08fb4 100644 --- a/sampleSpecs/adtMember.smithy +++ b/sampleSpecs/adtMember.smithy @@ -4,6 +4,7 @@ namespace smithy4s.example use smithy4s.meta#adtMember use smithy4s.meta#adt +use smithy4s.meta#generateOptics integer OrderNumber @@ -66,6 +67,7 @@ structure AdtTwo with [AdtMixinOne, AdtMixinTwo] { } @adt +@generateOptics union Podcast { video: Video audio: Audio @@ -78,5 +80,7 @@ structure PodcastCommon { durationMillis: Long } +@generateOptics structure Video with [PodcastCommon] {} +@generateOptics structure Audio with [PodcastCommon] {} diff --git a/sampleSpecs/optics.smithy b/sampleSpecs/optics.smithy new file mode 100644 index 000000000..788dcd7e4 --- /dev/null +++ b/sampleSpecs/optics.smithy @@ -0,0 +1,40 @@ +$version: "2" + +namespace smithy4s.example + +use smithy4s.meta#generateOptics + +@generateOptics +union OpticsUnion { + one: OpticsStructure +} + +@generateOptics +structure OpticsStructure { + two: OpticsEnum +} + +@generateOptics +enum OpticsEnum { + A, B +} + +@generateOptics +structure TestInput { + @required + @httpLabel + @length(min: 10) + pathParam: String + @httpQuery("queryParam") + @length(min: 10) + queryParam: String + @httpPayload + @required + body: TestBody +} + +@generateOptics +structure TestBody { + @length(min: 10) + data: String +} diff --git a/sampleSpecs/pizza.smithy b/sampleSpecs/pizza.smithy index 48d7deee0..fb007b352 100644 --- a/sampleSpecs/pizza.smithy +++ b/sampleSpecs/pizza.smithy @@ -334,21 +334,24 @@ operation Reservation { @http(method: "POST", uri: "/echo/{pathParam}") operation Echo { - input := { - @required - @httpLabel - @length(min: 10) - pathParam: String - @httpQuery("queryParam") - @length(min: 10) - queryParam: String - @httpPayload - @required - body: EchoBody - }// this operation must NOT have any errors + input: EchoInput + // this operation must NOT have any errors errors: [] } +structure EchoInput { + @required + @httpLabel + @length(min: 10) + pathParam: String + @httpQuery("queryParam") + @length(min: 10) + queryParam: String + @httpPayload + @required + body: EchoBody +} + structure EchoBody { @length(min: 10) data: String diff --git a/sampleSpecs/typeclass.smithy b/sampleSpecs/typeclass.smithy index 6a9991cb2..af0dbc180 100644 --- a/sampleSpecs/typeclass.smithy +++ b/sampleSpecs/typeclass.smithy @@ -3,6 +3,7 @@ $version: "2" namespace smithy4s.example use smithy4s.meta#typeclass +use smithy4s.meta#generateOptics // NOTE: normally you would likely not need to add instances of hash or other typeclasses // to all of your types. Here I am doing it just to test different rendering cases. @@ -20,6 +21,7 @@ structure MovieTheater { name: String } +@generateOptics @hash union PersonContactInfo { email: PersonEmail diff --git a/sampleSpecs/weather.smithy b/sampleSpecs/weather.smithy index 0cd08b1eb..c8658962e 100644 --- a/sampleSpecs/weather.smithy +++ b/sampleSpecs/weather.smithy @@ -1,5 +1,7 @@ namespace smithy4s.example +use smithy4s.meta#generateOptics + /// Provides weather forecasts. @paginated(inputToken: "nextToken", outputToken: "nextToken", pageSize: "pageSize") @@ -32,6 +34,7 @@ operation GetCity { errors: [NoSuchResource] } +@generateOptics structure GetCityInput { // "cityId" provides the identifier for the resource and // has to be marked as required. @@ -125,10 +128,12 @@ structure GetForecastInput { cityId: CityId, } +@generateOptics structure GetForecastOutput { forecast: ForecastResult } +@generateOptics union ForecastResult { rain: ChanceOfRain, sun: UVIndex