diff --git a/CHANGELOG.md b/CHANGELOG.md index 0022b9f3a..b2fc5d844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ Thank you! # 0.19.0 +## Validated newtypes [#1454](https://github.com/disneystreaming/smithy4s/pull/1454) + +Add support for rendering constrained newtypes over Smithy primitives as validated newtypes. These types now have an `apply` method which returns either an error or a validated value. + ## Documentation fix Prevent documentation from being generated for case class when the field are not generated because they're annotated with `@streaming` diff --git a/modules/bootstrapped/src/generated/smithy4s/example/NonValidatedString.scala b/modules/bootstrapped/src/generated/smithy4s/example/NonValidatedString.scala new file mode 100644 index 000000000..bde61f006 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/NonValidatedString.scala @@ -0,0 +1,15 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Newtype +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.schema.Schema.bijection +import smithy4s.schema.Schema.string + +object NonValidatedString extends Newtype[String] { + val id: ShapeId = ShapeId("smithy4s.example", "NonValidatedString") + val hints: Hints = Hints.empty + val underlyingSchema: Schema[String] = string.withId(id).addHints(hints).validated(smithy.api.Length(min = Some(1L), max = None)).validated(smithy.api.Pattern("[a-zA-Z0-9]+")) + implicit val schema: Schema[NonValidatedString] = bijection(underlyingSchema, asBijection) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/ValidatedFoo.scala b/modules/bootstrapped/src/generated/smithy4s/example/ValidatedFoo.scala new file mode 100644 index 000000000..56e6257e0 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/ValidatedFoo.scala @@ -0,0 +1,22 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.schema.Schema.struct + +final case class ValidatedFoo(name: ValidatedString = smithy4s.example.ValidatedString.unsafeApply("abc")) + +object ValidatedFoo extends ShapeTag.Companion[ValidatedFoo] { + val id: ShapeId = ShapeId("smithy4s.example", "ValidatedFoo") + + val hints: Hints = Hints.empty + + // constructor using the original order from the spec + private def make(name: ValidatedString): ValidatedFoo = ValidatedFoo(name) + + implicit val schema: Schema[ValidatedFoo] = struct( + ValidatedString.schema.field[ValidatedFoo]("name", _.name).addHints(smithy.api.Default(smithy4s.Document.fromString("abc"))), + )(make).withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/ValidatedString.scala b/modules/bootstrapped/src/generated/smithy4s/example/ValidatedString.scala new file mode 100644 index 000000000..c02d6ba6d --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/ValidatedString.scala @@ -0,0 +1,18 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.NewtypeValidated +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.Validator +import smithy4s.schema.Schema.bijection +import smithy4s.schema.Schema.string + +object ValidatedString extends NewtypeValidated[String] { + val id: ShapeId = ShapeId("smithy4s.example", "ValidatedString") + val hints: Hints = Hints.empty + val underlyingSchema: Schema[String] = string.withId(id).addHints(hints).validated(smithy.api.Length(min = Some(1L), max = None)).validated(smithy.api.Pattern("[a-zA-Z0-9]+")) + implicit val schema: Schema[ValidatedString] = bijection(underlyingSchema, asBijectionUnsafe) + val validator: Validator[String, ValidatedString] = Validator.of[String, ValidatedString].validating(smithy.api.Length(min = Some(1L), max = None)).alsoValidating(smithy.api.Pattern("[a-zA-Z0-9]+")) + @inline def apply(a: String): Either[String, ValidatedString] = validator.validate(a) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/package.scala b/modules/bootstrapped/src/generated/smithy4s/example/package.scala index 26b3d506a..52d37ec2c 100644 --- a/modules/bootstrapped/src/generated/smithy4s/example/package.scala +++ b/modules/bootstrapped/src/generated/smithy4s/example/package.scala @@ -82,6 +82,7 @@ package object example { type NonEmptyMapNumbers = smithy4s.example.NonEmptyMapNumbers.Type type NonEmptyNames = smithy4s.example.NonEmptyNames.Type type NonEmptyStrings = smithy4s.example.NonEmptyStrings.Type + type NonValidatedString = smithy4s.example.NonValidatedString.Type type ObjectKey = smithy4s.example.ObjectKey.Type type ObjectSize = smithy4s.example.ObjectSize.Type type OrderNumber = smithy4s.example.OrderNumber.Type @@ -115,5 +116,6 @@ package object example { type UVIndex = smithy4s.example.UVIndex.Type type UnicodeRegexString = smithy4s.example.UnicodeRegexString.Type type UnwrappedFancyList = smithy4s.example.UnwrappedFancyList.Type + type ValidatedString = smithy4s.example.ValidatedString.Type } \ No newline at end of file diff --git a/modules/bootstrapped/test/src/smithy4s/ValidatedNewtypesSpec.scala b/modules/bootstrapped/test/src/smithy4s/ValidatedNewtypesSpec.scala new file mode 100644 index 000000000..7aa4a4063 --- /dev/null +++ b/modules/bootstrapped/test/src/smithy4s/ValidatedNewtypesSpec.scala @@ -0,0 +1,106 @@ +/* + * Copyright 2021-2023 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 + +import smithy4s.schema.Schema.bijection +import smithy4s.schema.Schema.string +import munit.Assertions + +class ValidatedNewtypesSpec() extends munit.FunSuite { + val id1 = "id1" + val id2 = "id2" + + test("Validated newtypes are consistent") { + expect.same(AccountId.unsafeApply(id1).value, id1) + expect.different( + AccountId.unsafeApply(id1).value, + AccountId.unsafeApply(id2).value + ) + expect.different( + implicitly[ShapeTag[AccountId]], + implicitly[ShapeTag[DeviceId]] + ) + expect.same(AccountId.unapply(AccountId.unsafeApply(id1)), Some(id1)) + } + + test("Newtypes have well defined unapply") { + val aid = AccountId.unsafeApply(id1) + aid match { + case AccountId(id) => expect(id == id1) + } + } + + test("Validated newtypes unsafeApply throws exception") { + val e = Assertions.intercept[IllegalArgumentException] { + AccountId.unsafeApply("!^%&") + } + + expect.same( + e.getMessage(), + "String '!^%&' does not match pattern '[a-zA-Z0-9]+'" + ) + } + + type DeviceId = DeviceId.Type + object DeviceId extends NewtypeValidated[String] { + + val id: ShapeId = ShapeId("foo", "DeviceId") + val hints: Hints = Hints.empty + + val underlyingSchema: Schema[String] = string + .withId(id) + .addHints(hints) + .validated(smithy.api.Length(min = Some(1L), max = None)) + + implicit val schema: Schema[DeviceId] = + bijection(underlyingSchema, asBijectionUnsafe) + + val validator: Validator[String, DeviceId] = Validator + .of[String, DeviceId] + .validating(smithy.api.Length(min = Some(1L), max = None)) + + @inline def apply(a: String): Either[String, DeviceId] = + validator.validate(a) + + } + + type AccountId = AccountId.Type + + object AccountId extends NewtypeValidated[String] { + def id: smithy4s.ShapeId = ShapeId("foo", "AccountId") + val hints: Hints = Hints.empty + + val underlyingSchema: Schema[String] = string + .withId(id) + .addHints(hints) + .validated(smithy.api.Length(min = Some(1L), max = None)) + .validated(smithy.api.Pattern("[a-zA-Z0-9]+")) + + implicit val schema: Schema[AccountId] = + bijection(underlyingSchema, asBijectionUnsafe) + + val validator: Validator[String, AccountId] = Validator + .of[String, AccountId] + .validating(smithy.api.Length(min = Some(1L), max = None)) + .alsoValidating(smithy.api.Pattern("[a-zA-Z0-9]+")) + + @inline def apply(a: String): Either[String, AccountId] = + validator.validate(a) + + } + +} diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/build.sbt b/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/build.sbt new file mode 100644 index 000000000..3fe4c889c --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/build.sbt @@ -0,0 +1,10 @@ +lazy val root = (project in file(".")) + .enablePlugins(Smithy4sCodegenPlugin) + .settings( + scalaVersion := "2.13.10", + libraryDependencies ++= Seq( + "com.disneystreaming.smithy4s" %% "smithy4s-core" % smithy4sVersion.value, + "com.disneystreaming.smithy4s" %% "smithy4s-dynamic" % smithy4sVersion.value, + "com.disneystreaming.alloy" % "alloy-core" % "0.3.4", + ) + ) diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/project/build.properties b/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/project/build.properties new file mode 100644 index 000000000..72413de15 --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.8.3 diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/project/plugins.sbt b/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/project/plugins.sbt new file mode 100644 index 000000000..b8589b92c --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/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/render-validated-newtypes/src/main/scala/Main.scala b/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/src/main/scala/Main.scala new file mode 100644 index 000000000..0eec3bd3c --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/src/main/scala/Main.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2021-2024 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 newtypes.validated + +import newtypes.validated._ + +object Main extends App { + try { + val cityOrError: Either[String, ValidatedCity] = ValidatedCity("test-city") + val nameOrError: Either[String, ValidatedName] = ValidatedName("test-name") + val country: String = "test-country" + + println( + (nameOrError, cityOrError) match { + case (Right(name), Right(city)) => s"Success: ${Person(name, Some(city), Some(country))}" + case _ => s"Error" + } + ) + } catch { + case _: java.lang.ExceptionInInitializerError => + println("failed") + sys.exit(1) + } +} diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/src/main/smithy/validated-newtypes.smithy b/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/src/main/smithy/validated-newtypes.smithy new file mode 100644 index 000000000..98b73badb --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/src/main/smithy/validated-newtypes.smithy @@ -0,0 +1,30 @@ +$version: "2.0" + +metadata smithy4sRenderValidatedNewtypes = true + +namespace newtypes.validated + +use smithy4s.meta#unwrap +use alloy#simpleRestJson + +@length(min: 1, max: 10) +string ValidatedCity + +@length(min: 1, max: 10) +string ValidatedName + +@unwrap +@length(min: 1, max: 10) +string ValidatedCountry + +structure Person { + @httpLabel + @required + name: ValidatedName + + @httpQuery("town") + town: ValidatedCity + + @httpQuery("country") + country: ValidatedCountry +} diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/test new file mode 100644 index 000000000..863293500 --- /dev/null +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/render-validated-newtypes/test @@ -0,0 +1,2 @@ +# check if smithy4sCodegen works and everything compiles +> compile diff --git a/modules/codegen-plugin/src/smithy4s/codegen/GenerateSmithyBuild.scala b/modules/codegen-plugin/src/smithy4s/codegen/GenerateSmithyBuild.scala index 619a5e2a2..326176850 100644 --- a/modules/codegen-plugin/src/smithy4s/codegen/GenerateSmithyBuild.scala +++ b/modules/codegen-plugin/src/smithy4s/codegen/GenerateSmithyBuild.scala @@ -16,8 +16,9 @@ package smithy4s.codegen -import sbt._ import sbt.Keys._ +import sbt._ + import Smithy4sCodegenPlugin.autoImport._ import scala.collection.immutable.ListSet diff --git a/modules/codegen-plugin/src/smithy4s/codegen/Smithy4sCodegenPlugin.scala b/modules/codegen-plugin/src/smithy4s/codegen/Smithy4sCodegenPlugin.scala index 623fc3e9b..0ffa2cf65 100644 --- a/modules/codegen-plugin/src/smithy4s/codegen/Smithy4sCodegenPlugin.scala +++ b/modules/codegen-plugin/src/smithy4s/codegen/Smithy4sCodegenPlugin.scala @@ -19,7 +19,10 @@ package smithy4s.codegen import sbt.Keys._ import sbt.util.CacheImplicits._ import sbt.{fileJsonFormatter => _, _} -import scala.util.{Success, Try} + +import scala.util.Success +import scala.util.Try + import JsonConverters._ object Smithy4sCodegenPlugin extends AutoPlugin { @@ -269,7 +272,8 @@ object Smithy4sCodegenPlugin extends AutoPlugin { cacheFactory.make("smithy4sGeneratedSmithyFilesOutput") ) { case (changed, prevResult) => if (changed || prevResult.isEmpty) { - val file = (config / smithy4sGeneratedSmithyMetadataFile).value + val file = + (config / smithy4sGeneratedSmithyMetadataFile).value IO.write( file, s"""$$version: "2" diff --git a/modules/codegen/resources/META-INF/services/software.amazon.smithy.build.ProjectionTransformer b/modules/codegen/resources/META-INF/services/software.amazon.smithy.build.ProjectionTransformer index e6014a360..346791687 100644 --- a/modules/codegen/resources/META-INF/services/software.amazon.smithy.build.ProjectionTransformer +++ b/modules/codegen/resources/META-INF/services/software.amazon.smithy.build.ProjectionTransformer @@ -2,3 +2,4 @@ smithy4s.codegen.transformers.AwsStandardTypesTransformer smithy4s.codegen.transformers.AwsConstraintsRemover smithy4s.codegen.transformers.OpenEnumTransformer smithy4s.codegen.transformers.KeepOnlyMarkedShapes +smithy4s.codegen.transformers.ValidatedNewtypesTransformer diff --git a/modules/codegen/src/smithy4s/codegen/internals/CodegenRecord.scala b/modules/codegen/src/smithy4s/codegen/CodegenRecord.scala similarity index 78% rename from modules/codegen/src/smithy4s/codegen/internals/CodegenRecord.scala rename to modules/codegen/src/smithy4s/codegen/CodegenRecord.scala index da20fb605..727cff4f7 100644 --- a/modules/codegen/src/smithy4s/codegen/internals/CodegenRecord.scala +++ b/modules/codegen/src/smithy4s/codegen/CodegenRecord.scala @@ -14,18 +14,20 @@ * limitations under the License. */ -package smithy4s.codegen.internals +package smithy4s.codegen import software.amazon.smithy.model.Model import software.amazon.smithy.model.node.Node import scala.jdk.CollectionConverters._ +import scala.jdk.OptionConverters._ -private[internals] final case class CodegenRecord( - namespaces: List[String] +private[codegen] final case class CodegenRecord( + namespaces: List[String], + validatedNewtypes: Option[Boolean] ) -private[internals] object CodegenRecord { +private[codegen] object CodegenRecord { val METADATA_KEY = "smithy4sGenerated" @@ -41,11 +43,13 @@ private[internals] object CodegenRecord { def fromNode(node: Node): CodegenRecord = { val obj = node.expectObjectNode() val arrayNode = obj.expectArrayMember("namespaces") + val validatedNewtypes = + obj.getBooleanMember("validatedNewtypes").toScala.map(_.getValue()) val namespaces = arrayNode .getElements() .asScala .map(_.expectStringNode().getValue()) .toList - CodegenRecord(namespaces) + CodegenRecord(namespaces, validatedNewtypes) } } diff --git a/modules/codegen/src/smithy4s/codegen/internals/CodegenImpl.scala b/modules/codegen/src/smithy4s/codegen/internals/CodegenImpl.scala index 9f50f31b3..38a35a25f 100644 --- a/modules/codegen/src/smithy4s/codegen/internals/CodegenImpl.scala +++ b/modules/codegen/src/smithy4s/codegen/internals/CodegenImpl.scala @@ -217,6 +217,7 @@ private[codegen] object CodegenImpl { self => AwsConstraintsRemover.name :+ AwsStandardTypesTransformer.name :+ OpenEnumTransformer.name :+ - KeepOnlyMarkedShapes.name + KeepOnlyMarkedShapes.name :+ + ValidatedNewtypesTransformer.name } diff --git a/modules/codegen/src/smithy4s/codegen/internals/CollisionAvoidance.scala b/modules/codegen/src/smithy4s/codegen/internals/CollisionAvoidance.scala index 1966850df..2970e425c 100644 --- a/modules/codegen/src/smithy4s/codegen/internals/CollisionAvoidance.scala +++ b/modules/codegen/src/smithy4s/codegen/internals/CollisionAvoidance.scala @@ -17,10 +17,11 @@ package smithy4s.codegen.internals import cats.~> -import smithy4s.codegen.internals.Type.Nullable import Type.Alias +import Type.Nullable import Type.PrimitiveType +import Type.ValidatedAlias import TypedNode._ import Type.ExternalType import LineSegment._ @@ -86,6 +87,14 @@ private[internals] object CollisionAvoidance { rec, hints.map(modHint) ) + case ValidatedTypeAlias(shapeId, name, tpe, recursive, hints) => + ValidatedTypeAlias( + shapeId, + protectKeyword(name.capitalize), + modType(tpe), + recursive, + hints.map(modHint) + ) case Enumeration(shapeId, name, tag, values, hints) => val newValues = values.map { case EnumValue(value, intValue, name, realName, hints) => @@ -128,6 +137,8 @@ private[internals] object CollisionAvoidance { val protectedName = protectKeyword(name.capitalize) val unwrapped = isUnwrapped | (protectedName != name.capitalize) Alias(namespace, protectKeyword(name.capitalize), modType(tpe), unwrapped) + case ValidatedAlias(namespace, name, tpe) => + ValidatedAlias(namespace, protectKeyword(name.capitalize), modType(tpe)) case PrimitiveType(prim) => PrimitiveType(prim) case ExternalType(name, fqn, typeParams, pFqn, under, refinementHint) => ExternalType( @@ -216,6 +227,8 @@ private[internals] object CollisionAvoidance { ) case NewTypeTN(ref, target) => NewTypeTN(modRef(ref), target) + case ValidatedNewTypeTN(ref, target) => + ValidatedNewTypeTN(modRef(ref), target) case AltTN(ref, altName, alt) => AltTN(modRef(ref), altName, alt) case MapTN(values) => @@ -301,6 +314,7 @@ private[internals] object CollisionAvoidance { val NoInput_ = NameRef("smithy4s", "NoInput") val ShapeId_ = NameRef("smithy4s", "ShapeId") val Schema_ = NameRef("smithy4s", "Schema") + val Validator_ = NameRef("smithy4s", "Validator") val OperationSchema_ = NameRef("smithy4s.schema", "OperationSchema") val FunctorAlgebra_ = NameRef("smithy4s.kinds", "FunctorAlgebra") val BiFunctorAlgebra_ = NameRef("smithy4s.kinds", "BiFunctorAlgebra") @@ -309,6 +323,7 @@ private[internals] object CollisionAvoidance { val EnumValue_ = NameRef("smithy4s.schema", "EnumValue") val EnumTag_ = NameRef("smithy4s.schema", "EnumTag") val Newtype_ = NameRef("smithy4s", "Newtype") + val NewtypeValidated_ = NameRef("smithy4s", "NewtypeValidated") val Hints_ = NameRef("smithy4s", "Hints") val ShapeTag_ = NameRef("smithy4s", "ShapeTag") val ErrorSchema_ = NameRef("smithy4s.schema", "ErrorSchema") diff --git a/modules/codegen/src/smithy4s/codegen/internals/GeneratedNamespace.scala b/modules/codegen/src/smithy4s/codegen/internals/GeneratedNamespace.scala new file mode 100644 index 000000000..15d46c109 --- /dev/null +++ b/modules/codegen/src/smithy4s/codegen/internals/GeneratedNamespace.scala @@ -0,0 +1,22 @@ +/* + * Copyright 2021-2024 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.codegen.internals + +private[internals] final case class GeneratedNamespace( + namespace: String, + validatedNewtypes: Boolean +) diff --git a/modules/codegen/src/smithy4s/codegen/internals/IR.scala b/modules/codegen/src/smithy4s/codegen/internals/IR.scala index 9b5e385c0..443019a0b 100644 --- a/modules/codegen/src/smithy4s/codegen/internals/IR.scala +++ b/modules/codegen/src/smithy4s/codegen/internals/IR.scala @@ -103,6 +103,14 @@ private[internals] case class TypeAlias( hints: List[Hint] = Nil ) extends Decl +private[internals] case class ValidatedTypeAlias( + shapeId: ShapeId, + name: String, + tpe: Type, + recursive: Boolean = false, + hints: List[Hint] = Nil +) extends Decl + private[internals] case class Enumeration( shapeId: ShapeId, name: String, @@ -304,6 +312,8 @@ private[internals] object Type { tpe: Type, isUnwrapped: Boolean ) extends Type + case class ValidatedAlias(namespace: String, name: String, tpe: Type) + extends Type case class PrimitiveType(prim: Primitive) extends Type case class ExternalType( name: String, @@ -357,6 +367,7 @@ private[internals] object Hint { extends Hint case object GenerateServiceProduct extends Hint case object GenerateOptics extends Hint + case object ValidateNewtype extends Hint implicit val eq: Eq[Hint] = Eq.fromUniversalEquals } @@ -447,6 +458,8 @@ private[internals] object TypedNode { fields.traverse(_.traverse(_.traverse(f))).map(StructureTN(ref, _)) case NewTypeTN(ref, target) => f(target).map(NewTypeTN(ref, _)) + case ValidatedNewTypeTN(ref, target) => + f(target).map(ValidatedNewTypeTN(ref, _)) case AltTN(ref, altName, alt) => alt.traverse(f).map(AltTN(ref, altName, _)) case MapTN(values) => @@ -477,6 +490,8 @@ private[internals] object TypedNode { fields: List[(String, FieldTN[A])] ) extends TypedNode[A] case class NewTypeTN[A](ref: Type.Ref, target: A) extends TypedNode[A] + case class ValidatedNewTypeTN[A](ref: Type.Ref, target: A) + extends TypedNode[A] case class AltTN[A](ref: Type.Ref, altName: String, alt: AltValueTN[A]) extends TypedNode[A] case class MapTN[A](values: List[(A, A)]) extends TypedNode[A] diff --git a/modules/codegen/src/smithy4s/codegen/internals/Renderer.scala b/modules/codegen/src/smithy4s/codegen/internals/Renderer.scala index 7980306e4..ffe5386ff 100644 --- a/modules/codegen/src/smithy4s/codegen/internals/Renderer.scala +++ b/modules/codegen/src/smithy4s/codegen/internals/Renderer.scala @@ -185,6 +185,8 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => renderUnion(shapeId, union.nameRef, alts, mixins, recursive, hints) case ta @ TypeAlias(shapeId, _, tpe, _, recursive, hints) => renderNewtype(shapeId, ta.nameRef, tpe, recursive, hints) + case vta @ ValidatedTypeAlias(shapeId, _, tpe, recursive, hints) => + renderValidatedNewtype(shapeId, vta.nameRef, tpe, recursive, hints) case enumeration @ Enumeration(shapeId, _, tag, values, hints) => renderEnum(shapeId, enumeration.nameRef, tag, values, hints) } @@ -259,8 +261,11 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => def renderPackageContents: Lines = { val typeAliases = compilationUnit.declarations - .collect { case TypeAlias(_, name, _, _, _, hints) => - (name, hints) + .collect { + case TypeAlias(_, name, _, _, _, hints) => + (name, hints) + case ValidatedTypeAlias(_, name, _, _, hints) => + (name, hints) } .sortBy(_._1) .map { case (name, hints) => @@ -1338,6 +1343,50 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => ) } + private def renderValidatedNewtype( + shapeId: ShapeId, + name: NameRef, + tpe: Type, + recursive: Boolean, + hints: List[Hint] + ): Lines = { + val validator = { + val tags = hints.collect { case t: Hint.Constraint => t } + tags match { + case h :: tail => + ( + line".validating(${renderNativeHint(h.native)})" +: + tail.map { tag => line".alsoValidating(${renderNativeHint(tag.native)})" } + ).intercalate(Line.empty) + case _ => Line.empty + } + } + + val definition = + if (recursive) line"$recursive_(" + else Line.empty + val trailingCalls = + line".withId(id).addHints(hints)${renderConstraintValidation(hints)}" + val closing = if (recursive) ")" else "" + lines( + documentationAnnotation(hints), + deprecationAnnotation(hints), + obj(name, line"$NewtypeValidated_[$tpe]")( + renderId(shapeId), + renderHintsVal(hints), + line"val underlyingSchema: $Schema_[$tpe] = ${tpe.schemaRef}$trailingCalls", + lines( + line"implicit val schema: $Schema_[$name] = $definition$bijection_(underlyingSchema, asBijectionUnsafe)$closing" + ), + lines( + line"val validator: $Validator_[$tpe, $name] = $Validator_.of[$tpe, $name]$validator" + ), + line"@inline def apply(a: $tpe): Either[String, $name] = validator.validate(a)", + renderTypeclasses(hints, name) + ) + ) + } + private implicit class OperationExt(op: Operation) { def renderArgs = if (op.input == Type.unit) Line.empty @@ -1395,6 +1444,8 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => false ) => NameRef(ns, s"$name.schema").toLine + case Type.ValidatedAlias(ns, name, _) => + NameRef(ns, s"$name.schema").toLine case Type.Alias(ns, name, _, _) => NameRef(ns, s"$name.underlyingSchema").toLine case Type.Ref(ns, name) => NameRef(ns, s"$name.schema").toLine @@ -1534,6 +1585,14 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => else false -> line"${ref.show}($text)" }) + case ValidatedNewTypeTN(ref, target) => + Reader(topLevel => { + val (wroteCollection, text) = target.run(topLevel) + if (wroteCollection && !topLevel) + false -> text + else + false -> line"${ref.show}.unsafeApply($text)" + }) case AltTN(ref, altName, AltValueTN.TypeAltTN(alt)) => line"${ref.show}.${altName.capitalize}Case(${alt.runDefault}).widen".write diff --git a/modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala b/modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala index a14feb50d..7fa6696bb 100644 --- a/modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala +++ b/modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala @@ -16,20 +16,21 @@ package smithy4s.codegen.internals +import alloy.StructurePatternTrait import cats.data.NonEmptyList import cats.implicits._ import smithy4s.meta.AdtMemberTrait +import smithy4s.meta.AdtTrait import smithy4s.meta.ErrorMessageTrait +import smithy4s.meta.GenerateOpticsTrait +import smithy4s.meta.GenerateServiceProductTrait import smithy4s.meta.IndexedSeqTrait import smithy4s.meta.NoStackTraceTrait import smithy4s.meta.PackedInputsTrait import smithy4s.meta.RefinementTrait -import smithy4s.meta.VectorTrait -import smithy4s.meta.AdtTrait -import smithy4s.meta.GenerateServiceProductTrait -import smithy4s.meta.GenerateOpticsTrait import smithy4s.meta.TypeclassTrait -import alloy.StructurePatternTrait +import smithy4s.meta.ValidateNewtypeTrait +import smithy4s.meta.VectorTrait import software.amazon.smithy.aws.traits.ServiceTrait import software.amazon.smithy.model.Model import software.amazon.smithy.model.node._ @@ -47,7 +48,10 @@ import Type.Alias private[codegen] object SmithyToIR { - def apply(model: Model, namespace: String): CompilationUnit = { + def apply( + model: Model, + namespace: String + ): CompilationUnit = { val smithyToIR = new SmithyToIR(model, namespace) PostProcessor( CompilationUnit(namespace, smithyToIR.allDecls, smithyToIR.rendererConfig) @@ -65,7 +69,10 @@ private[codegen] object SmithyToIR { } -private[codegen] class SmithyToIR(model: Model, namespace: String) { +private[codegen] class SmithyToIR( + model: Model, + namespace: String +) { val finder = PathFinder.create(model) @@ -156,6 +163,14 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) { recursive, hints ).some + case Type.ValidatedAlias(_, name, tpe) => + ValidatedTypeAlias( + shape.getId(), + name, + tpe, + recursive, + hints + ).some case Type.PrimitiveType(_) => None case other => TypeAlias( @@ -606,14 +621,26 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) { shape.getId() != ShapeId.from(primitiveId) && !isUnboxedPrimitive(shape.getId()) ) { - Type - .Alias( - shape.getId().getNamespace(), - shape.getId().getName(), - externalOrBase, - isUnwrappedShape(shape) - ) - .some + val shouldValidate = + shape.hasTrait(classOf[ValidateNewtypeTrait]) + if (shouldValidate) { + Type + .ValidatedAlias( + shape.getId().getNamespace(), + shape.getId().getName(), + externalOrBase + ) + .some + } else { + Type + .Alias( + shape.getId().getNamespace(), + shape.getId().getName(), + externalOrBase, + isUnwrappedShape(shape) + ) + .some + } } else externalOrBase.some } @@ -930,6 +957,8 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) { Hint.GenerateServiceProduct case _: GenerateOpticsTrait => Hint.GenerateOptics + case _: ValidateNewtypeTrait => + Hint.ValidateNewtype case t if t.toShapeId() == ShapeId.fromParts("smithy.api", "trait") => Hint.Trait case ConstraintTrait(tr) => Hint.Constraint(toTypeRef(tr), unfoldTrait(tr)) @@ -1330,6 +1359,8 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) { // Alias case (node, Type.Alias(ns, name, tpe, _)) => TypedNode.NewTypeTN(Type.Ref(ns, name), NodeAndType(node, tpe)) + case (node, Type.ValidatedAlias(ns, name, tpe)) => + TypedNode.ValidatedNewTypeTN(Type.Ref(ns, name), NodeAndType(node, tpe)) // Enumeration (Enum Trait) case (N.StringNode(str), UnRef(shape @ T.enumeration(e))) => val (enumDef, index) = diff --git a/modules/codegen/src/smithy4s/codegen/internals/ToLine.scala b/modules/codegen/src/smithy4s/codegen/internals/ToLine.scala index 106914e42..30edc436e 100644 --- a/modules/codegen/src/smithy4s/codegen/internals/ToLine.scala +++ b/modules/codegen/src/smithy4s/codegen/internals/ToLine.scala @@ -66,6 +66,8 @@ private[internals] object ToLine { NameRef(ns, name) case Type.Alias(_, _, aliased, _) => typeToNameRef(aliased) + case Type.ValidatedAlias(ns, name, _) => + NameRef(ns, name) case Type.Ref(namespace, name) => NameRef(namespace, name) case Type.PrimitiveType(prim) => primitiveLine(prim) case e: Type.ExternalType => diff --git a/modules/codegen/src/smithy4s/codegen/transformers/ValidatedNewtypesTransformer.scala b/modules/codegen/src/smithy4s/codegen/transformers/ValidatedNewtypesTransformer.scala new file mode 100644 index 000000000..2a25405f0 --- /dev/null +++ b/modules/codegen/src/smithy4s/codegen/transformers/ValidatedNewtypesTransformer.scala @@ -0,0 +1,105 @@ +/* + * Copyright 2021-2024 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.codegen.transformers + +import smithy4s.meta.UnwrapTrait +import smithy4s.meta.ValidateNewtypeTrait +import software.amazon.smithy.build.ProjectionTransformer +import software.amazon.smithy.build.TransformContext +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.shapes.AbstractShapeBuilder +import software.amazon.smithy.model.shapes.NumberShape +import software.amazon.smithy.model.shapes.Shape +import software.amazon.smithy.model.shapes.StringShape +import software.amazon.smithy.model.traits.LengthTrait +import software.amazon.smithy.model.traits.PatternTrait +import software.amazon.smithy.model.traits.RangeTrait + +import scala.jdk.OptionConverters._ +import smithy4s.codegen.CodegenRecord + +class ValidatedNewtypesTransformer extends ProjectionTransformer { + + override def getName(): String = ValidatedNewtypesTransformer.name + + override def transform(context: TransformContext): Model = { + val transformer = context.getTransformer() + + val model = context.getModel() + val agn = CodegenRecord + .recordsFromModel(model) + .flatMap(record => + record.namespaces.map(ns => + (ns, record.validatedNewtypes.getOrElse(false)) + ) + ) + .toMap + + val supportsValidatedNewtypes = + model + .getMetadataProperty(ValidatedNewtypesTransformer.METADATA_KEY) + .toScala + .flatMap(_.asBooleanNode().toScala.map(_.getValue())) + .getOrElse(false) + + transformer.mapShapes( + model, + s => processShape(s, agn.getOrElse(_, supportsValidatedNewtypes)) + ) + } + + private def processShape(shape: Shape, lookup: String => Boolean) = + if (lookup(shape.getId().getNamespace())) + shape match { + case ValidatedNewtypesTransformer.SupportedShape(s) => + addTrait(Shape.shapeToBuilder(s): AbstractShapeBuilder[_, _]) + case _ => shape + } + else + shape + + private def addTrait[S <: Shape, B <: AbstractShapeBuilder[B, S]]( + builder: AbstractShapeBuilder[B, S] + ): S = { + builder.addTrait(new ValidateNewtypeTrait()) + builder.build() + } + +} + +object ValidatedNewtypesTransformer { + val name = "ValidatedNewtypesTransformer" + + private val METADATA_KEY = "smithy4sRenderValidatedNewtypes" + + object SupportedShape { + def unapply(shape: Shape): Option[Shape] = shape match { + case _ if shape.hasTrait(classOf[UnwrapTrait]) => None + case _ if shape.hasTrait(classOf[ValidateNewtypeTrait]) => None + case s: StringShape if hasStringConstraints(s) => Some(s) + case n: NumberShape if hasNumberConstraints(n) => Some(n) + case _ => None + } + + private def hasStringConstraints(shape: Shape): Boolean = + shape.getTrait(classOf[LengthTrait]).isPresent || + shape.getTrait(classOf[PatternTrait]).isPresent + + private def hasNumberConstraints(shape: Shape): Boolean = + shape.getTrait(classOf[RangeTrait]).isPresent + } +} diff --git a/modules/codegen/test/src/smithy4s/codegen/internals/RendererSpec.scala b/modules/codegen/test/src/smithy4s/codegen/internals/RendererSpec.scala index 9d05e2333..260671583 100644 --- a/modules/codegen/test/src/smithy4s/codegen/internals/RendererSpec.scala +++ b/modules/codegen/test/src/smithy4s/codegen/internals/RendererSpec.scala @@ -520,4 +520,36 @@ final class RendererSpec extends munit.ScalaCheckSuite { } } + test("newtype with constraint and validateNewtype annotation") { + val smithy = """ + |$version: "2" + | + |namespace smithy4s.example + | + |use smithy4s.meta#validateNewtype + | + |@length(min: 1, max: 10) + |@validateNewtype + |string MyValidatedString + | + |structure ValidatedFoo { + | mvs: MyValidatedString + |} + |""".stripMargin + + val contents = generateScalaCode(smithy).values + + assert( + contents.exists( + _.contains("object MyValidatedString extends NewtypeValidated[String]") + ) + ) + assert( + contents.exists( + _.contains( + "final case class ValidatedFoo(mvs: Option[MyValidatedString] = None)" + ) + ) + ) + } } diff --git a/modules/codegen/test/src/smithy4s/codegen/transformers/ValidatedNewtypesTransformerSpec.scala b/modules/codegen/test/src/smithy4s/codegen/transformers/ValidatedNewtypesTransformerSpec.scala new file mode 100644 index 000000000..0f9ed0577 --- /dev/null +++ b/modules/codegen/test/src/smithy4s/codegen/transformers/ValidatedNewtypesTransformerSpec.scala @@ -0,0 +1,176 @@ +/* + * Copyright 2021-2024 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.codegen.transformers + +import software.amazon.smithy.build.TransformContext +import smithy4s.meta.ValidateNewtypeTrait +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.model.Model + +class ValidatedNewtypesTransformerSpec extends munit.FunSuite { + + import smithy4s.codegen.internals.TestUtils._ + + test( + "Leaves shape unchanged when @validateNewtype is already present" + ) { + assertPresent("smithy4s.transformer.test#ValidatedString") { + """|$version: "2.0" + | + |metadata smithy4sRenderValidatedNewtypes = false + | + |namespace smithy4s.transformer.test + | + |use smithy4s.meta#validateNewtype + | + |@length(min: 1, max: 10) + |@validateNewtype + |string ValidatedString + |""".stripMargin + } + } + + test("Adds @validateNewtype on string alias with constraint") { + assertPresent("smithy4s.transformer.test#ValidatedString") { + """|$version: "2.0" + | + |metadata smithy4sRenderValidatedNewtypes = true + | + |namespace smithy4s.transformer.test + | + |@length(min: 1, max: 10) + |string ValidatedString + |""".stripMargin + } + } + + test("Adds @validateNewtype on number alias with constraint") { + assertPresent("smithy4s.transformer.test#ValidatedNumber") { + """|$version: "2.0" + | + |metadata smithy4sRenderValidatedNewtypes = true + | + |namespace smithy4s.transformer.test + | + |@range(min: 1, max: 10) + |integer ValidatedNumber + |""".stripMargin + } + } + + test("Does not add @validateNewtype on unwrapped string alias") { + assertMissing("smithy4s.transformer.test#ValidatedString") { + """|$version: "2.0" + | + |metadata smithy4sRenderValidatedNewtypes = true + | + |namespace smithy4s.transformer.test + | + |use smithy4s.meta#unwrap + | + |@length(min: 1, max: 10) + |@unwrap + |string ValidatedString + |""".stripMargin + } + } + + test( + "Does not add @validateNewtype on type when smithy4sRenderValidatedNewtypes=false" + ) { + assertMissing("smithy4s.transformer.test#ValidatedString") { + """|$version: "2.0" + | + |metadata smithy4sRenderValidatedNewtypes = false + | + |namespace smithy4s.transformer.test + | + |@length(min: 1, max: 10) + |string ValidatedString + |""".stripMargin + } + } + + test( + "Does not add @validateNewtype on previously generated shapes with validatedNewtypes=false" + ) { + assertMissing("smithy4s.transformer.test#ValidatedString") { + """|$version: "2.0" + | + |metadata smithy4sRenderValidatedNewtypes = true + |metadata smithy4sGenerated = [{smithy4sVersion: "dev-SNAPSHOT", namespaces: ["smithy4s.transformer.test"], validatedNewtypes: false}] + | + |namespace smithy4s.transformer.test + | + |@length(min: 1, max: 10) + |string ValidatedString + |""".stripMargin + } + } + + test( + "Adds @validateNewtype on previously generated shapes with validatedNewtypes=true" + ) { + assertPresent("smithy4s.transformer.test#ValidatedString") { + """|$version: "2.0" + | + |metadata smithy4sRenderValidatedNewtypes = true + |metadata smithy4sGenerated = [{smithy4sVersion: "dev-SNAPSHOT", namespaces: ["smithy4s.transformer.test"], validatedNewtypes: true}] + | + |namespace smithy4s.transformer.test + | + |@length(min: 1, max: 10) + |string ValidatedString + |""".stripMargin + } + } + + private def assertPresent(shapeId: String)(inputModel: String*)(implicit + loc: munit.Location + ): Unit = { + val containsTrait = loadAndTransformModel(inputModel: _*) + .expectShape(ShapeId.from(shapeId)) + .hasTrait(classOf[ValidateNewtypeTrait]) + + assert( + containsTrait, + "Expected validateNewtype trait to be present" + ) + } + + private def assertMissing(shapeId: String)(inputModel: String*)(implicit + loc: munit.Location + ): Unit = { + val containsTrait = loadAndTransformModel(inputModel: _*) + .expectShape(ShapeId.from(shapeId)) + .hasTrait(classOf[ValidateNewtypeTrait]) + + assert( + !containsTrait, + "Expected validateNewtype trait to be missing" + ) + } + + def loadAndTransformModel(inputModel: String*): Model = + new ValidatedNewtypesTransformer() + .transform( + TransformContext + .builder() + .model(loadModel(inputModel: _*)) + .build() + ) + +} diff --git a/modules/core/src-2/NewtypeValidated.scala b/modules/core/src-2/NewtypeValidated.scala new file mode 100644 index 000000000..43bc4992c --- /dev/null +++ b/modules/core/src-2/NewtypeValidated.scala @@ -0,0 +1,62 @@ +/* + * Copyright 2021-2024 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 + +abstract class NewtypeValidated[A] extends HasId { self => + // This encoding originally comes from this library: + // https://github.com/alexknvl/newtypes#what-does-it-do + type Base + trait _Tag extends Any + type Type <: Base with _Tag + + @inline def apply(a: A): Either[String, Type] + + @inline final def unsafeApply(a: A): Type = apply(a) match { + case Right(value) => value + case Left(error) => throw new IllegalArgumentException(error) + } + + @inline final def value(x: Type): A = + x.asInstanceOf[A] + + implicit final class Ops(val self: Type) { + @inline final def value: A = NewtypeValidated.this.value(self) + } + + def schema: Schema[Type] + + implicit val tag: ShapeTag[Type] = new ShapeTag[Type] { + def id: ShapeId = self.id + def schema: Schema[Type] = self.schema + } + + def unapply(t: Type): Some[A] = Some(t.value) + + protected implicit val asBijectionUnsafe: Bijection[A, Type] = + new NewtypeValidated.Make[A, Type] { + def to(a: A): Type = a.asInstanceOf[Type] + def from(t: Type): A = value(t) + } + + object hint { + def unapply(h: Hints): Option[Type] = h.get(tag) + } +} + +object NewtypeValidated { + private[smithy4s] trait Make[A, B] extends Bijection[A, B] +} diff --git a/modules/core/src-3/NewtypeValidated.scala b/modules/core/src-3/NewtypeValidated.scala new file mode 100644 index 000000000..bac19395e --- /dev/null +++ b/modules/core/src-3/NewtypeValidated.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2021-2024 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 + +abstract class NewtypeValidated[A] extends HasId { self => + opaque type Type = A + + def apply(a: A): Either[String, Type] + + def unsafeApply(a: A): Type = apply(a) match { + case Right(value) => value + case Left(error) => throw new IllegalArgumentException(error) + } + + extension (orig: Type) def value: A = orig + + def unapply(orig: Type): Some[A] = Some(orig.value) + + def schema: Schema[Type] + + implicit val tag: ShapeTag[Type] = new ShapeTag[Type] { + def id: ShapeId = self.id + def schema: Schema[Type] = self.schema + } + + protected implicit val asBijectionUnsafe: Bijection[A, Type] = + new NewtypeValidated.Make[A, Type] { + def to(a: A): Type = a.asInstanceOf[Type] + def from(t: Type): A = value(t) + } + + object hint { + def unapply(h: Hints): Option[Type] = h.get(tag) + } +} + +object NewtypeValidated { + private[smithy4s] trait Make[A, B] extends Bijection[A, B] +} diff --git a/modules/core/src/smithy4s/Hints.scala b/modules/core/src/smithy4s/Hints.scala index 0d2729e4d..3b414f407 100644 --- a/modules/core/src/smithy4s/Hints.scala +++ b/modules/core/src/smithy4s/Hints.scala @@ -47,6 +47,7 @@ trait Hints { final def has[A](implicit key: ShapeTag[A]): Boolean = this.get[A].isDefined final def get[A](key: ShapeTag.Has[A]): Option[A] = get(key.getTag) final def get[T](nt: Newtype[T]): Option[nt.Type] = get(nt.tag) + final def get[T](vnt: NewtypeValidated[T]): Option[vnt.Type] = get(vnt.tag) final def filter(predicate: Hint => Boolean): Hints = Hints.fromSeq(all.filter(predicate).toSeq) final def filterNot(predicate: Hint => Boolean): Hints = diff --git a/modules/core/src/smithy4s/Validator.scala b/modules/core/src/smithy4s/Validator.scala new file mode 100644 index 000000000..4122da908 --- /dev/null +++ b/modules/core/src/smithy4s/Validator.scala @@ -0,0 +1,59 @@ +/* + * Copyright 2021-2024 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 + +sealed trait Validator[A, B] { + def validate(value: A): Either[String, B] + + def alsoValidating[C](constraint: C)(implicit + ev: RefinementProvider.Simple[C, A] + ): Validator[A, B] +} + +object Validator { + + def of[A, B]: ValidatorBuilder[A, B] = + new ValidatorBuilder[A, B] + + final class ValidatorBuilder[A, B] private[smithy4s] () { + def validating[C](constraint: C)(implicit + bijection: Bijection[A, B], + ev: RefinementProvider.Simple[C, A] + ): Validator[A, B] = + new ValidatorImpl[A, B](List(ev.make(constraint)), bijection) + } + + private class ValidatorImpl[A, B]( + refinements: List[Refinement.Aux[_, A, A]], + bijection: Bijection[A, B] + ) extends Validator[A, B] { + + override def validate(value: A): Either[String, B] = { + refinements + .foldLeft(Right(value): Either[String, A]) { + (valueOrError, refinement) => + valueOrError.flatMap(refinement.apply) + } + .map(bijection.apply) + } + + override def alsoValidating[C](constraint: C)(implicit + ev: RefinementProvider.Simple[C, A] + ): Validator[A, B] = + new ValidatorImpl[A, B](refinements :+ ev.make(constraint), bijection) + } +} diff --git a/modules/core/src/smithy4s/http/internals/UrlFormDataDecoderSchemaVisitor.scala b/modules/core/src/smithy4s/http/internals/UrlFormDataDecoderSchemaVisitor.scala index 56fd88820..720a693b6 100644 --- a/modules/core/src/smithy4s/http/internals/UrlFormDataDecoderSchemaVisitor.scala +++ b/modules/core/src/smithy4s/http/internals/UrlFormDataDecoderSchemaVisitor.scala @@ -110,7 +110,9 @@ private[http] class UrlFormDataDecoderSchemaVisitor( val kvSchema: Schema[(K, V)] = { val kField = key.required[KV]("key", _._1) val vField = value.required[KV]("value", _._2) - Schema.struct(kField, vField)((_, _)).addHints(UrlFormName("entry")) + Schema + .struct(kField, vField)((_, _)) + .addHints(UrlFormName("entry")) } compile(Schema.vector(kvSchema).addHints(hints)) .map(_.toMap) diff --git a/modules/core/src/smithy4s/http/internals/UrlFormDataEncoderSchemaVisitor.scala b/modules/core/src/smithy4s/http/internals/UrlFormDataEncoderSchemaVisitor.scala index 9409b49cb..7758cf0b7 100644 --- a/modules/core/src/smithy4s/http/internals/UrlFormDataEncoderSchemaVisitor.scala +++ b/modules/core/src/smithy4s/http/internals/UrlFormDataEncoderSchemaVisitor.scala @@ -98,7 +98,9 @@ private[http] class UrlFormDataEncoderSchemaVisitor( val kvSchema: Schema[(K, V)] = { val kField = key.required[KV]("key", _._1) val vField = value.required[KV]("value", _._2) - Schema.struct(kField, vField)((_, _)).addHints(UrlFormName("entry")) + Schema + .struct(kField, vField)((_, _)) + .addHints(UrlFormName("entry")) } // Avoid serialising empty maps, see comment in collection case and // https://github.com/smithy-lang/smithy/issues/1868. diff --git a/modules/docs/markdown/04-codegen/01-customisation/15-validated-newtypes.md b/modules/docs/markdown/04-codegen/01-customisation/15-validated-newtypes.md new file mode 100644 index 000000000..1af8b8977 --- /dev/null +++ b/modules/docs/markdown/04-codegen/01-customisation/15-validated-newtypes.md @@ -0,0 +1,60 @@ +--- +sidebar_label: Validated Newtypes +title: Validated Newtypes +--- + +As of version `0.19.x` Smithy4s has the ability to render constrained newtypes over Smithy primitives as +"validated" classes in the code it generates. In practice this means that a newtype will now have an +`apply` method that returns either a validated value or an error. + +The way to utilize this feature is through your Smithy specifications by adding a file with the following +content to your Smithy sources: + +```kotlin +$version: "2" + +metadata smithy4sRenderValidatedNewtypes = true +``` + +Alternatively, if you want to generate validated newtypes only for select shapes in your model, you can accomplish +this using the `smithy4s.meta#validateNewtype` trait. This trait can only be used on number shapes with a range +constraint or string shapes with pattern and/or length constraints. + +```kotlin +use smithy4s.meta#validateNewtype + +@validateNewtype +@length(min: 5) +string Name +``` + +Below is the generated scala class that Smithy4s will generate: + +```scala mdoc:compile-only + +import smithy4s._ +import smithy4s.schema.Schema.bijection +import smithy4s.schema.Schema.string + +type Name = Name.Type + +object Name extends NewtypeValidated[String] { + val id: ShapeId = ShapeId("smithy4s.example", "Name") + + val hints: Hints = Hints.empty + + val underlyingSchema: Schema[String] = + string + .withId(id) + .addHints(hints) + .validated(smithy.api.Length(min = Some(5L), max = None)) + + implicit val schema: Schema[Name] = bijection(underlyingSchema, asBijectionUnsafe) + + val validator: Validator[String, Name] = + Validator.of[String, Name] + .validating(smithy.api.Length(min = Some(5L), max = None)) + + @inline def apply(a: String): Either[String, Name] = validator.validate(a) +} +``` \ No newline at end of file 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 7f6adf665..5efd65965 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 @@ -11,3 +11,4 @@ smithy4s.meta.TypeclassTrait$Provider smithy4s.meta.GenerateServiceProductTrait$Provider smithy4s.meta.GenerateOpticsTrait$Provider smithy4s.meta.OnlyTrait$Provider +smithy4s.meta.ValidateNewtypeTrait$Provider diff --git a/modules/protocol/resources/META-INF/smithy/smithy4s.meta.smithy b/modules/protocol/resources/META-INF/smithy/smithy4s.meta.smithy index 283f4e754..f7d6311ac 100644 --- a/modules/protocol/resources/META-INF/smithy/smithy4s.meta.smithy +++ b/modules/protocol/resources/META-INF/smithy/smithy4s.meta.smithy @@ -163,3 +163,12 @@ structure generateOptics {} /// via extending scala.util.control.NoStackTrace instead of Throwable. @trait(selector: "structure :is([trait|error])") structure noStackTrace {} + +@trait(selector: """ + :is( + number[trait|range], + string[trait|pattern], + string[trait|length] + )""", + conflicts: ["smithy4s.meta#unwrap"]) +structure validateNewtype {} diff --git a/modules/protocol/src/smithy4s/meta/ValidateNewtypeTrait.java b/modules/protocol/src/smithy4s/meta/ValidateNewtypeTrait.java new file mode 100644 index 000000000..50ee8b445 --- /dev/null +++ b/modules/protocol/src/smithy4s/meta/ValidateNewtypeTrait.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021-2024 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 ValidateNewtypeTrait extends AnnotationTrait { + public static ShapeId ID = ShapeId.from("smithy4s.meta#validateNewtype"); + + public ValidateNewtypeTrait(ObjectNode node) { + super(ID, node); + } + + public ValidateNewtypeTrait() { + super(ID, Node.objectNode()); + } + + public static final class Provider extends AbstractTrait.Provider { + public Provider() { + super(ID); + } + + @Override + public ValidateNewtypeTrait createTrait(ShapeId target, Node node) { + return new ValidateNewtypeTrait(node.expectObjectNode()); + } + } +} diff --git a/sampleSpecs/validated-newtype.smithy b/sampleSpecs/validated-newtype.smithy new file mode 100644 index 000000000..62dac2c8f --- /dev/null +++ b/sampleSpecs/validated-newtype.smithy @@ -0,0 +1,19 @@ +$version: "2" + +namespace smithy4s.example + +use smithy4s.meta#validateNewtype + +@length(min: 1) +@pattern("[a-zA-Z0-9]+") +@validateNewtype +string ValidatedString + +@length(min: 1) +@pattern("[a-zA-Z0-9]+") +string NonValidatedString + +structure ValidatedFoo { + name: ValidatedString = "abc" +} +