diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8615e1aa3..d463bff2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ on: # midnight, every Monday and Thursday - cron: '0 0 * * 1,4' +env: + SBT_OPTS: "-XX:MaxMetaspaceSize=4G -XX:MaxInlineLevel=20 -Xss2m -Xms512M -Xmx6G -XX:ReservedCodeCacheSize=256M" + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -62,8 +65,7 @@ jobs: - name: Run tests run: | sbt test_$BUILD_KEY \ - pushRemoteCache_$BUILD_KEY \ - -J-Xmx4G + pushRemoteCache_$BUILD_KEY - name: Run plugin tests if: matrix.scalaVersion == '2_12' && matrix.scalaPlatform == 'jvm' @@ -134,7 +136,7 @@ jobs: - name: Publish ${{ github.ref }} run: | echo $PGP_SECRET | base64 --decode | gpg --import --no-tty --batch --yes - sbt 'pullRemoteCache; release' -J-Xmx2G + sbt 'pullRemoteCache; release' env: PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} PGP_SECRET: ${{ secrets.PGP_SECRET }} diff --git a/.gitignore b/.gitignore index ac125a60d..6ad921464 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,5 @@ version #smithy cli metadata build/smithy/classpath.json + +.sbtopts diff --git a/.sbtopts b/.sbtopts.example similarity index 100% rename from .sbtopts rename to .sbtopts.example diff --git a/CHANGELOG.md b/CHANGELOG.md index 0022b9f3a..1f201a404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,27 @@ Previously they'd be named after the **member target**, now they will use the na There's usually only one instance of `EncoderK[F, A]` for a particular `F[_]`, and interpreters don't need to know what `A` is. For convenience, the type parameter has been moved to a type member. +# 0.18.23 + +## 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. + +# 0.18.22 + +* Add support for `@default` for `Timestamp` fields in https://github.com/disneystreaming/smithy4s/pull/1557 + +# 0.18.21 + +## Documentation fix + +* Addition of a new `@scalaImport` trait to provide a mechanism to add additional imports to the generated code. Read the new [docs](https://disneystreaming.github.io/smithy4s/docs/codegen/customisation/scala-imports) for more info (see https://github.com/disneystreaming/smithy4s/pull/1550). +* Added support for parsing timestamps without seconds in https://github.com/disneystreaming/smithy4s/pull/1553. + +# 0.18.20 + +* Change semantics of `Blob.equals` - Blobs do not take underlying type into consideration, just bytes in https://github.com/disneystreaming/smithy4s/pull/1526 + # 0.18.19 - binary-breaking changes in `core` **WARNING**: This release includes binary-breaking changes in the `core` module. This is indirectly caused by an upstream change in [smithy-lang/smithy](https://github.com/smithy-lang/smithy/). diff --git a/README.md b/README.md index d87d38482..29eec1887 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,12 @@ ThisBuild / bloopAllowedCombos := Seq( ) ) ``` + +## Note for .sbtopts + +You usually should use `.sbtopts` to add some more memory for `sbt`, as Smithy4s is complex. You copy the `.sbtopts.example` to `.sbtopts` and adjust the values to your needs: + +```bash +cp .sbtopts.example .sbtopts +``` + diff --git a/build.sbt b/build.sbt index 29063458f..1c8d3eeb2 100644 --- a/build.sbt +++ b/build.sbt @@ -210,6 +210,9 @@ lazy val core = projectMatrix } .taskValue }, + scalacOptions ++= Seq( + "-Wconf:msg=value noInlineDocumentSupport in class ProtocolDefinition is deprecated:silent" + ), libraryDependencies += Dependencies.collectionsCompat.value, Compile / packageSrc / mappings ++= { val base = (Compile / sourceManaged).value @@ -414,7 +417,7 @@ lazy val codegen = projectMatrix Dependencies.Alloy.core, Dependencies.Alloy.openapi, Dependencies.Smithytranslate.proto, - "com.lihaoyi" %% "os-lib" % "0.9.3", + "com.lihaoyi" %% "os-lib" % "0.10.1", Dependencies.Circe.core.value, Dependencies.Circe.parser.value, Dependencies.Circe.generic.value, @@ -471,6 +474,7 @@ lazy val codegenPlugin = (projectMatrix in file("modules/codegen-plugin")) Compile / unmanagedSources / excludeFilter := { f => Glob("**/sbt-test/**").matches(f.toPath) }, + libraryDependencies += Dependencies.MunitV1.diff.value, publishLocal := { // make sure that core and codegen are published before the // plugin is published @@ -704,8 +708,8 @@ lazy val protobuf = projectMatrix libraryDependencies ++= { if (virtualAxes.value.contains(VirtualAxis.jvm)) Seq( - "com.google.protobuf" % "protobuf-java" % "3.24.0", - "com.google.protobuf" % "protobuf-java-util" % "3.24.0" % Test + "com.google.protobuf" % "protobuf-java" % "3.24.4", + "com.google.protobuf" % "protobuf-java-util" % "3.24.4" % Test ) else Seq( @@ -941,6 +945,10 @@ lazy val bootstrapped = projectMatrix Compile / PB.protoSources ++= Seq( exampleGeneratedResourcesOutput.value ), + Compile / PB.protocExecutable := sys.env + .get("PROTOC_PATH") + .map(file(_)) + .getOrElse((Compile / PB.protocExecutable).value), Compile / PB.targets := Seq( scalapb.gen() -> (Compile / sourceManaged).value / "scalapb" ), diff --git a/flake.lock b/flake.lock index e0823f3fb..19f639ea5 100644 --- a/flake.lock +++ b/flake.lock @@ -17,11 +17,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1663192503, - "narHash": "sha256-vbyobo3DokKs/d4dlp8dIpVi3zYzXl4xo3e8VnY1Bj8=", + "lastModified": 1718110205, + "narHash": "sha256-cv//kqJTOcaL5v3bfU/q+McjzlPikQ9omL3j7qltUsA=", "owner": "nixos", "repo": "nixpkgs", - "rev": "31946d7d94ea25194b8c4c23e5770ec09d42c3d0", + "rev": "8f5f49d8ad22689611fcbb62b1a79124a35cf67d", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 8295d4920..f97941baa 100644 --- a/flake.nix +++ b/flake.nix @@ -2,30 +2,32 @@ inputs.nixpkgs.url = "github:nixos/nixpkgs"; inputs.flake-utils.url = "github:numtide/flake-utils"; - outputs = { self, nixpkgs, flake-utils, ... }@inputs: + outputs = { nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; - shellPackages = [ - "jre" - "sbt" - "nodejs-16_x" - "yarn" + shellPackages = with pkgs; [ + temurin-jre-bin-17 + nodejs-18_x + yarn + (pkgs.sbt.override { jre = pkgs.temurin-jre-bin-17; }) ]; + protobuf = pkgs.protobuf3_21; in { devShells.default = pkgs.mkShell { - buildInputs = map (pkgName: pkgs.${pkgName}) shellPackages; - nativeBuildInputs = [ pkgs.openssl pkgs.zlib ]; + buildInputs = shellPackages; + nativeBuildInputs = [ pkgs.openssl pkgs.zlib protobuf ]; welcomeMessage = '' Welcome to the smithy4s Nix shell! 👋 Available packages: - ${builtins.concatStringsSep "\n" (map (n : "- ${n}") shellPackages)} + ${builtins.concatStringsSep "\n" (map (n : "- ${n.name}") shellPackages)} ''; shellHook = '' echo "$welcomeMessage" ''; + PROTOC_PATH = pkgs.lib.getExe protobuf; }; } ); diff --git a/modules/bootstrapped/resources/smithy4s.example.ServiceWithNullsAndDefaults.json b/modules/bootstrapped/resources/smithy4s.example.ServiceWithNullsAndDefaults.json index a2a62f672..bd11a2e73 100644 --- a/modules/bootstrapped/resources/smithy4s.example.ServiceWithNullsAndDefaults.json +++ b/modules/bootstrapped/resources/smithy4s.example.ServiceWithNullsAndDefaults.json @@ -7,12 +7,12 @@ "paths": { "/operation/{requiredLabel}": { "post": { - "operationId": "Operation", + "operationId": "DefaultNullsOperation", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OperationRequestContent" + "$ref": "#/components/schemas/DefaultNullsOperationRequestContent" } } }, @@ -78,7 +78,7 @@ ], "responses": { "200": { - "description": "Operation 200 response", + "description": "DefaultNullsOperation 200 response", "headers": { "optional-header": { "schema": { @@ -102,18 +102,38 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OperationResponseContent" + "$ref": "#/components/schemas/DefaultNullsOperationResponseContent" } } } } } } + }, + "/timestamp-operation": { + "post": { + "operationId": "TimestampOperation", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimestampOperationRequestContent" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "TimestampOperation 200 response" + } + } + } } }, "components": { "schemas": { - "OperationRequestContent": { + "DefaultNullsOperationRequestContent": { "type": "object", "properties": { "optional": { @@ -132,7 +152,7 @@ "requiredWithDefault" ] }, - "OperationResponseContent": { + "DefaultNullsOperationResponseContent": { "type": "object", "properties": { "optional": { @@ -150,6 +170,30 @@ "required": [ "requiredWithDefault" ] + }, + "TimestampOperationRequestContent": { + "type": "object", + "properties": { + "httpDate": { + "type": "string", + "default": "Thu, 23 May 2024 10:20:30 GMT", + "format": "date-time" + }, + "epochSeconds": { + "type": "number", + "default": 1716459630 + }, + "dateTime": { + "type": "string", + "default": "2024-05-23T10:20:30.000Z", + "format": "date-time" + } + }, + "required": [ + "dateTime", + "epochSeconds", + "httpDate" + ] } } } diff --git a/modules/bootstrapped/src/generated/smithy4s/example/DefaultNullsOperationInput.scala b/modules/bootstrapped/src/generated/smithy4s/example/DefaultNullsOperationInput.scala new file mode 100644 index 000000000..6c5c5af12 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/DefaultNullsOperationInput.scala @@ -0,0 +1,34 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.schema.Schema.string +import smithy4s.schema.Schema.struct + +final case class DefaultNullsOperationInput(optionalWithDefault: String = "optional-default", requiredLabel: String = "required-label-with-default", requiredWithDefault: String = "required-default", optionalHeaderWithDefault: String = "optional-header-with-default", requiredHeaderWithDefault: String = "required-header-with-default", optionalQueryWithDefault: String = "optional-query-with-default", requiredQueryWithDefault: String = "required-query-with-default", optional: Option[String] = None, optionalHeader: Option[String] = None, optionalQuery: Option[String] = None) + +object DefaultNullsOperationInput extends ShapeTag.Companion[DefaultNullsOperationInput] { + val id: ShapeId = ShapeId("smithy4s.example", "DefaultNullsOperationInput") + + val hints: Hints = Hints( + smithy.api.Input(), + ).lazily + + // constructor using the original order from the spec + private def make(optional: Option[String], optionalWithDefault: String, requiredLabel: String, requiredWithDefault: String, optionalHeader: Option[String], optionalHeaderWithDefault: String, requiredHeaderWithDefault: String, optionalQuery: Option[String], optionalQueryWithDefault: String, requiredQueryWithDefault: String): DefaultNullsOperationInput = DefaultNullsOperationInput(optionalWithDefault, requiredLabel, requiredWithDefault, optionalHeaderWithDefault, requiredHeaderWithDefault, optionalQueryWithDefault, requiredQueryWithDefault, optional, optionalHeader, optionalQuery) + + implicit val schema: Schema[DefaultNullsOperationInput] = struct( + string.optional[DefaultNullsOperationInput]("optional", _.optional), + string.field[DefaultNullsOperationInput]("optionalWithDefault", _.optionalWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("optional-default"))), + string.required[DefaultNullsOperationInput]("requiredLabel", _.requiredLabel).addHints(smithy.api.Default(smithy4s.Document.fromString("required-label-with-default")), smithy.api.HttpLabel()), + string.required[DefaultNullsOperationInput]("requiredWithDefault", _.requiredWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("required-default"))), + string.optional[DefaultNullsOperationInput]("optionalHeader", _.optionalHeader).addHints(smithy.api.HttpHeader("optional-header")), + string.field[DefaultNullsOperationInput]("optionalHeaderWithDefault", _.optionalHeaderWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("optional-header-with-default")), smithy.api.HttpHeader("optional-header-with-default")), + string.required[DefaultNullsOperationInput]("requiredHeaderWithDefault", _.requiredHeaderWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("required-header-with-default")), smithy.api.HttpHeader("required-header-with-default")), + string.optional[DefaultNullsOperationInput]("optionalQuery", _.optionalQuery).addHints(smithy.api.HttpQuery("optional-query")), + string.field[DefaultNullsOperationInput]("optionalQueryWithDefault", _.optionalQueryWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("optional-query-with-default")), smithy.api.HttpQuery("optional-query-with-default")), + string.field[DefaultNullsOperationInput]("requiredQueryWithDefault", _.requiredQueryWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("required-query-with-default")), smithy.api.HttpQuery("required-query-with-default")), + )(make).withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/DefaultNullsOperationOutput.scala b/modules/bootstrapped/src/generated/smithy4s/example/DefaultNullsOperationOutput.scala new file mode 100644 index 000000000..f81abaf10 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/DefaultNullsOperationOutput.scala @@ -0,0 +1,30 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.schema.Schema.string +import smithy4s.schema.Schema.struct + +final case class DefaultNullsOperationOutput(optionalWithDefault: String = "optional-default", requiredWithDefault: String = "required-default", optionalHeaderWithDefault: String = "optional-header-with-default", requiredHeaderWithDefault: String = "required-header-with-default", optional: Option[String] = None, optionalHeader: Option[String] = None) + +object DefaultNullsOperationOutput extends ShapeTag.Companion[DefaultNullsOperationOutput] { + val id: ShapeId = ShapeId("smithy4s.example", "DefaultNullsOperationOutput") + + val hints: Hints = Hints( + smithy.api.Output(), + ).lazily + + // constructor using the original order from the spec + private def make(optional: Option[String], optionalWithDefault: String, requiredWithDefault: String, optionalHeader: Option[String], optionalHeaderWithDefault: String, requiredHeaderWithDefault: String): DefaultNullsOperationOutput = DefaultNullsOperationOutput(optionalWithDefault, requiredWithDefault, optionalHeaderWithDefault, requiredHeaderWithDefault, optional, optionalHeader) + + implicit val schema: Schema[DefaultNullsOperationOutput] = struct( + string.optional[DefaultNullsOperationOutput]("optional", _.optional), + string.field[DefaultNullsOperationOutput]("optionalWithDefault", _.optionalWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("optional-default"))), + string.required[DefaultNullsOperationOutput]("requiredWithDefault", _.requiredWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("required-default"))), + string.optional[DefaultNullsOperationOutput]("optionalHeader", _.optionalHeader).addHints(smithy.api.HttpHeader("optional-header")), + string.field[DefaultNullsOperationOutput]("optionalHeaderWithDefault", _.optionalHeaderWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("optional-header-with-default")), smithy.api.HttpHeader("optional-header-with-default")), + string.required[DefaultNullsOperationOutput]("requiredHeaderWithDefault", _.requiredHeaderWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("required-header-with-default")), smithy.api.HttpHeader("required-header-with-default")), + )(make).withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/DefaultTest.scala b/modules/bootstrapped/src/generated/smithy4s/example/DefaultTest.scala index bc58468ea..9b4354fea 100644 --- a/modules/bootstrapped/src/generated/smithy4s/example/DefaultTest.scala +++ b/modules/bootstrapped/src/generated/smithy4s/example/DefaultTest.scala @@ -20,7 +20,7 @@ import smithy4s.schema.Schema.string import smithy4s.schema.Schema.struct import smithy4s.schema.Schema.timestamp -final case class DefaultTest(one: Int = 1, two: String = "test", three: List[String] = List(), four: List[String] = List(), five: String = "", six: Int = 0, seven: Document = smithy4s.Document.nullDoc, eight: Map[String, String] = Map(), nine: Short = 0, ten: Double = 0.0d, eleven: Float = 0.0f, twelve: Long = 0L, thirteen: Timestamp = Timestamp(0, 0), fourteen: Timestamp = Timestamp(0, 0), fifteen: Timestamp = Timestamp(0, 0), sixteen: Byte = 0, seventeen: Blob = Blob.empty, eighteen: Boolean = false) +final case class DefaultTest(one: Int = 1, two: String = "test", three: List[String] = List(), four: List[String] = List(), five: String = "", six: Int = 0, seven: Document = smithy4s.Document.nullDoc, eight: Map[String, String] = Map(), nine: Short = 0, ten: Double = 0.0d, eleven: Float = 0.0f, twelve: Long = 0L, thirteen: Timestamp = Timestamp(0L, 0), fourteen: Timestamp = Timestamp(0L, 0), fifteen: Timestamp = Timestamp(0L, 0), sixteen: Byte = 0, seventeen: Blob = Blob.empty, eighteen: Boolean = false) object DefaultTest extends ShapeTag.Companion[DefaultTest] { val id: ShapeId = ShapeId("smithy4s.example", "DefaultTest") 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/OperationInput.scala b/modules/bootstrapped/src/generated/smithy4s/example/OperationInput.scala deleted file mode 100644 index 6a6f0b704..000000000 --- a/modules/bootstrapped/src/generated/smithy4s/example/OperationInput.scala +++ /dev/null @@ -1,34 +0,0 @@ -package smithy4s.example - -import smithy4s.Hints -import smithy4s.Schema -import smithy4s.ShapeId -import smithy4s.ShapeTag -import smithy4s.schema.Schema.string -import smithy4s.schema.Schema.struct - -final case class OperationInput(optionalWithDefault: String = "optional-default", requiredLabel: String = "required-label-with-default", requiredWithDefault: String = "required-default", optionalHeaderWithDefault: String = "optional-header-with-default", requiredHeaderWithDefault: String = "required-header-with-default", optionalQueryWithDefault: String = "optional-query-with-default", requiredQueryWithDefault: String = "required-query-with-default", optional: Option[String] = None, optionalHeader: Option[String] = None, optionalQuery: Option[String] = None) - -object OperationInput extends ShapeTag.Companion[OperationInput] { - val id: ShapeId = ShapeId("smithy4s.example", "OperationInput") - - val hints: Hints = Hints( - smithy.api.Input(), - ).lazily - - // constructor using the original order from the spec - private def make(optional: Option[String], optionalWithDefault: String, requiredLabel: String, requiredWithDefault: String, optionalHeader: Option[String], optionalHeaderWithDefault: String, requiredHeaderWithDefault: String, optionalQuery: Option[String], optionalQueryWithDefault: String, requiredQueryWithDefault: String): OperationInput = OperationInput(optionalWithDefault, requiredLabel, requiredWithDefault, optionalHeaderWithDefault, requiredHeaderWithDefault, optionalQueryWithDefault, requiredQueryWithDefault, optional, optionalHeader, optionalQuery) - - implicit val schema: Schema[OperationInput] = struct( - string.optional[OperationInput]("optional", _.optional), - string.field[OperationInput]("optionalWithDefault", _.optionalWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("optional-default"))), - string.required[OperationInput]("requiredLabel", _.requiredLabel).addHints(smithy.api.Default(smithy4s.Document.fromString("required-label-with-default")), smithy.api.HttpLabel()), - string.required[OperationInput]("requiredWithDefault", _.requiredWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("required-default"))), - string.optional[OperationInput]("optionalHeader", _.optionalHeader).addHints(smithy.api.HttpHeader("optional-header")), - string.field[OperationInput]("optionalHeaderWithDefault", _.optionalHeaderWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("optional-header-with-default")), smithy.api.HttpHeader("optional-header-with-default")), - string.required[OperationInput]("requiredHeaderWithDefault", _.requiredHeaderWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("required-header-with-default")), smithy.api.HttpHeader("required-header-with-default")), - string.optional[OperationInput]("optionalQuery", _.optionalQuery).addHints(smithy.api.HttpQuery("optional-query")), - string.field[OperationInput]("optionalQueryWithDefault", _.optionalQueryWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("optional-query-with-default")), smithy.api.HttpQuery("optional-query-with-default")), - string.field[OperationInput]("requiredQueryWithDefault", _.requiredQueryWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("required-query-with-default")), smithy.api.HttpQuery("required-query-with-default")), - )(make).withId(id).addHints(hints) -} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/OperationOutput.scala b/modules/bootstrapped/src/generated/smithy4s/example/OperationOutput.scala deleted file mode 100644 index 19b2c128d..000000000 --- a/modules/bootstrapped/src/generated/smithy4s/example/OperationOutput.scala +++ /dev/null @@ -1,30 +0,0 @@ -package smithy4s.example - -import smithy4s.Hints -import smithy4s.Schema -import smithy4s.ShapeId -import smithy4s.ShapeTag -import smithy4s.schema.Schema.string -import smithy4s.schema.Schema.struct - -final case class OperationOutput(optionalWithDefault: String = "optional-default", requiredWithDefault: String = "required-default", optionalHeaderWithDefault: String = "optional-header-with-default", requiredHeaderWithDefault: String = "required-header-with-default", optional: Option[String] = None, optionalHeader: Option[String] = None) - -object OperationOutput extends ShapeTag.Companion[OperationOutput] { - val id: ShapeId = ShapeId("smithy4s.example", "OperationOutput") - - val hints: Hints = Hints( - smithy.api.Output(), - ).lazily - - // constructor using the original order from the spec - private def make(optional: Option[String], optionalWithDefault: String, requiredWithDefault: String, optionalHeader: Option[String], optionalHeaderWithDefault: String, requiredHeaderWithDefault: String): OperationOutput = OperationOutput(optionalWithDefault, requiredWithDefault, optionalHeaderWithDefault, requiredHeaderWithDefault, optional, optionalHeader) - - implicit val schema: Schema[OperationOutput] = struct( - string.optional[OperationOutput]("optional", _.optional), - string.field[OperationOutput]("optionalWithDefault", _.optionalWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("optional-default"))), - string.required[OperationOutput]("requiredWithDefault", _.requiredWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("required-default"))), - string.optional[OperationOutput]("optionalHeader", _.optionalHeader).addHints(smithy.api.HttpHeader("optional-header")), - string.field[OperationOutput]("optionalHeaderWithDefault", _.optionalHeaderWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("optional-header-with-default")), smithy.api.HttpHeader("optional-header-with-default")), - string.required[OperationOutput]("requiredHeaderWithDefault", _.requiredHeaderWithDefault).addHints(smithy.api.Default(smithy4s.Document.fromString("required-header-with-default")), smithy.api.HttpHeader("required-header-with-default")), - )(make).withId(id).addHints(hints) -} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/ServiceWithNullsAndDefaults.scala b/modules/bootstrapped/src/generated/smithy4s/example/ServiceWithNullsAndDefaults.scala index 61fc1b5e3..5fce0b700 100644 --- a/modules/bootstrapped/src/generated/smithy4s/example/ServiceWithNullsAndDefaults.scala +++ b/modules/bootstrapped/src/generated/smithy4s/example/ServiceWithNullsAndDefaults.scala @@ -9,11 +9,13 @@ import smithy4s.Transformation import smithy4s.kinds.PolyFunction5 import smithy4s.kinds.toPolyFunction5.const5 import smithy4s.schema.OperationSchema +import smithy4s.schema.Schema.unit trait ServiceWithNullsAndDefaultsGen[F[_, _, _, _, _]] { self => - def operation(input: OperationInput): F[OperationInput, Nothing, OperationOutput, Nothing, Nothing] + def defaultNullsOperation(input: DefaultNullsOperationInput): F[DefaultNullsOperationInput, Nothing, DefaultNullsOperationOutput, Nothing, Nothing] + def timestampOperation(input: TimestampOperationInput): F[TimestampOperationInput, Nothing, Unit, Nothing, Nothing] def transform: Transformation.PartiallyApplied[ServiceWithNullsAndDefaultsGen[F]] = Transformation.of[ServiceWithNullsAndDefaultsGen[F]](this) } @@ -35,7 +37,8 @@ object ServiceWithNullsAndDefaultsGen extends Service.Mixin[ServiceWithNullsAndD } val endpoints: Vector[smithy4s.Endpoint[ServiceWithNullsAndDefaultsOperation, _, _, _, _, _]] = Vector( - ServiceWithNullsAndDefaultsOperation.Operation, + ServiceWithNullsAndDefaultsOperation.DefaultNullsOperation, + ServiceWithNullsAndDefaultsOperation.TimestampOperation, ) def input[I, E, O, SI, SO](op: ServiceWithNullsAndDefaultsOperation[I, E, O, SI, SO]): I = op.input @@ -60,26 +63,40 @@ sealed trait ServiceWithNullsAndDefaultsOperation[Input, Err, Output, StreamedIn object ServiceWithNullsAndDefaultsOperation { object reified extends ServiceWithNullsAndDefaultsGen[ServiceWithNullsAndDefaultsOperation] { - def operation(input: OperationInput): Operation = Operation(input) + def defaultNullsOperation(input: DefaultNullsOperationInput): DefaultNullsOperation = DefaultNullsOperation(input) + def timestampOperation(input: TimestampOperationInput): TimestampOperation = TimestampOperation(input) } class Transformed[P[_, _, _, _, _], P1[_ ,_ ,_ ,_ ,_]](alg: ServiceWithNullsAndDefaultsGen[P], f: PolyFunction5[P, P1]) extends ServiceWithNullsAndDefaultsGen[P1] { - def operation(input: OperationInput): P1[OperationInput, Nothing, OperationOutput, Nothing, Nothing] = f[OperationInput, Nothing, OperationOutput, Nothing, Nothing](alg.operation(input)) + def defaultNullsOperation(input: DefaultNullsOperationInput): P1[DefaultNullsOperationInput, Nothing, DefaultNullsOperationOutput, Nothing, Nothing] = f[DefaultNullsOperationInput, Nothing, DefaultNullsOperationOutput, Nothing, Nothing](alg.defaultNullsOperation(input)) + def timestampOperation(input: TimestampOperationInput): P1[TimestampOperationInput, Nothing, Unit, Nothing, Nothing] = f[TimestampOperationInput, Nothing, Unit, Nothing, Nothing](alg.timestampOperation(input)) } def toPolyFunction[P[_, _, _, _, _]](impl: ServiceWithNullsAndDefaultsGen[P]): PolyFunction5[ServiceWithNullsAndDefaultsOperation, P] = new PolyFunction5[ServiceWithNullsAndDefaultsOperation, P] { def apply[I, E, O, SI, SO](op: ServiceWithNullsAndDefaultsOperation[I, E, O, SI, SO]): P[I, E, O, SI, SO] = op.run(impl) } - final case class Operation(input: OperationInput) extends ServiceWithNullsAndDefaultsOperation[OperationInput, Nothing, OperationOutput, Nothing, Nothing] { - def run[F[_, _, _, _, _]](impl: ServiceWithNullsAndDefaultsGen[F]): F[OperationInput, Nothing, OperationOutput, Nothing, Nothing] = impl.operation(input) + final case class DefaultNullsOperation(input: DefaultNullsOperationInput) extends ServiceWithNullsAndDefaultsOperation[DefaultNullsOperationInput, Nothing, DefaultNullsOperationOutput, Nothing, Nothing] { + def run[F[_, _, _, _, _]](impl: ServiceWithNullsAndDefaultsGen[F]): F[DefaultNullsOperationInput, Nothing, DefaultNullsOperationOutput, Nothing, Nothing] = impl.defaultNullsOperation(input) def ordinal: Int = 0 - def endpoint: smithy4s.Endpoint[ServiceWithNullsAndDefaultsOperation,OperationInput, Nothing, OperationOutput, Nothing, Nothing] = Operation + def endpoint: smithy4s.Endpoint[ServiceWithNullsAndDefaultsOperation,DefaultNullsOperationInput, Nothing, DefaultNullsOperationOutput, Nothing, Nothing] = DefaultNullsOperation } - object Operation extends smithy4s.Endpoint[ServiceWithNullsAndDefaultsOperation,OperationInput, Nothing, OperationOutput, Nothing, Nothing] { - val schema: OperationSchema[OperationInput, Nothing, OperationOutput, Nothing, Nothing] = Schema.operation(ShapeId("smithy4s.example", "Operation")) - .withInput(OperationInput.schema) - .withOutput(OperationOutput.schema) + object DefaultNullsOperation extends smithy4s.Endpoint[ServiceWithNullsAndDefaultsOperation,DefaultNullsOperationInput, Nothing, DefaultNullsOperationOutput, Nothing, Nothing] { + val schema: OperationSchema[DefaultNullsOperationInput, Nothing, DefaultNullsOperationOutput, Nothing, Nothing] = Schema.operation(ShapeId("smithy4s.example", "DefaultNullsOperation")) + .withInput(DefaultNullsOperationInput.schema) + .withOutput(DefaultNullsOperationOutput.schema) .withHints(smithy.api.Http(method = smithy.api.NonEmptyString("POST"), uri = smithy.api.NonEmptyString("/operation/{requiredLabel}"), code = 200)) - def wrap(input: OperationInput): Operation = Operation(input) + def wrap(input: DefaultNullsOperationInput): DefaultNullsOperation = DefaultNullsOperation(input) + } + final case class TimestampOperation(input: TimestampOperationInput) extends ServiceWithNullsAndDefaultsOperation[TimestampOperationInput, Nothing, Unit, Nothing, Nothing] { + def run[F[_, _, _, _, _]](impl: ServiceWithNullsAndDefaultsGen[F]): F[TimestampOperationInput, Nothing, Unit, Nothing, Nothing] = impl.timestampOperation(input) + def ordinal: Int = 1 + def endpoint: smithy4s.Endpoint[ServiceWithNullsAndDefaultsOperation,TimestampOperationInput, Nothing, Unit, Nothing, Nothing] = TimestampOperation + } + object TimestampOperation extends smithy4s.Endpoint[ServiceWithNullsAndDefaultsOperation,TimestampOperationInput, Nothing, Unit, Nothing, Nothing] { + val schema: OperationSchema[TimestampOperationInput, Nothing, Unit, Nothing, Nothing] = Schema.operation(ShapeId("smithy4s.example", "TimestampOperation")) + .withInput(TimestampOperationInput.schema) + .withOutput(unit) + .withHints(smithy.api.Http(method = smithy.api.NonEmptyString("POST"), uri = smithy.api.NonEmptyString("/timestamp-operation"), code = 200)) + def wrap(input: TimestampOperationInput): TimestampOperation = TimestampOperation(input) } } diff --git a/modules/bootstrapped/src/generated/smithy4s/example/StructureWithScalaImports.scala b/modules/bootstrapped/src/generated/smithy4s/example/StructureWithScalaImports.scala new file mode 100644 index 000000000..b331eafd9 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/StructureWithScalaImports.scala @@ -0,0 +1,24 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.refined.Age.provider._ +import smithy4s.schema.Schema.struct + + +final case class StructureWithScalaImports(teenage: Option[Age] = None) + +object StructureWithScalaImports extends ShapeTag.Companion[StructureWithScalaImports] { + val id: ShapeId = ShapeId("smithy4s.example", "StructureWithScalaImports") + + val hints: Hints = Hints.empty + + // constructor using the original order from the spec + private def make(teenage: Option[Age]): StructureWithScalaImports = StructureWithScalaImports(teenage) + + implicit val schema: Schema[StructureWithScalaImports] = struct( + Age.schema.validated(smithy.api.Range(min = Some(scala.math.BigDecimal(13.0)), max = Some(scala.math.BigDecimal(19.0)))).optional[StructureWithScalaImports]("teenage", _.teenage), + )(make).withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/TimestampOperationInput.scala b/modules/bootstrapped/src/generated/smithy4s/example/TimestampOperationInput.scala new file mode 100644 index 000000000..1207e2399 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/TimestampOperationInput.scala @@ -0,0 +1,28 @@ +package smithy4s.example + +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ShapeTag +import smithy4s.Timestamp +import smithy4s.schema.Schema.struct +import smithy4s.schema.Schema.timestamp + +final case class TimestampOperationInput(httpDate: Timestamp = Timestamp(1716459630L, 0), epochSeconds: Timestamp = Timestamp(1716459630L, 0), dateTime: Timestamp = Timestamp(1716459630L, 0)) + +object TimestampOperationInput extends ShapeTag.Companion[TimestampOperationInput] { + val id: ShapeId = ShapeId("smithy4s.example", "TimestampOperationInput") + + val hints: Hints = Hints( + smithy.api.Input(), + ).lazily + + // constructor using the original order from the spec + private def make(httpDate: Timestamp, epochSeconds: Timestamp, dateTime: Timestamp): TimestampOperationInput = TimestampOperationInput(httpDate, epochSeconds, dateTime) + + implicit val schema: Schema[TimestampOperationInput] = struct( + timestamp.required[TimestampOperationInput]("httpDate", _.httpDate).addHints(smithy.api.TimestampFormat.HTTP_DATE.widen, smithy.api.Default(smithy4s.Document.fromString("Thu, 23 May 2024 10:20:30 GMT"))), + timestamp.required[TimestampOperationInput]("epochSeconds", _.epochSeconds).addHints(smithy.api.TimestampFormat.EPOCH_SECONDS.widen, smithy.api.Default(smithy4s.Document.fromDouble(1.71645963E9d))), + timestamp.required[TimestampOperationInput]("dateTime", _.dateTime).addHints(smithy.api.TimestampFormat.DATE_TIME.widen, smithy.api.Default(smithy4s.Document.fromString("2024-05-23T10:20:30.000Z"))), + )(make).withId(id).addHints(hints) +} 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..1e5f93e27 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/ValidatedString.scala @@ -0,0 +1,18 @@ +package smithy4s.example + +import smithy4s.Bijection +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.ShapeId +import smithy4s.ValidatedNewtype +import smithy4s.Validator +import smithy4s.schema.Schema.string + +object ValidatedString extends ValidatedNewtype[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]+")) + val validator: Validator[String, ValidatedString] = Validator.of[String, ValidatedString](Bijection[String, ValidatedString](_.asInstanceOf[ValidatedString], value(_))).validating(smithy.api.Length(min = Some(1L), max = None)).alsoValidating(smithy.api.Pattern("[a-zA-Z0-9]+")) + implicit val schema: Schema[ValidatedString] = validator.toSchema(underlyingSchema) + @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/src/main/smithy4s/refined/Age.scala b/modules/bootstrapped/src/main/smithy4s/refined/Age.scala index d040848c5..be86313c9 100644 --- a/modules/bootstrapped/src/main/smithy4s/refined/Age.scala +++ b/modules/bootstrapped/src/main/smithy4s/refined/Age.scala @@ -20,5 +20,10 @@ object Age { Age.apply, (b: Age) => b.value ) + + implicit val rangeProvider + : RefinementProvider.Simple[smithy.api.Range, Age] = + RefinementProvider.rangeConstraint(x => x.value) + } } diff --git a/modules/bootstrapped/test/src-js/smithy4s/TimestampSpec.scala b/modules/bootstrapped/test/src-js/smithy4s/TimestampSpec.scala index e2b014b7c..0ee6c9cf9 100644 --- a/modules/bootstrapped/test/src-js/smithy4s/TimestampSpec.scala +++ b/modules/bootstrapped/test/src-js/smithy4s/TimestampSpec.scala @@ -184,6 +184,28 @@ class TimestampSpec() extends munit.FunSuite with munit.ScalaCheckSuite { } } + property("Converts from date time format without seconds") { + forAll { (i: Date) => + val year = i.getUTCFullYear().toInt + val month = i.getUTCMonth().toInt + 1 // in js month is 0-11 + val date = i.getUTCDate().toInt + val hours = i.getUTCHours().toInt + val minutes = i.getUTCMinutes().toInt + val str = f"$year%04d-$month%02d-$date%02dT$hours%02d:$minutes%02dZ" + val parsed = Timestamp.parse(str, TimestampFormat.DATE_TIME) + val expected = Timestamp( + year = year, + month = month, + day = date, + hour = hours, + minute = minutes, + second = 0, + nano = 0 + ) + expect.same(parsed, Some(expected)) + } + } + property("Convert to/from epoch milliseconds") { forAll { (d: Date) => val epochMilli = d.valueOf().toLong diff --git a/modules/bootstrapped/test/src-jvm/smithy4s/TimestampSpec.scala b/modules/bootstrapped/test/src-jvm/smithy4s/TimestampSpec.scala index 9b13ba77a..f2b8e40bc 100644 --- a/modules/bootstrapped/test/src-jvm/smithy4s/TimestampSpec.scala +++ b/modules/bootstrapped/test/src-jvm/smithy4s/TimestampSpec.scala @@ -169,6 +169,10 @@ class TimestampSpec() extends munit.FunSuite with munit.ScalaCheckSuite { .ofPattern("yyyyMMdd'T'HHmmssX", Locale.ENGLISH) .withZone(ZoneOffset.UTC) + private val dateTimeWithoutSecondsFormatter = DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mmX", Locale.ENGLISH) + .withZone(ZoneOffset.UTC) + property("Converts to concise date format") { forAll { (i: Instant) => val ts = Timestamp(i.getEpochSecond, i.getNano) @@ -187,6 +191,24 @@ class TimestampSpec() extends munit.FunSuite with munit.ScalaCheckSuite { } } + property("Converts from date time format without seconds") { + forAll { (i: Instant) => + val str = dateTimeWithoutSecondsFormatter.format(i) + val parsed = Timestamp.parse(str, TimestampFormat.DATE_TIME) + val zdt = i.atZone(ZoneOffset.UTC) + val expected = Timestamp( + year = zdt.getYear(), + month = zdt.getMonthValue(), + day = zdt.getDayOfMonth(), + hour = zdt.getHour(), + minute = zdt.getMinute(), + second = 0, + nano = 0 + ) + expect.same(parsed, Some(expected)) + } + } + property("Convert to/from epoch milliseconds") { forAll { (i: Instant) => val ts = Timestamp.fromInstant(i) diff --git a/modules/bootstrapped/test/src/smithy4s/BlobSpec.scala b/modules/bootstrapped/test/src/smithy4s/BlobSpec.scala index 0522d7636..a320e2639 100644 --- a/modules/bootstrapped/test/src/smithy4s/BlobSpec.scala +++ b/modules/bootstrapped/test/src/smithy4s/BlobSpec.scala @@ -21,15 +21,37 @@ import munit._ import java.nio.ByteBuffer import java.io.ByteArrayOutputStream import scala.util.Using -class BlobSpec() extends FunSuite { +import org.scalacheck.Prop._ +import scala.collection.immutable.Queue +class BlobSpec() extends ScalaCheckSuite { + + property("equals and hashcode contract") { + forAll { (value: String) => + val bytes = value.getBytes() + val array = Blob(value) + val byteBuffer = Blob(ByteBuffer.wrap(bytes)) + val queue = Blob.queue( + Queue(bytes.map(b => Blob(ByteBuffer.wrap(Array(b)))): _*), + bytes.size + ) + + assert(array == byteBuffer) + assert(array == queue) + assert(array.hashCode() == byteBuffer.hashCode()) + assert(array.hashCode() == queue.hashCode()) + } + } test("sameBytesAs works across data structures") { assert(Blob("foo").sameBytesAs(Blob("foo".getBytes))) assert(Blob("foo").sameBytesAs(Blob(ByteBuffer.wrap("foo".getBytes)))) } - test("equals depends on underlying data structure") { - assert(Blob("foo") != Blob(ByteBuffer.wrap("foo".getBytes))) + test("equals does not depend on underlying data structure") { + assert(Blob("foo") == Blob(ByteBuffer.wrap("foo".getBytes))) + assert( + Blob("foo") == Blob(ByteBuffer.wrap("f".getBytes)).concat(Blob("oo")) + ) assert(Blob("foo") == Blob("foo")) assert( Blob(ByteBuffer.wrap("foo".getBytes)) == Blob( @@ -56,6 +78,16 @@ class BlobSpec() extends FunSuite { assertNotEquals(blob1.hashCode, blob3.hashCode) } + test("QueueBlob.hashcode is consistent") { + def makeBlob(str: String) = + Blob(str.getBytes).concat(Blob(ByteBuffer.wrap(str.getBytes()))) + val blob1 = makeBlob("foo") + val blob2 = makeBlob("foo") + val blob3 = makeBlob("bar") + assertEquals(blob1.hashCode, blob2.hashCode) + assertNotEquals(blob1.hashCode, blob3.hashCode) + } + test("Concat works as expected") { val blob = Blob("foo") ++ Blob("bar") assertEquals(blob.size, 6) diff --git a/modules/bootstrapped/test/src/smithy4s/DocumentSpec.scala b/modules/bootstrapped/test/src/smithy4s/DocumentSpec.scala index 0265370df..6b3b8e941 100644 --- a/modules/bootstrapped/test/src/smithy4s/DocumentSpec.scala +++ b/modules/bootstrapped/test/src/smithy4s/DocumentSpec.scala @@ -21,8 +21,9 @@ import smithy.api.Default import smithy4s.example.IntList import alloy.Discriminated import munit._ -import smithy4s.example.OperationOutput +import smithy4s.example.DefaultNullsOperationOutput import alloy.Untagged +import smithy4s.example.TimestampOperationInput class DocumentSpec() extends FunSuite { @@ -423,8 +424,8 @@ class DocumentSpec() extends FunSuite { test("document encoder - all default") { val result = Document.Encoder - .fromSchema(OperationOutput.schema) - .encode(OperationOutput()) + .fromSchema(DefaultNullsOperationOutput.schema) + .encode(DefaultNullsOperationOutput()) expect.same( Document.obj( @@ -444,9 +445,9 @@ class DocumentSpec() extends FunSuite { val result = Document.Encoder .withExplicitDefaultsEncoding(true) .fromSchema( - OperationOutput.schema + DefaultNullsOperationOutput.schema ) - .encode(OperationOutput()) + .encode(DefaultNullsOperationOutput()) expect.same( Document.obj( "optional" -> Document.nullDoc, @@ -470,9 +471,9 @@ class DocumentSpec() extends FunSuite { val result = Document.Encoder .withExplicitDefaultsEncoding(false) .fromSchema( - OperationOutput.schema + DefaultNullsOperationOutput.schema ) - .encode(OperationOutput()) + .encode(DefaultNullsOperationOutput()) expect.same( Document.obj( "requiredWithDefault" -> Document.fromString("required-default"), @@ -489,9 +490,9 @@ class DocumentSpec() extends FunSuite { ) { val result = Document.Encoder .withExplicitDefaultsEncoding(true) - .fromSchema(OperationOutput.schema) + .fromSchema(DefaultNullsOperationOutput.schema) .encode( - OperationOutput( + DefaultNullsOperationOutput( optional = Some("optional-override"), optionalWithDefault = "optional-default-override", requiredWithDefault = "required-default-override", @@ -529,9 +530,9 @@ class DocumentSpec() extends FunSuite { ) { val result = Document.Encoder .withExplicitDefaultsEncoding(false) - .fromSchema(OperationOutput.schema) + .fromSchema(DefaultNullsOperationOutput.schema) .encode( - OperationOutput( + DefaultNullsOperationOutput( optional = Some("optional-override"), optionalWithDefault = "optional-default-override", requiredWithDefault = "required-default-override", @@ -565,6 +566,46 @@ class DocumentSpec() extends FunSuite { } + test("Document encoder - timestamp defaults") { + val result = Document.Encoder + .withExplicitDefaultsEncoding(false) + .fromSchema(TimestampOperationInput.schema) + .encode(TimestampOperationInput()) + expect.same( + Document.obj( + "httpDate" -> Document.fromString("Thu, 23 May 2024 10:20:30 GMT"), + "dateTime" -> Document.fromString("2024-05-23T10:20:30Z"), + "epochSeconds" -> Document.fromLong(1716459630L) + ), + result + ) + } + + test("Document decoder - timestamp defaults") { + val doc = Document.obj() + val result = Document.Decoder + .fromSchema(TimestampOperationInput.schema) + .decode(doc) + val defaultTimestamp = Timestamp( + year = 2024, + month = 5, + day = 23, + hour = 10, + minute = 20, + second = 30 + ) + expect.same( + Right( + TimestampOperationInput( + dateTime = defaultTimestamp, + httpDate = defaultTimestamp, + epochSeconds = defaultTimestamp + ) + ), + result + ) + } + test("Document syntax allows to build documents more concisely") { import Document.syntax._ diff --git a/modules/bootstrapped/test/src/smithy4s/ValidatedNewtypesSpec.scala b/modules/bootstrapped/test/src/smithy4s/ValidatedNewtypesSpec.scala new file mode 100644 index 000000000..bb54fa269 --- /dev/null +++ b/modules/bootstrapped/test/src/smithy4s/ValidatedNewtypesSpec.scala @@ -0,0 +1,109 @@ +/* + * 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.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 ValidatedNewtype[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)) + + val validator: Validator[String, DeviceId] = Validator + .of[String, DeviceId]( + Bijection[String, DeviceId](_.asInstanceOf[DeviceId], value(_)) + ) + .validating(smithy.api.Length(min = Some(1L), max = None)) + + implicit val schema: Schema[DeviceId] = + validator.toSchema(underlyingSchema) + + @inline def apply(a: String): Either[String, DeviceId] = + validator.validate(a) + + } + + type AccountId = AccountId.Type + + object AccountId extends ValidatedNewtype[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]+")) + + val validator: Validator[String, AccountId] = Validator + .of[String, AccountId]( + Bijection[String, AccountId](_.asInstanceOf[AccountId], value(_)) + ) + .validating(smithy.api.Length(min = Some(1L), max = None)) + .alsoValidating(smithy.api.Pattern("[a-zA-Z0-9]+")) + + implicit val schema: Schema[AccountId] = + validator.toSchema(underlyingSchema) + + @inline def apply(a: String): Either[String, AccountId] = + validator.validate(a) + + } + +} diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/aws-newtype-flatten/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/aws-newtype-flatten/test index c813c07d5..d853c9a95 100644 --- a/modules/codegen-plugin/src/sbt-test/codegen-plugin/aws-newtype-flatten/test +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/aws-newtype-flatten/test @@ -2,7 +2,7 @@ > run # This new type was not flattened because it has range constraints -$ exists smithy_output/smithy4s/com/amazonaws/dynamodb/ListTablesInputLimit.scala +$ exists smithy_output/com/amazonaws/dynamodb/ListTablesInputLimit.scala # Flattened and removed by the projection transformer --$ exists smithy_output/smithy4s/com/amazonaws/dynamodb/Long.scala +-$ exists smithy_output/com/amazonaws/dynamodb/Long.scala diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/aws-specs/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/aws-specs/test index e58e612c4..9c4c518d2 100644 --- a/modules/codegen-plugin/src/sbt-test/codegen-plugin/aws-specs/test +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/aws-specs/test @@ -1,3 +1,3 @@ # check if smithy4sCodegen works > smithy4sCodegen -$ exists target/scala-2.13/src_managed/main/scala/smithy4s/com/amazonaws/dynamodb/AttributeValue.scala +$ exists target/scala-2.13/src_managed/main/smithy4s/com/amazonaws/dynamodb/AttributeValue.scala diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/custom-settings/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/custom-settings/test index 0e740f87c..4372b5970 100644 --- a/modules/codegen-plugin/src/sbt-test/codegen-plugin/custom-settings/test +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/custom-settings/test @@ -1,10 +1,10 @@ # check if smithy4sCodegen works > show p1/smithy4sAllExternalDependencies > p1/compile -$ exists p1/smithy_output/smithy4s/aws/iam/ActionPermissionDescription.scala -$ exists p1/smithy_output/smithy4s/smithy4s/example/ObjectService.scala +$ exists p1/smithy_output/aws/iam/ActionPermissionDescription.scala +$ exists p1/smithy_output/smithy4s/example/ObjectService.scala > p2/compile -$ exists p2/smithy_output/smithy4s/aws/iam/ActionPermissionDescription.scala -$ exists p2/smithy_output/smithy4s/smithy4s/example/ObjectService.scala --$ exists p2/smithy_output/smithy4s/smithy4s/toexclude/StructureToExclude.scala +$ exists p2/smithy_output/aws/iam/ActionPermissionDescription.scala +$ exists p2/smithy_output/smithy4s/example/ObjectService.scala +-$ exists p2/smithy_output/smithy4s/toexclude/StructureToExclude.scala diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/defaults/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/defaults/test index 03ff1b43e..049493127 100644 --- a/modules/codegen-plugin/src/sbt-test/codegen-plugin/defaults/test +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/defaults/test @@ -1,6 +1,7 @@ # check if smithy4sCodegen works +> set logLevel := Level.Debug > compile -$ exists target/scala-2.13/src_managed/main/scala/smithy4s/smithy4s/example/ObjectService.scala +$ exists target/scala-2.13/src_managed/main/smithy4s/smithy4s/example/ObjectService.scala $ exists target/scala-2.13/resource_managed/main/smithy4s.example.ObjectService.json # check if code can run, this can reveal runtime issues @@ -8,7 +9,7 @@ $ exists target/scala-2.13/resource_managed/main/smithy4s.example.ObjectService. > run $ copy-file example-added.smithy src/main/smithy/example-added.smithy > compile -$ exists target/scala-2.13/src_managed/main/scala/smithy4s/smithy4s/example/Added.scala +$ exists target/scala-2.13/src_managed/main/smithy4s/smithy4s/example/Added.scala # ensuring that removing existing files removes their outputs $ delete src/main/smithy/example.smithy diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/dependencies-only/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/dependencies-only/test index 1b03c2fca..7e6822104 100644 --- a/modules/codegen-plugin/src/sbt-test/codegen-plugin/dependencies-only/test +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/dependencies-only/test @@ -1,3 +1,3 @@ # check if smithy4sCodegen works > p1/compile -$ exists p1/smithy_output/smithy4s/aws/iam/ActionPermissionDescription.scala +$ exists p1/smithy_output/aws/iam/ActionPermissionDescription.scala diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/extra-configs/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/extra-configs/test index 4815ec114..535543089 100644 --- a/modules/codegen-plugin/src/sbt-test/codegen-plugin/extra-configs/test +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/extra-configs/test @@ -1,7 +1,7 @@ # check if main sources are generated > compile -$ exists target/scala-2.13/src_managed/main/scala/smithy4s/example/ExampleStruct.scala +$ exists target/scala-2.13/src_managed/main/smithy4s/example/ExampleStruct.scala # check if test sources are generated > Test/compile -$ exists target/scala-2.13/src_managed/test/scala/smithy4s/testexample/TestStruct.scala +$ exists target/scala-2.13/src_managed/test/smithy4s/testexample/TestStruct.scala diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/multimodule-aws/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/multimodule-aws/test index 883b4d0ac..9638cef6d 100644 --- a/modules/codegen-plugin/src/sbt-test/codegen-plugin/multimodule-aws/test +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/multimodule-aws/test @@ -1,5 +1,5 @@ # check if smithy4sCodegen works with libraries that were built with Smithy4s > show bar/smithy4sAllExternalDependencies > compile -$ exists foo/target/scala-2.13/src_managed/main/scala/smithy4s/foo/Lambda.scala -$ absent bar/target/scala-2.13/src_managed/main/scala/smithy4s/foo/Lambda.scala +$ exists foo/target/scala-2.13/src_managed/main/smithy4s/foo/Lambda.scala +$ absent bar/target/scala-2.13/src_managed/main/smithy4s/foo/Lambda.scala diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/multimodule-staged/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/multimodule-staged/test index 183e549f6..436ce1e19 100644 --- a/modules/codegen-plugin/src/sbt-test/codegen-plugin/multimodule-staged/test +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/multimodule-staged/test @@ -2,8 +2,8 @@ > foo/publishLocal > upstream/publishLocal > bar/compile -$ exists bar/target/scala-2.13/src_managed/main/scala/smithy4s/bar/Bar.scala -$ absent bar/target/scala-2.13/src_managed/main/scala/smithy4s/foo/Foo.scala +$ exists bar/target/scala-2.13/src_managed/main/smithy4s/bar/Bar.scala +$ absent bar/target/scala-2.13/src_managed/main/smithy4s/foo/Foo.scala # check if code can run, this can reveal runtime issues# such as initialization errors > bar/run diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/multimodule/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/multimodule/test index e48a23e0e..f0f8da933 100644 --- a/modules/codegen-plugin/src/sbt-test/codegen-plugin/multimodule/test +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/multimodule/test @@ -1,10 +1,10 @@ # check if smithy4sCodegen works in multimodule contexts > compile -$ exists bar/target/scala-2.13/src_managed/main/scala/smithy4s/bar/Bar.scala -$ absent bar/target/scala-2.13/src_managed/main/scala/smithy4s/foo/Foo.scala -$ absent bar/target/scala-2.13/src_managed/main/scala/smithy4s/foodir/FooDir.scala -$ exists foo/target/scala-2.13/src_managed/main/scala/smithy4s/foo/Foo.scala -$ exists foo/target/scala-2.13/src_managed/main/scala/smithy4s/foodir/FooDir.scala +$ exists bar/target/scala-2.13/src_managed/main/smithy4s/bar/Bar.scala +$ absent bar/target/scala-2.13/src_managed/main/smithy4s/foo/Foo.scala +$ absent bar/target/scala-2.13/src_managed/main/smithy4s/foodir/FooDir.scala +$ exists foo/target/scala-2.13/src_managed/main/smithy4s/foo/Foo.scala +$ exists foo/target/scala-2.13/src_managed/main/smithy4s/foodir/FooDir.scala # check if code can run, this can reveal runtime issues# such as initialization errors > bar/run 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/sbt-test/codegen-plugin/scala3/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/scala3/test index c6fef7253..7754244ad 100644 --- a/modules/codegen-plugin/src/sbt-test/codegen-plugin/scala3/test +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/scala3/test @@ -1,10 +1,10 @@ # check if smithy4sCodegen works > compile -$ exists target/scala-3.3.0/src_managed/main/scala/smithy4s/smithy4s/errors/BadRequest.scala -$ exists target/scala-3.3.0/src_managed/main/scala/smithy4s/smithy4s/errors/InternalServerError.scala -$ exists target/scala-3.3.0/src_managed/main/scala/smithy4s/smithy4s/errors/ErrorService.scala -$ exists target/scala-3.3.0/src_managed/main/scala/smithy4s/smithy4s/errors/package.scala +$ exists target/scala-3.3.0/src_managed/main/smithy4s/smithy4s/errors/BadRequest.scala +$ exists target/scala-3.3.0/src_managed/main/smithy4s/smithy4s/errors/InternalServerError.scala +$ exists target/scala-3.3.0/src_managed/main/smithy4s/smithy4s/errors/ErrorService.scala +$ exists target/scala-3.3.0/src_managed/main/smithy4s/smithy4s/errors/package.scala # check if code can run > run diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/test index 442fb337b..291aa5750 100644 --- a/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/test +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-build/test @@ -1,5 +1,5 @@ # check if smithy4sCodegen works > compile -$ exists target/scala-2.13/src_managed/main/scala/smithy4s/smithy4s/example/ObjectService.scala +$ exists target/scala-2.13/src_managed/main/smithy4s/smithy4s/example/ObjectService.scala $ exists target/scala-2.13/resource_managed/main/smithy4s.example.ObjectService.json > checkOpenApi diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-rules/test b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-rules/test index b2a89b735..cdec95a97 100644 --- a/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-rules/test +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/smithy-rules/test @@ -1,3 +1,3 @@ # check if smithy4sCodegen works > compile -$ exists target/scala-2.13/src_managed/main/scala/smithy4s/smithy/rules +$ exists target/scala-2.13/src_managed/main/smithy4s/smithy/rules diff --git a/modules/codegen-plugin/src/sbt-test/codegen-plugin/update-lsp-config/expected.json b/modules/codegen-plugin/src/sbt-test/codegen-plugin/update-lsp-config/expected.json index 4546f793b..2b2e64f24 100644 --- a/modules/codegen-plugin/src/sbt-test/codegen-plugin/update-lsp-config/expected.json +++ b/modules/codegen-plugin/src/sbt-test/codegen-plugin/update-lsp-config/expected.json @@ -6,7 +6,7 @@ ], "maven" : { "dependencies" : [ - "com.disneystreaming.alloy:alloy-core:0.3.8" + "com.disneystreaming.alloy:alloy-core:0.3.9" ], "repositories" : [ { diff --git a/modules/codegen-plugin/src/smithy4s/codegen/CachedTask.scala b/modules/codegen-plugin/src/smithy4s/codegen/CachedTask.scala new file mode 100644 index 000000000..c634d6ccf --- /dev/null +++ b/modules/codegen-plugin/src/smithy4s/codegen/CachedTask.scala @@ -0,0 +1,83 @@ +/* + * 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 + +import munit.diff.Diff +import sbt._ +import sbt.util.CacheImplicits._ +import sbt.util.CacheStore +import sbt.util.Logger +import sjsonnew._ +import sjsonnew.support.murmurhash.Hasher +import sjsonnew.support.scalajson.unsafe.Converter +import sjsonnew.support.scalajson.unsafe.{PrettyPrinter => prettify} + +import scala.util.Try + +private[codegen] object CachedTask { + + // This implementation is inspired by sbt.util.Tracked.inputChanged + // The main difference is that when the values don't match, the difference is calculated + // using munit-diff and recorded to debug log + def inputChanged[I: JsonFormat, O](store: CacheStore, logger: Logger)( + f: (Boolean, I) => O + ): I => O = { in => + def debug(str: String): Unit = logger.debug(s"[smithy4s] $str") + + val previousValue = Try(store.read[ValueAndHash[I]]()).toOption + val newValueHash = hash(in) + store.write[ValueAndHash[I]]((in, newValueHash)) + + previousValue match { + case None => + debug("Could not read previous inputs value from cache.") + f(true, in) + + case Some((oldValue, previousHash)) => + (toJson(oldValue), toJson(in)) match { + case (Some(oldArgs), Some(newArgs)) if !oldArgs.equals(newArgs) => + val diff = new Diff(prettify(oldArgs), prettify(newArgs)) + val report = diff.createReport( + "Arguments changed between smithy4s codegen invocations, diff:", + printObtainedAsStripMargin = false + ) + debug(report) + f(true, in) + + case (_, _) if (previousHash != newValueHash) => + debug( + "Codegen arguments didn't change, but their hashes didn't match. " + + "This means file change on paths provided as codegen arguments." + ) + f(true, in) + + case _ => + debug("Input didn't change between codegen invocations") + f(false, in) + } + + } + } + + private type ValueAndHash[I] = (I, Int) + + private def toJson[I: JsonFormat](args: I) = + Converter.toJson(args).toOption + + private def hash[I: JsonFormat](in: I) = + Hasher.hash(in).toOption.getOrElse(-1) +} 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..d0d15baae 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 { @@ -188,7 +191,7 @@ object Smithy4sCodegenPlugin extends AutoPlugin { (config / sourceManaged).value / "smithy" ), config / unmanagedSourceDirectories ++= (config / smithy4sInputDirs).value, - config / smithy4sOutputDir := (config / sourceManaged).value / "scala", + config / smithy4sOutputDir := (config / sourceManaged).value / "smithy4s", config / smithy4sResourceDir := (config / resourceManaged).value, config / smithy4sCodegen := cachedSmithyCodegen(config).value, config / smithy4sSmithyLibrary := true, @@ -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" @@ -408,7 +412,7 @@ object Smithy4sCodegenPlugin extends AutoPlugin { (inputDirs ++ generatedFiles) .filter(_.exists()) .toList - val outputPath = (conf / smithy4sOutputDir).value / "smithy4s" + val outputPath = (conf / smithy4sOutputDir).value val resourceOutputPath = (conf / smithy4sResourceDir).value val allowedNamespaces = (conf / smithy4sAllowedNamespaces).?.value.map(_.toSet) @@ -451,21 +455,24 @@ object Smithy4sCodegenPlugin extends AutoPlugin { ) val cached = - Tracked.inputChanged[CodegenArgs, Seq[File]]( - s.cacheStoreFactory.make("input") + CachedTask.inputChanged[CodegenArgs, Seq[File]]( + s.cacheStoreFactory.make("input"), + s.log ) { Function.untupled { Tracked.lastOutput[(Boolean, CodegenArgs), Seq[File]]( s.cacheStoreFactory.make("output") ) { case ((inputChanged, args), outputs) => if (inputChanged || outputs.isEmpty) { - s.log.debug("Regenerating managed sources") + s.log.debug(s"[smithy4s] Input changed: $inputChanged") + s.log.debug(s"[smithy4s] Outputs empty: ${outputs.isEmpty}") + s.log.debug("[smithy4s] Sources will be regenerated") val resPaths = smithy4s.codegen.Codegen .generateToDisk(args) .toList resPaths.map(path => new File(path.toString)) } else { - s.log.debug("Using cached version of outputs") + s.log.debug("[smithy4s] Using cached version of outputs") outputs.getOrElse(Seq.empty) } } @@ -474,4 +481,5 @@ object Smithy4sCodegenPlugin extends AutoPlugin { cached(codegenArgs) } + } 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..5dd1bdb81 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,14 +314,17 @@ 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") + val Bijection_ = NameRef("smithy4s", "Bijection") val StreamingSchema_ = NameRef("smithy4s.schema", "StreamingSchema") val Enumeration_ = NameRef("smithy4s", "Enumeration") val EnumValue_ = NameRef("smithy4s.schema", "EnumValue") val EnumTag_ = NameRef("smithy4s.schema", "EnumTag") val Newtype_ = NameRef("smithy4s", "Newtype") + val ValidatedNewtype_ = NameRef("smithy4s", "ValidatedNewtype") 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..fbdc20cdc 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,8 @@ private[internals] object Hint { extends Hint case object GenerateServiceProduct extends Hint case object GenerateOptics extends Hint + case class ScalaImports(imports: List[String]) extends Hint + case object ValidateNewtype extends Hint implicit val eq: Eq[Hint] = Eq.fromUniversalEquals } @@ -447,6 +459,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 +491,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..88c5884ac 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) } @@ -207,6 +209,16 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => } } + private def renderScalaImports(hints: List[Hint]): Lines = { + lines( + hints.flatMap { + case Hint.ScalaImports(imports) => + imports.map(LineSegment.Import(_).toLine) + case _ => Nil + } + ) + } + /** * Returns the given list of Smithy documentation strings formatted as Scaladoc comments. * @@ -259,8 +271,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) => @@ -322,6 +337,7 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => lines( documentationAnnotation(hints), deprecationAnnotation(hints), + renderScalaImports(hints), block(line"trait $genName[F[_, _, _, _, _]]")( line"self =>", newline, @@ -812,6 +828,7 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => lines( documentationAnnotation(product.hints), deprecationAnnotation(product.hints), + renderScalaImports(product.hints), base ) } @@ -1087,6 +1104,7 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => lines( documentationAnnotation(hints), deprecationAnnotation(hints), + renderScalaImports(hints), block( line"sealed trait ${NameDef(name.name)} extends ${mixinExtendsStatement}scala.Product with scala.Serializable" ).withSameLineValue(line" self =>")( @@ -1252,6 +1270,7 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => lines( documentationAnnotation(hints), deprecationAnnotation(hints), + renderScalaImports(hints), block( line"sealed abstract class ${name.name}(_name: $string_, _stringValue: $string_, _intValue: $int_, _hints: $Hints_) extends $Enumeration_.Value" )( @@ -1326,6 +1345,7 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => lines( documentationAnnotation(hints), deprecationAnnotation(hints), + renderScalaImports(hints), obj(name, line"$Newtype_[$tpe]")( renderId(shapeId), renderHintsVal(hints), @@ -1338,6 +1358,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"$ValidatedNewtype_[$tpe]")( + renderId(shapeId), + renderHintsVal(hints), + line"val underlyingSchema: $Schema_[$tpe] = ${tpe.schemaRef}$trailingCalls", + lines( + line"val validator: $Validator_[$tpe, $name] = $Validator_.of[$tpe, $name]($Bijection_[$tpe, $name](_.asInstanceOf[$name], value(_)))$validator" + ), + lines( + line"implicit val schema: $Schema_[$name] = ${definition}validator.toSchema(underlyingSchema)$closing" + ), + 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 +1459,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 +1600,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 @@ -1574,7 +1648,7 @@ private[internals] class Renderer(compilationUnit: CompilationUnit) { self => else line"$blob(Array[Byte](${ba.mkString(", ")}))" case Primitive.Timestamp => - ts => line"${NameRef("smithy4s", "Timestamp")}(${ts.toEpochMilli}, 0)" + ts => line"${NameRef("smithy4s", "Timestamp")}(${ts.getEpochSecond()}L, ${ts.getNano()})" case Primitive.Document => { (node: Node) => node.accept(new NodeVisitor[Line] { def arrayNode(x: ArrayNode): Line = { diff --git a/modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala b/modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala index a14feb50d..c733dbec3 100644 --- a/modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala +++ b/modules/codegen/src/smithy4s/codegen/internals/SmithyToIR.scala @@ -24,6 +24,8 @@ import smithy4s.meta.IndexedSeqTrait import smithy4s.meta.NoStackTraceTrait import smithy4s.meta.PackedInputsTrait import smithy4s.meta.RefinementTrait +import smithy4s.meta.ScalaImportsTrait +import smithy4s.meta.ValidateNewtypeTrait import smithy4s.meta.VectorTrait import smithy4s.meta.AdtTrait import smithy4s.meta.GenerateServiceProductTrait @@ -44,10 +46,19 @@ import scala.annotation.nowarn import scala.jdk.CollectionConverters._ import Type.Alias +import java.time.Instant +import java.time.ZonedDateTime +import java.util.Locale +import java.time.temporal.ChronoField +import java.time.format.DateTimeFormatterBuilder +import scala.util.Try 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 +76,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 +170,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 +628,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 +964,10 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) { Hint.GenerateServiceProduct case _: GenerateOpticsTrait => Hint.GenerateOptics + case s: ScalaImportsTrait => + Hint.ScalaImports(s.getImports().asScala.toList) + 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 +1368,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) = @@ -1433,60 +1473,84 @@ private[codegen] class SmithyToIR(model: Model, namespace: String) { private def unfoldNodeAndTypeP( node: Node, p: Primitive - ): TypedNode[NodeAndType] = (node, p) match { - // String - case (N.StringNode(str), Primitive.String) => - TypedNode.PrimitiveTN(Primitive.String, str) - // Numeric - case (N.NumberNode(num), Primitive.Int) => - TypedNode.PrimitiveTN(Primitive.Int, num.intValue()) - case (N.NumberNode(num), Primitive.Long) => - TypedNode.PrimitiveTN(Primitive.Long, num.longValue()) - case (N.NumberNode(num), Primitive.Double) => - TypedNode.PrimitiveTN(Primitive.Double, num.doubleValue()) - case (N.NumberNode(num), Primitive.Float) => - TypedNode.PrimitiveTN(Primitive.Float, num.floatValue()) - case (N.NumberNode(num), Primitive.Short) => - TypedNode.PrimitiveTN(Primitive.Short, num.shortValue()) - case (N.NumberNode(num), Primitive.BigDecimal) => - TypedNode.PrimitiveTN(Primitive.BigDecimal, BigDecimal(num.doubleValue())) - case (N.NumberNode(num), Primitive.BigInteger) => - TypedNode.PrimitiveTN(Primitive.BigInteger, BigInt(num.intValue())) - // Boolean - case (N.BooleanNode(bool), Primitive.Bool) => - TypedNode.PrimitiveTN(Primitive.Bool, bool) - case (node, Primitive.Document) => - TypedNode.PrimitiveTN(Primitive.Document, node) - case (node, Primitive.String) if node == Node.nullNode => - TypedNode.PrimitiveTN(Primitive.String, "") - case (node, Primitive.Int) if node == Node.nullNode => - TypedNode.PrimitiveTN(Primitive.Int, 0) - case (node, Primitive.Long) if node == Node.nullNode => - TypedNode.PrimitiveTN(Primitive.Long, 0L) - case (node, Primitive.Double) if node == Node.nullNode => - TypedNode.PrimitiveTN(Primitive.Double, 0.0) - case (node, Primitive.Float) if node == Node.nullNode => - TypedNode.PrimitiveTN(Primitive.Float, 0.0f) - case (node, Primitive.Short) if node == Node.nullNode => - TypedNode.PrimitiveTN(Primitive.Short, 0: Short) - case (node, Primitive.Byte) if node == Node.nullNode => - TypedNode.PrimitiveTN(Primitive.Byte, 0.toByte) - case (node, Primitive.Blob) if node == Node.nullNode => - TypedNode.PrimitiveTN(Primitive.Blob, Array.empty[Byte]) - case (node, Primitive.Bool) if node == Node.nullNode => - TypedNode.PrimitiveTN(Primitive.Bool, false) - case (node, Primitive.Timestamp) if node == Node.nullNode => - TypedNode.PrimitiveTN( - Primitive.Timestamp, - java.time.Instant.ofEpochSecond(0) - ) - case (_, Primitive.Unit) => - TypedNode.PrimitiveTN( - Primitive.Unit, - () + ): TypedNode[NodeAndType] = { + def notSupported(nodeAndPrimitive: (Node, Primitive)) = + throw new NotImplementedError( + s"Unsupported case: $nodeAndPrimitive" ) - case other => - throw new NotImplementedError(s"Unsupported case: $other") + (node, p) match { + // String + case (N.StringNode(str), Primitive.String) => + TypedNode.PrimitiveTN(Primitive.String, str) + // Numeric + case (N.NumberNode(num), Primitive.Int) => + TypedNode.PrimitiveTN(Primitive.Int, num.intValue()) + case (N.NumberNode(num), Primitive.Long) => + TypedNode.PrimitiveTN(Primitive.Long, num.longValue()) + case (N.NumberNode(num), Primitive.Double) => + TypedNode.PrimitiveTN(Primitive.Double, num.doubleValue()) + case (N.NumberNode(num), Primitive.Float) => + TypedNode.PrimitiveTN(Primitive.Float, num.floatValue()) + case (N.NumberNode(num), Primitive.Short) => + TypedNode.PrimitiveTN(Primitive.Short, num.shortValue()) + case (N.NumberNode(num), Primitive.BigDecimal) => + TypedNode.PrimitiveTN( + Primitive.BigDecimal, + BigDecimal(num.doubleValue()) + ) + case (N.NumberNode(num), Primitive.BigInteger) => + TypedNode.PrimitiveTN(Primitive.BigInteger, BigInt(num.intValue())) + // Boolean + case (N.BooleanNode(bool), Primitive.Bool) => + TypedNode.PrimitiveTN(Primitive.Bool, bool) + case (node, Primitive.Document) => + TypedNode.PrimitiveTN(Primitive.Document, node) + case (node, Primitive.String) if node == Node.nullNode => + TypedNode.PrimitiveTN(Primitive.String, "") + case (node, Primitive.Int) if node == Node.nullNode => + TypedNode.PrimitiveTN(Primitive.Int, 0) + case (node, Primitive.Long) if node == Node.nullNode => + TypedNode.PrimitiveTN(Primitive.Long, 0L) + case (node, Primitive.Double) if node == Node.nullNode => + TypedNode.PrimitiveTN(Primitive.Double, 0.0) + case (node, Primitive.Float) if node == Node.nullNode => + TypedNode.PrimitiveTN(Primitive.Float, 0.0f) + case (node, Primitive.Short) if node == Node.nullNode => + TypedNode.PrimitiveTN(Primitive.Short, 0: Short) + case (node, Primitive.Byte) if node == Node.nullNode => + TypedNode.PrimitiveTN(Primitive.Byte, 0.toByte) + case (node, Primitive.Blob) if node == Node.nullNode => + TypedNode.PrimitiveTN(Primitive.Blob, Array.empty[Byte]) + case (node, Primitive.Bool) if node == Node.nullNode => + TypedNode.PrimitiveTN(Primitive.Bool, false) + case timestamp @ (node, Primitive.Timestamp) => + val value = node match { + case N.StringNode(str) => + Try(Instant.parse(str)) + .orElse( + Try(ZonedDateTime.parse(str, httpDateFormatter).toInstant()) + ) + .toOption + .getOrElse(notSupported(timestamp)) + case N.NumberNode(num) => + Instant.ofEpochSecond(num.longValue) + case _ if node == Node.nullNode => java.time.Instant.ofEpochSecond(0) + case _ => notSupported(timestamp) + } + TypedNode.PrimitiveTN(Primitive.Timestamp, value) + case (_, Primitive.Unit) => + TypedNode.PrimitiveTN( + Primitive.Unit, + () + ) + case other => + notSupported(other) + } } + private val httpDateFormatter = new DateTimeFormatterBuilder() + .appendPattern("EEE, dd MMM yyyy HH:mm:ss z") + .parseDefaulting(ChronoField.OFFSET_SECONDS, 0) + .toFormatter(Locale.ENGLISH); + } 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..551e02402 100644 --- a/modules/codegen/test/src/smithy4s/codegen/internals/RendererSpec.scala +++ b/modules/codegen/test/src/smithy4s/codegen/internals/RendererSpec.scala @@ -491,6 +491,140 @@ final class RendererSpec extends munit.ScalaCheckSuite { ) } + test( + "generated code of a shape that is applied @scalaImports should contain imports" + ) { + + val structure = + """ + |$version: "2.0" + | + |namespace smithy4s + | + |use smithy4s.meta#scalaImports + | + |apply smithy4s#MyStruct @scalaImports( + | ["smithy4s.providers._"] + |) + | + |structure MyStruct { + | str: String + |} + |""".stripMargin + + val service = + """ + |$version: "2.0" + | + |namespace smithy4s + | + |use smithy4s.meta#scalaImports + | + |apply smithy4s#MyService @scalaImports( + | ["smithy4s.providers._"] + |) + | + | + |service MyService { + | version: "1.0.0" + |} + |""".stripMargin + + val union = + """ + |$version: "2.0" + | + |namespace smithy4s + | + |use smithy4s.meta#scalaImports + | + |apply smithy4s#MyUnion @scalaImports( + | ["smithy4s.providers._"] + |) + | + |union MyUnion { + | int: Integer, + | str: String + |} + |""".stripMargin + + val myEnum = + """ + |$version: "2.0" + | + |namespace smithy4s + | + |use smithy4s.meta#scalaImports + | + |apply smithy4s#MyEnum @scalaImports( + | ["smithy4s.providers._"] + |) + | + |enum MyEnum { + | Right = "right" + | Left = "left" + |} + |""".stripMargin + + List(structure, service, union, myEnum).foreach { smithy => + val contents = generateScalaCode(smithy).values + + assert( + contents.exists(_.contains("import smithy4s.providers._")), + "generated code should contain imports" + ) + } + + } + + test("mix refinement and scalaImports work") { + + val smithy = + """ + |$version: "2.0" + | + |namespace smithy4s + | + |use smithy4s.meta#refinement + |use smithy4s.meta#scalaImports + | + |@trait(selector: "integer") + |structure SizeFormat { } + | + |apply smithy4s#SizeFormat @refinement( + | targetType: "smithy4s.types.Natural" + | providerImport: "smithy4s.providers._" + |) + | + |@SizeFormat + |integer Size + | + |structure Input { + | + |@range(min: 1, max: 100) + |size: Size + | + |} + | + |apply smithy4s#Input @scalaImports( + | ["smithy4s.providers._"] + |) + |""".stripMargin + + val allContents = generateScalaCode(smithy) + + assert( + allContents("smithy4s.Size").contains("import smithy4s.providers._"), + "generated code should contain imports" + ) + + assert( + allContents("smithy4s.Input").contains("import smithy4s.providers._"), + "generated code should contain imports" + ) + + } + property("enumeration order is preserved") { // custom input to avoid scalacheck shrinking @@ -520,4 +654,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 ValidatedNewtype[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/AbstractNewtype.scala b/modules/core/src-2/AbstractNewtype.scala new file mode 100644 index 000000000..e527b354c --- /dev/null +++ b/modules/core/src-2/AbstractNewtype.scala @@ -0,0 +1,35 @@ +/* + * 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 AbstractNewtype[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 final def value(x: Type): A = + x.asInstanceOf[A] + + def schema: Schema[Type] + + implicit val tag: ShapeTag[Type] = new ShapeTag[Type] { + def id: ShapeId = self.id + def schema: Schema[Type] = self.schema + } +} diff --git a/modules/core/src-2/Newtype.scala b/modules/core/src-2/Newtype.scala index af4dc336c..b565cf2b7 100644 --- a/modules/core/src-2/Newtype.scala +++ b/modules/core/src-2/Newtype.scala @@ -16,29 +16,17 @@ package smithy4s -abstract class Newtype[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 +abstract class Newtype[A] extends AbstractNewtype[A] { self => - @inline final def apply(a: A): Type = a.asInstanceOf[Type] + // This is no longer used, but kept to make MiMa happy in 0.18 + private[smithy4s] trait _Tag extends Any - @inline final def value(x: Type): A = - x.asInstanceOf[A] + @inline final def apply(a: A): Type = a.asInstanceOf[Type] implicit final class Ops(val self: Type) { @inline final def value: A = Newtype.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) implicit val asBijection: Bijection[A, Type] = new Newtype.Make[A, Type] { diff --git a/modules/core/src-2/ValidatedNewtype.scala b/modules/core/src-2/ValidatedNewtype.scala new file mode 100644 index 000000000..59cf88a85 --- /dev/null +++ b/modules/core/src-2/ValidatedNewtype.scala @@ -0,0 +1,41 @@ +/* + * 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 ValidatedNewtype[A] extends AbstractNewtype[A] { self => + + @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) + } + + implicit final class Ops(val self: Type) { + @inline final def value: A = ValidatedNewtype.this.value(self) + } + + def unapply(t: Type): Some[A] = Some(t.value) + + object hint { + def unapply(h: Hints): Option[Type] = h.get(tag) + } +} + +object ValidatedNewtype { + private[smithy4s] trait Make[A, B] extends Bijection[A, B] +} diff --git a/modules/core/src-3/AbstractNewtype.scala b/modules/core/src-3/AbstractNewtype.scala new file mode 100644 index 000000000..ec324cb20 --- /dev/null +++ b/modules/core/src-3/AbstractNewtype.scala @@ -0,0 +1,28 @@ +/* + * 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 AbstractNewtype[A] extends HasId { self => + type Type + + def schema: Schema[Type] + + implicit val tag: ShapeTag[Type] = new ShapeTag[Type] { + def id: ShapeId = self.id + def schema: Schema[Type] = self.schema + } +} diff --git a/modules/core/src-3/Newtype.scala b/modules/core/src-3/Newtype.scala index a6bab7547..55a08783c 100644 --- a/modules/core/src-3/Newtype.scala +++ b/modules/core/src-3/Newtype.scala @@ -16,21 +16,16 @@ package smithy4s -abstract class Newtype[A] extends HasId { self => - opaque type Type = A +abstract class Newtype[A] extends AbstractNewtype[A] { self => + opaque type T = A - def apply(a: A): Type = a + type Type = T extension (orig: Type) def value: A = orig - def unapply(orig: Type): Some[A] = Some(orig.value) - - def schema: Schema[Type] + def apply(a: A): Newtype.this.Type = a - implicit val tag: ShapeTag[Type] = new ShapeTag[Type] { - def id: ShapeId = self.id - def schema: Schema[Type] = self.schema - } + def unapply(orig: Type): Some[A] = Some(orig.value) implicit val asBijection: Bijection[A, Type] = new Newtype.Make[A, Type] { def to(a: A): Type = self.apply(a) diff --git a/modules/core/src-3/ValidatedNewtype.scala b/modules/core/src-3/ValidatedNewtype.scala new file mode 100644 index 000000000..959242db6 --- /dev/null +++ b/modules/core/src-3/ValidatedNewtype.scala @@ -0,0 +1,42 @@ +/* + * 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 ValidatedNewtype[A] extends AbstractNewtype[A] { self => + opaque type T = A + + type Type = T + + 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) + + object hint { + def unapply(h: Hints): Option[Type] = h.get(tag) + } +} + +object ValidatedNewtype { + private[smithy4s] trait Make[A, B] extends Bijection[A, B] +} diff --git a/modules/core/src-js/smithy4s/Timestamp.scala b/modules/core/src-js/smithy4s/Timestamp.scala index 95c26ca7f..e8e695c02 100644 --- a/modules/core/src-js/smithy4s/Timestamp.scala +++ b/modules/core/src-js/smithy4s/Timestamp.scala @@ -290,7 +290,7 @@ object Timestamp { private[this] def parseDateTime(s: String): Timestamp = { val len = s.length - if (len < 19) error() + if (len < 16) error() var pos = 0 val year = { val ch0 = s.charAt(pos) @@ -342,18 +342,20 @@ object Timestamp { val minute = { val ch0 = s.charAt(pos) val ch1 = s.charAt(pos + 1) - val ch2 = s.charAt(pos + 2) - if (ch0 < '0' || ch0 > '5' || ch1 < '0' || ch1 > '9' || ch2 != ':') + if (ch0 < '0' || ch0 > '5' || ch1 < '0' || ch1 > '9') error() - pos += 3 + pos += 2 ch0 * 10 + ch1 - 528 // 528 == '0' * 11 } val second = { - val ch0 = s.charAt(pos) - val ch1 = s.charAt(pos + 1) - if (ch0 < '0' || ch0 > '5' || ch1 < '0' || ch1 > '9') error() - pos += 2 - ch0 * 10 + ch1 - 528 // 528 == '0' * 11 + val separator = s.charAt(pos) + if (separator == ':') { + val ch0 = s.charAt(pos + 1) + val ch1 = s.charAt(pos + 2) + if (ch0 < '0' || ch0 > '5' || ch1 < '0' || ch1 > '9') error() + pos += 3 + ch0 * 10 + ch1 - 528 // 528 == '0' * 11 + } else 0 } var epochSecond = toEpochDay( year, diff --git a/modules/core/src-jvm-native/smithy4s/Timestamp.scala b/modules/core/src-jvm-native/smithy4s/Timestamp.scala index 4b5dd3f4c..bfc1f3519 100644 --- a/modules/core/src-jvm-native/smithy4s/Timestamp.scala +++ b/modules/core/src-jvm-native/smithy4s/Timestamp.scala @@ -275,7 +275,7 @@ object Timestamp extends TimestampCompanionPlatform { private[this] def parseDateTime(s: String): Timestamp = { val len = s.length - if (len < 19) error() + if (len < 16) error() var pos = 0 val year = { val ch0 = s.charAt(pos) @@ -327,18 +327,20 @@ object Timestamp extends TimestampCompanionPlatform { val minute = { val ch0 = s.charAt(pos) val ch1 = s.charAt(pos + 1) - val ch2 = s.charAt(pos + 2) - if (ch0 < '0' || ch0 > '5' || ch1 < '0' || ch1 > '9' || ch2 != ':') + if (ch0 < '0' || ch0 > '5' || ch1 < '0' || ch1 > '9') error() - pos += 3 + pos += 2 ch0 * 10 + ch1 - 528 // 528 == '0' * 11 } val second = { - val ch0 = s.charAt(pos) - val ch1 = s.charAt(pos + 1) - if (ch0 < '0' || ch0 > '5' || ch1 < '0' || ch1 > '9') error() - pos += 2 - ch0 * 10 + ch1 - 528 // 528 == '0' * 11 + val separator = s.charAt(pos) + if (separator == ':') { + val ch0 = s.charAt(pos + 1) + val ch1 = s.charAt(pos + 2) + if (ch0 < '0' || ch0 > '5' || ch1 < '0' || ch1 > '9') error() + pos += 3 + ch0 * 10 + ch1 - 528 // 528 == '0' * 11 + } else 0 } var epochSecond = toEpochDay( year, diff --git a/modules/core/src/smithy4s/Blob.scala b/modules/core/src/smithy4s/Blob.scala index ed8c9ffd7..e49856d69 100644 --- a/modules/core/src/smithy4s/Blob.scala +++ b/modules/core/src/smithy4s/Blob.scala @@ -113,6 +113,20 @@ sealed trait Blob { } final def ++(other: Blob) = concat(other) + + override def equals(other: Any): Boolean = + other match { + case otherBlob: Blob => sameBytesAs(otherBlob) + case _ => false + } + + override def hashCode(): Int = { + import util.hashing.MurmurHash3 + var h = MurmurHash3.stringHash("Blob") + foreach(o => h = MurmurHash3.mix(h, o.##)) + MurmurHash3.finalizeHash(h, size) + } + } object Blob { @@ -171,12 +185,7 @@ object Blob { override def toString = s"ByteBufferBlob(...)" override def isEmpty: Boolean = !buf.hasRemaining() override def size: Int = buf.remaining() - override def hashCode = buf.hashCode() - override def equals(other: Any): Boolean = { - other.isInstanceOf[ByteBufferBlob] && - buf.compareTo(other.asInstanceOf[ByteBufferBlob].buf) == 0 - } } final class ArraySliceBlob private[smithy4s] (val arr: Array[Byte], val offset: Int, val length: Int) extends Blob { @@ -206,23 +215,6 @@ object Blob { override def toString(): String = s"ArraySliceBlob(..., $offset, $length)" - override def hashCode(): Int = { - import util.hashing.MurmurHash3 - var h = MurmurHash3.stringHash("ArraySliceBlob") - h = MurmurHash3.mix(h, MurmurHash3.arrayHash(arr)) - h = MurmurHash3.mix(h, offset) - MurmurHash3.mixLast(h, length) - } - - override def equals(other: Any): Boolean = { - other.isInstanceOf[ArraySliceBlob] && { - val o = other.asInstanceOf[ArraySliceBlob] - offset == o.offset && - length == o.length && - java.util.Arrays.equals(arr, o.arr) - } - } - } final class QueueBlob private[smithy4s] (val blobs: Queue[Blob], val size: Int) extends Blob { diff --git a/modules/core/src/smithy4s/Hints.scala b/modules/core/src/smithy4s/Hints.scala index 0d2729e4d..65fa4a165 100644 --- a/modules/core/src/smithy4s/Hints.scala +++ b/modules/core/src/smithy4s/Hints.scala @@ -46,7 +46,10 @@ trait Hints { def get[A](implicit key: ShapeTag[A]): Option[A] 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) + private[smithy4s] final def get[T](nt: Newtype[T]): Option[nt.Type] = get( + nt.tag + ) + final def get[T](nt: AbstractNewtype[T]): Option[nt.Type] = get(nt.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..a45ba4553 --- /dev/null +++ b/modules/core/src/smithy4s/Validator.scala @@ -0,0 +1,70 @@ +/* + * 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 toSchema(a: Schema[A]): Schema[B] + + def alsoValidating[C](constraint: C)(implicit + ev: RefinementProvider.Simple[C, A] + ): Validator[A, B] +} + +object Validator { + + def of[A, B](bijection: Bijection[A, B]): ValidatorBuilder[A, B] = + new ValidatorBuilder[A, B](bijection) + + final class ValidatorBuilder[A, B] private[smithy4s] ( + bijection: Bijection[A, B] + ) { + def validating[C](constraint: C)(implicit + 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]) { + case (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) + + override def toSchema(a: Schema[A]): Schema[B] = { + refinements + .foldLeft(a) { (schema, refinement) => + schema.refined[A](refinement) + } + .biject(bijection) + } + } +} diff --git a/modules/core/src/smithy4s/http/HttpUnaryClientCodecs.scala b/modules/core/src/smithy4s/http/HttpUnaryClientCodecs.scala index fbd758458..422ccc9d8 100644 --- a/modules/core/src/smithy4s/http/HttpUnaryClientCodecs.scala +++ b/modules/core/src/smithy4s/http/HttpUnaryClientCodecs.scala @@ -120,7 +120,7 @@ object HttpUnaryClientCodecs { val mediaTypeWriters = new CachedSchemaCompiler.Uncached[HttpRequest.Writer[Blob, *]] { def fromSchema[A](schema: Schema[A]): HttpRequest.Writer[Blob, A] = { - val maybeRawMediaType = HttpMediaType.fromSchema(schema).map(_.value) + val maybeRawMediaType = if (rawStringsAndBlobPayloads) HttpMediaType.fromSchema(schema).map(_.value) else None maybeRawMediaType match { case Some(mt) => new HttpRequest.Writer[Blob, A] { 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/core/src/smithy4s/schema/Schema.scala b/modules/core/src/smithy4s/schema/Schema.scala index 805d66b7b..23fde7cee 100644 --- a/modules/core/src/smithy4s/schema/Schema.scala +++ b/modules/core/src/smithy4s/schema/Schema.scala @@ -341,8 +341,12 @@ object Schema { private [smithy4s] class PartiallyAppliedRefinement[A, B](private val schema: Schema[A]) extends AnyVal { def apply[C](c: C)(implicit refinementProvider: RefinementProvider[C, A, B]): Schema[B] = { - val hint = Hints.Binding.fromValue(c)(refinementProvider.tag) - RefinementSchema(schema.addHints(hint), refinementProvider.make(c)) + apply(refinementProvider.make(c)) + } + + def apply(refinement: Refinement[A, B]): Schema[B] = { + val hint = Hints.Binding.fromValue(refinement.constraint)(refinement.tag) + RefinementSchema(schema.addHints(hint), refinement) } } diff --git a/modules/docs/markdown/04-codegen/01-customisation/15-scala-imports.md b/modules/docs/markdown/04-codegen/01-customisation/15-scala-imports.md new file mode 100644 index 000000000..7bfe5bf02 --- /dev/null +++ b/modules/docs/markdown/04-codegen/01-customisation/15-scala-imports.md @@ -0,0 +1,149 @@ +--- +sidebar_label: ScalaImports +title: Add Scala imports to generated code +--- + +`scalaImports` trait provides a mechanism for adding additional imports to smithy4s's generated code. This can be particularly useful when you want to combine type refinements (especially when the type refinements come a third party or in other module) and validators. + +Lets say We have a smithy specification and it's accommodated Scala code as below: + +```kotlin +$version: "2.0" +namespace test + +use smithy4s.meta#refinement + +@trait(selector: "integer") +@refinement( + targetType: "myapp.types.PositiveInt" + providerImport: "myapp.types.providers._" +) +structure PageSizeFormat { } + +@PageSizeFormat +integer PageSize + +structure Input { + pageSize: PageSize +} + +``` + +And + +```scala mdoc:reset:invisible +// this is just here so the lower blocks will compile +import smithy4s._ +import smithy4s.schema.Schema._ +case class PageSizeFormat() + +object PageSizeFormat extends ShapeTag.Companion[PageSizeFormat] { + val id: ShapeId = ShapeId("smithy4s", "PageSizeFormat") + + val hints: Hints = Hints( + smithy.api.Trait(selector = Some("integer"), structurallyExclusive = None, conflicts = None, breakingChanges = None), + ).lazily + + + implicit val schema: Schema[PageSizeFormat] = constant(PageSizeFormat()).withId(id).addHints(hints) +} +``` + +```scala mdoc:silent +// package myapp.types +// The recommendations from Type refinements docs are also applied here +import smithy4s._ + +case class PositiveInt(value: Int) +object PositiveInt { + + private def isPositiveInt(value: Int): Boolean = value > 0 + + def apply(value: Int): Either[String, PositiveInt] = + if (isPositiveInt(value)) Right(new PositiveInt(value)) + else Left(s"$value is not a positive int") +} + +object providers { + + implicit val provider: RefinementProvider[PageSizeFormat, Int, PositiveInt] = Refinement.drivenBy[PageSizeFormat]( + PositiveInt.apply, + (i: PositiveInt) => i.value + ) +} +``` + +:::info + +Note that the implicit `RefinementProvider` is not in the companion object of `PositiveInt`, so that We need to add an `providerImport` to the `refinement` trait. + +::: + +What We have here is `PageSize` will be generated as a `PositiveInt`. Which is really nice, but what if you need another validator like `@range` to limit how big a `pageSize` can be? So, just let try: + +```kotlin +structure Input { + // highlight-start + @range(max: 100) + // highlight-end + pageSize: PageSize +} +``` + +And compile to see what happen: + +``` +[error] 20 | PageSize.schema.validated(smithy.api.Range(min = None, max = Some(scala.math.BigDecimal(100.0)))).field[Input]("pageSize", _.pageSize).addHints(smithy.api.Default(smithy4s.Document.fromDouble(0.0d))), +[error] | ^ +[error] |No implicit value of type smithy4s.RefinementProvider.Simple[smithy.api.Range, test.PageSize] was found for parameter constraint of method validated in trait Schema. +[error] |I found: +[error] | +[error] | smithy4s.RefinementProvider.isomorphismConstraint[smithy.api.Range, A, +[error] | test.PageSize.Type](smithy4s.RefinementProvider.enumRangeConstraint[A], +[error] | /* missing */summon[smithy4s.Bijection[A, test.PageSize.Type]]) +[error] | +[error] |But no implicit values were found that match type smithy4s.Bijection[A, test.PageSize.Type]. +[error] one error found +``` + +This looks scary, but it basically says that We need to create an implicit value of `RefinementProvider.Simple[Range, PageSize.Type]` and provide it to the file that contains `Input` structure. We can do the first step by adding this to `providers` object: + +```scala + implicit val rangeProvider: RefinementProvider.Simple[smithy.api.Range, PositiveInt] = + RefinementProvider.rangeConstraint(x => x.value) +``` +:::info + +Note that the `PageSize.Type` is `PositiveInt` + +::: + +And for the second step, We need to apply `scalaImports` trait with an appropriate import to `Input` structure: + +```kotlin +namespace test +// highlight-start +use smithy4s.meta#scalaImports +// highlight-end +use smithy4s.meta#refinement + +@trait(selector: "integer") +@refinement( + targetType: "myapp.types.Natural" + providerImport: "myapp.types.providers._" +) +structure PageSizeFormat {} + +@PageSizeFormat +integer PageSize + +// highlight-start +@scalaImports(["myapp.types.providers._"]) +// highlight-end +structure Input { + @range(max: 100) + pageSize: PageSize +} +``` + +Now, Smithy4s will validate any `PageSize` value against range and then refine it into `PositiveInt`. We have the best of both worlds. 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..c049eee12 --- /dev/null +++ b/modules/docs/markdown/04-codegen/01-customisation/15-validated-newtypes.md @@ -0,0 +1,58 @@ +--- +sidebar_label: Validated Newtypes +title: Validated Newtypes +--- + +As of version `0.18.23` and above, 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.string + +type Name = Name.Type + +object Name extends ValidatedNewtype[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)) + + val validator: Validator[String, Name] = + Validator.of[String, Name](Bijection[String, Name](_.asInstanceOf[Name], value(_))) + .validating(smithy.api.Length(min = Some(5L), max = None)) + + implicit val schema: Schema[Name] = validator.toSchema(underlyingSchema) + + @inline def apply(a: String): Either[String, Name] = validator.validate(a) +} +``` diff --git a/modules/docs/markdown/06-guides/endpoint-middleware.md b/modules/docs/markdown/06-guides/endpoint-middleware.md index 7a1d0386b..a8ab23fef 100644 --- a/modules/docs/markdown/06-guides/endpoint-middleware.md +++ b/modules/docs/markdown/06-guides/endpoint-middleware.md @@ -108,7 +108,6 @@ We will create a server-side middleware that implements the authentication as de import smithy4s.example.guides.auth._ import cats.effect._ import cats.implicits._ -import org.http4s.implicits._ import org.http4s._ import smithy4s.http4s.SimpleRestJsonBuilder import smithy4s._ diff --git a/modules/docs/src/main/scala/docs/AwsServiceList.scala b/modules/docs/src/main/scala/docs/AwsServiceList.scala index 9a33349f6..f1da39174 100644 --- a/modules/docs/src/main/scala/docs/AwsServiceList.scala +++ b/modules/docs/src/main/scala/docs/AwsServiceList.scala @@ -25,15 +25,15 @@ object AwsServiceList { println(s"\n#### $emoji ${artifact.service}\n") val sbt = - s""""${artifact.organization}" % "${artifact.name}" % "${summary.version}"""" + s"""`"${artifact.organization}" % "${artifact.name}" % "${summary.version}"`""" val mill = - s"""ivy"${artifact.organization}:${artifact.name}:${summary.version}"""" + s"""`ivy"${artifact.organization}:${artifact.name}:${summary.version}"`""" println(s"* sbt: $sbt") println(s"* mill: $mill") if (artifact.streamingOperations.nonEmpty) { println("") println(s"**Unsupported streaming operations**") - artifact.streamingOperations.foreach(op => println(s"* $op")) + artifact.streamingOperations.foreach(op => println(s"* `$op`")) } } } diff --git a/modules/http4s/test/src/smithy4s/http4s/NullsAndDefaultEncodingSuite.scala b/modules/http4s/test/src/smithy4s/http4s/NullsAndDefaultEncodingSuite.scala index 86ce47e1c..1053e386e 100644 --- a/modules/http4s/test/src/smithy4s/http4s/NullsAndDefaultEncodingSuite.scala +++ b/modules/http4s/test/src/smithy4s/http4s/NullsAndDefaultEncodingSuite.scala @@ -21,14 +21,15 @@ import org.http4s._ import org.http4s.implicits._ import cats.effect.IO import smithy4s.example.ServiceWithNullsAndDefaults -import smithy4s.example.OperationOutput +import smithy4s.example.DefaultNullsOperationOutput import io.circe.Json import org.typelevel.ci.CIString import org.typelevel.ci._ import org.http4s.circe.CirceInstances import org.http4s.client.Client -import smithy4s.example.OperationInput +import smithy4s.example.DefaultNullsOperationInput import cats.effect.kernel.Deferred +import smithy4s.example.TimestampOperationInput object NullsAndDefaultEncodingSuite extends SimpleIOSuite with CirceInstances { @@ -65,7 +66,7 @@ object NullsAndDefaultEncodingSuite extends SimpleIOSuite with CirceInstances { } test("client - explicit defaults encoding = false") { - runClientTest(explicitDefaults = false, OperationInput()) + runClientTest(explicitDefaults = false, DefaultNullsOperationInput()) .map { request => assert.same( Map( @@ -91,7 +92,7 @@ object NullsAndDefaultEncodingSuite extends SimpleIOSuite with CirceInstances { } test("client - explicit defaults encoding = true") { - runClientTest(explicitDefaults = true, OperationInput()) + runClientTest(explicitDefaults = true, DefaultNullsOperationInput()) .map { request => assert.same( Map( @@ -120,8 +121,14 @@ object NullsAndDefaultEncodingSuite extends SimpleIOSuite with CirceInstances { } object Impl extends ServiceWithNullsAndDefaults[IO] { - override def operation(input: OperationInput): IO[OperationOutput] = - IO.pure(OperationOutput()) + + override def timestampOperation(input: TimestampOperationInput): IO[Unit] = + IO.unit + + override def defaultNullsOperation( + input: DefaultNullsOperationInput + ): IO[DefaultNullsOperationOutput] = + IO.pure(DefaultNullsOperationOutput()) } private val specHeaders = Set( @@ -164,7 +171,7 @@ object NullsAndDefaultEncodingSuite extends SimpleIOSuite with CirceInstances { private def runClientTest( explicitDefaults: Boolean, - input: OperationInput + input: DefaultNullsOperationInput ): IO[TestRequest] = { val resources = for { promise <- Deferred[IO, Request[IO]].toResource @@ -182,7 +189,7 @@ object NullsAndDefaultEncodingSuite extends SimpleIOSuite with CirceInstances { .resource } yield (promise, client) resources.use { case (promise, client) => - client.operation(input) >> promise.get.flatMap { req => + client.defaultNullsOperation(input) >> promise.get.flatMap { req => val labels = req.uri.path.segments .map(_.toString) .toList diff --git a/modules/json/src/smithy4s/json/internals/JsonPayloadCodecCompilerImpl.scala b/modules/json/src/smithy4s/json/internals/JsonPayloadCodecCompilerImpl.scala index 353df1fc0..44c6cfe21 100644 --- a/modules/json/src/smithy4s/json/internals/JsonPayloadCodecCompilerImpl.scala +++ b/modules/json/src/smithy4s/json/internals/JsonPayloadCodecCompilerImpl.scala @@ -79,19 +79,22 @@ private[json] case class JsonPayloadCodecCompilerImpl( def fromSchema[A](schema: Schema[A], cache: Cache): PayloadDecoder[A] = { val jcodec = jsoniterCodecCompiler.fromSchema(schema, cache) - new JsonPayloadDecoder(jcodec) + new JsonPayloadDecoder(jcodec, schema.getDefaultValue) } def fromSchema[A](schema: Schema[A]): PayloadDecoder[A] = fromSchema(schema, createCache()) } - private class JsonPayloadDecoder[A](jcodec: JsonCodec[A]) + private class JsonPayloadDecoder[A](jcodec: JsonCodec[A], default: Option[A]) extends PayloadDecoder[A] { def decode(blob: Blob): Either[PayloadError, A] = { try { Right { - if (blob.isEmpty) readFromString("{}", jsoniterReaderConfig)(jcodec) + if (blob.isEmpty) + default.getOrElse( + readFromString("{}", jsoniterReaderConfig)(jcodec) + ) else blob match { case b: Blob.ArraySliceBlob => 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..67f91dd03 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,5 @@ smithy4s.meta.TypeclassTrait$Provider smithy4s.meta.GenerateServiceProductTrait$Provider smithy4s.meta.GenerateOpticsTrait$Provider smithy4s.meta.OnlyTrait$Provider +smithy4s.meta.ScalaImportsTrait$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..e53911bf1 100644 --- a/modules/protocol/resources/META-INF/smithy/smithy4s.meta.smithy +++ b/modules/protocol/resources/META-INF/smithy/smithy4s.meta.smithy @@ -2,13 +2,12 @@ $version: "2.0" metadata suppressions = [ { - id: "UnreferencedShape", - namespace: "smithy4s.meta", + id: "UnreferencedShape" + namespace: "smithy4s.meta" reason: "This is a library namespace." } ] - namespace smithy4s.meta @trait(selector: ":is(operation)") @@ -39,26 +38,27 @@ structure adt {} // from various formats, Smithy4s will do a best effort to try and back the IndexedSeq // the most efficiently possible, often using `ArraySeq` and storing primitive values // in unboxed ways. -@trait(selector: """ +@trait( + selector: """ list :not(:test([trait|smithy4s.meta#vector], - [trait|smithy.api#uniqueItems]))""") + [trait|smithy.api#uniqueItems]))""" +) structure indexedSeq {} // the vector trait can be added to list shapes in order for the generated collection // fields to be of type `Vector` instead of `List` -@trait(selector: """ +@trait( + selector: """ list :not(:test([trait|smithy4s.meta#indexedSeq], - [trait|smithy.api#uniqueItems]))""") + [trait|smithy.api#uniqueItems]))""" +) structure vector {} // the errorMessage trait marks a structure's field as one that will be used // for the generated exception's error message. -@trait( - selector: "structure > member", - structurallyExclusive: "member" -) +@trait(selector: "structure > member", structurallyExclusive: "member") structure errorMessage {} /// Allows specifying a custom type that smithy4s will use for rendering @@ -88,8 +88,10 @@ structure errorMessage {} @trait(selector: "* [trait|trait]") structure refinement { @required - targetType: Classpath, - providerImport: Import, + targetType: Classpath + + providerImport: Import + parameterised: Boolean = false } @@ -105,7 +107,7 @@ string Import /// This trait is used to signal that this type should not be wrapped /// in a newtype at usage sites. For example: -/// +/// /// @unwrap /// string Email /// @@ -134,7 +136,7 @@ structure unwrap {} /// /// @show /// structure Person { -/// name: String +/// name: String /// } /// /// This example would lead to generated code where the Person @@ -143,7 +145,8 @@ structure unwrap {} @trait(selector: "* [trait|trait]") structure typeclass { @required - targetType: Classpath, + targetType: Classpath + @required interpreter: Classpath } @@ -155,7 +158,7 @@ structure generateServiceProduct {} /// Placing this trait on a shape will cause the generated /// code to have optics (Lenses or Prisms) in the companion -/// object. +/// object. @trait(selector: ":is(enum, intEnum, union, structure)") structure generateOptics {} @@ -163,3 +166,23 @@ structure generateOptics {} /// via extending scala.util.control.NoStackTrace instead of Throwable. @trait(selector: "structure :is([trait|error])") structure noStackTrace {} + +/// Allows users to manually add imports to files of generated shapes. +/// This would be helpful when some shape needs specific import(s) in order +/// to compile. Espically in the case you want to compose refinement types +/// and other validators. +@trait +list scalaImports { + member: Import +} + +@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/ScalaImportsTrait.java b/modules/protocol/src/smithy4s/meta/ScalaImportsTrait.java new file mode 100644 index 000000000..7ab1753fe --- /dev/null +++ b/modules/protocol/src/smithy4s/meta/ScalaImportsTrait.java @@ -0,0 +1,100 @@ +/* + * 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 java.util.List; +import java.util.stream.Collectors; +import software.amazon.smithy.model.SourceException; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.AbstractTrait; +import software.amazon.smithy.model.traits.AbstractTraitBuilder; +import software.amazon.smithy.model.traits.TraitService; +import software.amazon.smithy.utils.SmithyBuilder; +import software.amazon.smithy.utils.ToSmithyBuilder; + +public final class ScalaImportsTrait extends AbstractTrait implements ToSmithyBuilder { + + + public static final ShapeId ID = ShapeId.from("smithy4s.meta#scalaImports"); + + private final List imports; + + private ScalaImportsTrait(ScalaImportsTrait.Builder builder) { + super(ID, builder.getSourceLocation()); + this.imports = builder.imports; + + if (this.imports == null) { + throw new SourceException("imports must be provided.", getSourceLocation()); + } + } + + public List getImports() { + return this.imports; + } + + @Override + protected Node createNode() { + ArrayNode.Builder builder = ArrayNode.builder(); + getImports().forEach(s -> builder.withValue(s)); + return builder.build(); + } + + @Override + public SmithyBuilder toBuilder() { + return builder().imports(imports).sourceLocation(getSourceLocation()); + } + + /** + * @return Returns a new RefinedTrait builder. + */ + public static ScalaImportsTrait.Builder builder() { + return new Builder(); + } + + public static final class Builder extends AbstractTraitBuilder { + + private List imports; + + public ScalaImportsTrait.Builder imports(List imports) { + this.imports = imports; + return this; + } + + @Override + public ScalaImportsTrait build() { + return new ScalaImportsTrait(this); + } + } + + public static final class Provider implements TraitService { + + @Override + public ShapeId getShapeId() { + return ID; + } + + @Override + public ScalaImportsTrait createTrait(ShapeId target, Node value) { + ArrayNode arrayNode = value.expectArrayNode(); + List imports = arrayNode.getElements().stream().map(node -> node.expectStringNode().getValue()).collect(Collectors.toList()); + return builder().sourceLocation(value).imports(imports).build(); + } + + } +} 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/modules/website/package.json b/modules/website/package.json index 2e08fd339..b8b259dc0 100644 --- a/modules/website/package.json +++ b/modules/website/package.json @@ -16,9 +16,9 @@ "dependencies": { "@docusaurus/core": "2.4.3", "@docusaurus/preset-classic": "2.4.3", - "docusaurus-lunr-search": "3.3.2", + "docusaurus-lunr-search": "3.4.0", "@mdx-js/react": "^1.6.21", - "clsx": "^2.0.0", + "clsx": "^2.1.1", "prism-react-renderer": "^2.1.0", "react": "^17.0.1", "react-dom": "^17.0.1" diff --git a/modules/website/yarn.lock b/modules/website/yarn.lock index e78eaa609..51ec60daf 100644 --- a/modules/website/yarn.lock +++ b/modules/website/yarn.lock @@ -1465,7 +1465,7 @@ "@docusaurus/theme-search-algolia" "2.4.3" "@docusaurus/types" "2.4.3" -"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2": +"@docusaurus/react-loadable@5.5.2": version "5.5.2" resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce" integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ== @@ -2937,10 +2937,10 @@ clsx@^1.2.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== -clsx@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b" - integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q== +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== collapse-white-space@^1.0.2: version "1.0.6" @@ -3473,10 +3473,10 @@ dns-packet@^5.2.2: dependencies: "@leichtgewicht/ip-codec" "^2.0.1" -docusaurus-lunr-search@3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/docusaurus-lunr-search/-/docusaurus-lunr-search-3.3.2.tgz#23699b899d9275402e3004e1fe6085e1bae4f007" - integrity sha512-+TXfiRAwIAaNwME8bBZvC+osfoXjJSNs5BcZu92lIHoWc3Myct4Nw3jU0FMXQCQGQcQ0FgFqMDoh56LPCLVaxQ== +docusaurus-lunr-search@3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/docusaurus-lunr-search/-/docusaurus-lunr-search-3.4.0.tgz#cefb0f8628f629e780451ae042d3cc35f078826f" + integrity sha512-GfllnNXCLgTSPH9TAKWmbn8VMfwpdOAZ1xl3T2GgX8Pm26qSDLfrrdVwjguaLfMJfzciFL97RKrAJlgrFM48yw== dependencies: autocomplete.js "^0.37.0" clsx "^1.2.1" @@ -6258,6 +6258,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1: dependencies: "@babel/runtime" "^7.10.3" +"react-loadable@npm:@docusaurus/react-loadable@5.5.2": + version "5.5.2" + resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce" + integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ== + dependencies: + "@types/react" "*" + prop-types "^15.6.2" + react-router-config@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" diff --git a/modules/xml/src/smithy4s/xml/Xml.scala b/modules/xml/src/smithy4s/xml/Xml.scala index 0793a84b7..1b287f34d 100644 --- a/modules/xml/src/smithy4s/xml/Xml.scala +++ b/modules/xml/src/smithy4s/xml/Xml.scala @@ -115,7 +115,7 @@ object Xml { XmlDocument.documentEventifier .eventify(xmlDocument) - .through(render(collapseEmpty = false)) + .through(render.raw(collapseEmpty = false)) } private def writeToBytes[A: Schema](a: A): Stream[fs2.Pure, Byte] = diff --git a/modules/xml/src/smithy4s/xml/internals/XmlPayloadEncoderCompilerImpl.scala b/modules/xml/src/smithy4s/xml/internals/XmlPayloadEncoderCompilerImpl.scala index c1385b3cc..30f82e2ca 100644 --- a/modules/xml/src/smithy4s/xml/internals/XmlPayloadEncoderCompilerImpl.scala +++ b/modules/xml/src/smithy4s/xml/internals/XmlPayloadEncoderCompilerImpl.scala @@ -33,7 +33,7 @@ private[xml] class XmlPayloadEncoderCompilerImpl(escapeAttributes: Boolean) Blob { eventifier .eventify(xmlDocumentEncoder.encode(a)) - .through(fs2.data.xml.render(collapseEmpty = false)) + .through(fs2.data.xml.render.raw(collapseEmpty = false)) .through(fs2.text.utf8.encode[fs2.Pure]) .compile .to(Collector.supportsArray(Array)) diff --git a/modules/xml/test/src/smithy4s/xml/XmlCodecSpec.scala b/modules/xml/test/src/smithy4s/xml/XmlCodecSpec.scala index b33409f56..7be02550b 100644 --- a/modules/xml/test/src/smithy4s/xml/XmlCodecSpec.scala +++ b/modules/xml/test/src/smithy4s/xml/XmlCodecSpec.scala @@ -585,7 +585,7 @@ object XmlCodecSpec extends SimpleIOSuite { def show(xmlDocument: XmlDocument): String = XmlDocument.documentEventifier .eventify(xmlDocument) - .through(render()) + .through(render.raw()) .compile .string } diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 9833ee1ea..dfb923897 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -31,7 +31,7 @@ object Dependencies { val Alloy = new { val org = "com.disneystreaming.alloy" - val alloyVersion = "0.3.8" + val alloyVersion = "0.3.9" val core = org % "alloy-core" % alloyVersion val openapi = org %% "alloy-openapi" % alloyVersion val protobuf = org % "alloy-protobuf" % alloyVersion @@ -40,7 +40,7 @@ object Dependencies { val Smithytranslate = new { val org = "com.disneystreaming.smithy" - val smithyTranslateVersion = "0.5.2" + val smithyTranslateVersion = "0.5.3" val proto = org %% "smithytranslate-proto" % smithyTranslateVersion } @@ -80,7 +80,7 @@ object Dependencies { object Fs2Data { val xml: Def.Initialize[ModuleID] = - Def.setting("org.gnieh" %%% "fs2-data-xml" % "1.10.0") + Def.setting("org.gnieh" %%% "fs2-data-xml" % "1.11.0") } object Mill { @@ -139,7 +139,7 @@ object Dependencies { ) } - class MunitCross(munitVersion: String) { + class MunitCross(val munitVersion: String) { val core: Def.Initialize[ModuleID] = Def.setting("org.scalameta" %%% "munit" % munitVersion) val scalacheck: Def.Initialize[ModuleID] = @@ -147,6 +147,10 @@ object Dependencies { } object Munit extends MunitCross("0.7.29") object MunitMilestone extends MunitCross("1.0.0-M6") + object MunitV1 extends MunitCross("1.0.0") { + val diff: Def.Initialize[ModuleID] = + Def.setting("org.scalameta" %%% "munit-diff" % munitVersion) + } val Scalacheck = new { val scalacheckVersion = "1.16.0" diff --git a/project/Smithy4sBuildPlugin.scala b/project/Smithy4sBuildPlugin.scala index 10a6edab6..d499cc486 100644 --- a/project/Smithy4sBuildPlugin.scala +++ b/project/Smithy4sBuildPlugin.scala @@ -30,7 +30,7 @@ case class CatsEffectAxis(idSuffix: String, directorySuffix: String) object Smithy4sBuildPlugin extends AutoPlugin { val Scala212 = "2.12.19" - val Scala213 = "2.13.12" + val Scala213 = "2.13.13" val Scala3 = "3.3.3" object autoImport { diff --git a/project/plugins.sbt b/project/plugins.sbt index 993d65eaa..e6c339e37 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,6 +1,6 @@ // format: off -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.12.0") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.15.0") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.12.1") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.2.1") addSbtPlugin("com.github.sbt" % "sbt-dynver" % "5.0.1") addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.10.0") @@ -14,12 +14,12 @@ addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.9.0") addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.17") addSbtPlugin("com.github.sbt" % "sbt-git" % "2.0.1") addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.3") -addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.5.17") -addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.6") +addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.5.18") +addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.7") libraryDependencies ++= Seq( "com.lihaoyi" %% "os-lib" % "0.8.1", - "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-macros" % "2.28.4", + "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-macros" % "2.28.5", "com.thesamet.scalapb" %% "compilerplugin" % "0.11.15" ) diff --git a/sampleSpecs/scalaImports.smithy b/sampleSpecs/scalaImports.smithy new file mode 100644 index 000000000..0ca384817 --- /dev/null +++ b/sampleSpecs/scalaImports.smithy @@ -0,0 +1,11 @@ +$version: "2.0" + +namespace smithy4s.example + +use smithy4s.meta#scalaImports + +@scalaImports(["smithy4s.refined.Age.provider._"]) +structure StructureWithScalaImports { + @range(min: 13, max: 19) + teenage: Age +} diff --git a/sampleSpecs/serviceWithNullsAndDefaults.smithy b/sampleSpecs/serviceWithNullsAndDefaults.smithy index f4e29ec66..505e5fb35 100644 --- a/sampleSpecs/serviceWithNullsAndDefaults.smithy +++ b/sampleSpecs/serviceWithNullsAndDefaults.smithy @@ -9,12 +9,31 @@ use smithy4s.meta#packedInputs @packedInputs service ServiceWithNullsAndDefaults { version: "1.0.0", - operations: [Operation] + operations: [DefaultNullsOperation, TimestampOperation] } +@http(method: "POST", uri: "/timestamp-operation") +operation TimestampOperation { + input := { + @required + @timestampFormat("http-date") + @default("Thu, 23 May 2024 10:20:30 GMT") + httpDate: Timestamp + + @required + @timestampFormat("epoch-seconds") + @default(1716459630) + epochSeconds: Timestamp + + @required + @timestampFormat("date-time") + @default("2024-05-23T10:20:30.000Z") + dateTime: Timestamp + } +} @http(method: "POST", uri: "/operation/{requiredLabel}") -operation Operation { +operation DefaultNullsOperation { input := { optional: String 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" +} +