Skip to content

Commit

Permalink
Validated newtypes (#1454)
Browse files Browse the repository at this point in the history
* WIP: validated newtypes squashed

Co-Authored-By: Denis Rosca <[email protected]>

* Common supertype

* Fix mima

* Fix formatting

* Fix bincompat issue in 2.1x

* ber precise about mima baseline

* Add dummy change to retrigger build

---------

Co-authored-by: Jakub Kozłowski <[email protected]>
  • Loading branch information
denisrosca and kubukoz committed Jun 24, 2024
1 parent 8e1ee57 commit 5cee547
Show file tree
Hide file tree
Showing 42 changed files with 1,152 additions and 70 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,20 @@ When adding entries, please treat them as if they could end up in a release any

Thank you!

# 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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

}
109 changes: 109 additions & 0 deletions modules/bootstrapped/test/src/smithy4s/ValidatedNewtypesSpec.scala
Original file line number Diff line number Diff line change
@@ -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)

}

}
Original file line number Diff line number Diff line change
@@ -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",
)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sbt.version=1.8.3
Original file line number Diff line number Diff line change
@@ -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
)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# check if smithy4sCodegen works and everything compiles
> compile
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@

package smithy4s.codegen

import sbt._
import sbt.Keys._
import sbt._

import Smithy4sCodegenPlugin.autoImport._
import scala.collection.immutable.ListSet

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ smithy4s.codegen.transformers.AwsStandardTypesTransformer
smithy4s.codegen.transformers.AwsConstraintsRemover
smithy4s.codegen.transformers.OpenEnumTransformer
smithy4s.codegen.transformers.KeepOnlyMarkedShapes
smithy4s.codegen.transformers.ValidatedNewtypesTransformer
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ private[codegen] object CodegenImpl { self =>
AwsConstraintsRemover.name :+
AwsStandardTypesTransformer.name :+
OpenEnumTransformer.name :+
KeepOnlyMarkedShapes.name
KeepOnlyMarkedShapes.name :+
ValidatedNewtypesTransformer.name

}
Loading

0 comments on commit 5cee547

Please sign in to comment.