Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Thank you!
# 0.19.11

- codegen: Fix an `IllegalAccessError` (e.g. `class ...IncludeClosures cannot access its abstract superclass ...BackwardCompatHelper`) that could occur during `smithy4sCodegen` when a project dependency pulled a different version of a Smithy library (`smithy-build`, `smithy-model`, etc.) than the one bundled with the codegen plugin. The model-loading `URLClassLoader` used the plugin classloader as its parent, so a duplicate copy of a plugin-provided module on the child loader could split a package across two classloaders and break package-private access. The codegen now drops any dependency already provided by the parent classloader (tracked via the new `BuildInfo.codegenDependencies`) from the child classloader, so Smithy versions no longer need to be aligned between the plugin and the project's dependencies.
- dynamic: Add `DynamicSchemaIndex.loadModel(model, performValidation: Boolean)` overload. When `performValidation = true`, constraint traits (`@length`, `@range`, `@pattern`, ...) are reified into schema refinements instead of being kept just as hints, matching the behaviour of codegen-produced schemas.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.11 is already out, we need a new section :)


# 0.19.10

Expand Down
20 changes: 18 additions & 2 deletions modules/dynamic/src-jvm/DynamicSchemaIndexCompanionPlatform.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ private[dynamic] trait DynamicSchemaIndexCompanionPlatform {
*/
def loadModel(
model: software.amazon.smithy.model.Model
): DynamicSchemaIndex = loadModel(model, performValidation = false)

/**
* Loads a dynamic schema index model from a smithy model.
*
* @param performValidation when true, constraint traits (`@length`, `@range`,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My first thought seeing this name was that we'd be doing model validations rather than schema validations...

maybe addSchemaValidators or applySchemaRefinements would fit better?

* `@pattern` etc) are reified into Schema objects that get enforced upon
* decoding, instead of being kept as inert hints (which is the default dynamic
* behaviour), mirroring the fact that codegen-produced schemas enforce these
* constraints but dynamically-loaded do not).
*/
def loadModel(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also have a platform-agnostic variant that works on our own model of Model - let's add performValidation to it too

model: software.amazon.smithy.model.Model,
performValidation: Boolean
): DynamicSchemaIndex = {
val flattenedModel =
ModelTransformer.create().flattenAndRemoveMixins(model);
Expand All @@ -35,8 +49,10 @@ private[dynamic] trait DynamicSchemaIndexCompanionPlatform {
smithy4s.Document
.decode[smithy4s.dynamic.model.Model](document)
.map(load(_)) match {
case Left(error) => throw error
case Right(value) => value
case Left(error) => throw error
case Right(value) =>
if (performValidation) DynamicSchemaValidation.reifyConstraints(value)
else value
}
}

Expand Down
148 changes: 148 additions & 0 deletions modules/dynamic/src-jvm/DynamicSchemaValidation.scala

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to limit ourselves to JVM here, this could work on all platforms.

Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
* Copyright 2021-2026 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.dynamic

import smithy4s.Document
import smithy4s.Refinement
import smithy4s.RefinementProvider
import smithy4s.ShapeId
import smithy4s.Surjection
import smithy4s.schema.CollectionTag._
import smithy4s.schema.Primitive._
import smithy4s.schema.Schema
import smithy4s.schema.Schema._
import smithy4s.~>

/**
* Dynamically-loaded schemas only carry constraint traits (`@length`, `@range`,
* `@pattern` etc) as hints: by default, `DynamicModelCompiler` only attaches them via
* `addHints`, unlike smithy4s codegen, which reifies such traits into `RefinementSchema`
* wrappers that get enforced upon decoding.
*
* This object provides a transformation for dynamically-loaded schemas, so that validation
* hints are reintroduced at a `Schema` level.
*/
private[dynamic] object DynamicSchemaValidation {

def reifyConstraints(index: DynamicSchemaIndex): DynamicSchemaIndex =
new DynamicSchemaIndex {
def allServices: Iterable[DynamicSchemaIndex.ServiceWrapper] =
index.allServices

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I believe this isn't right: operation inputs/outputs/errors won't have their traits reified. UNLESS the default DSI implementation calls getSchema(shapeId) - in which case they would be reified - but we can't assume that about the index instance here, as it's an implementation detail and not something enforced by a contract.

To be safe, we could convert those services to builders and map each endpoint's input/output/errors, reifying the constraints right there. I think a simpler alternative to this would be embedding the reification process directly in the DynamicCompiler rather than in a "middleware" (here)

def allSchemas: Iterable[Schema[_]] =
index.allSchemas.map(reifySchema(_))
def getSchema(shapeId: ShapeId): Option[Schema[_]] =
index.getSchema(shapeId).map(reifySchema(_))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like that we map the schemas on read. Not only is it per-call overhead, but it can also produce different instances of schemas when you call it multiple times, which can break things like schema compilation caches.

def metadata: Map[String, Document] = index.metadata
}

private def reifySchema[A](schema: Schema[A]): Schema[A] =
schema.transformTransitivelyK(ReifyConstraints)

private object ReifyConstraints extends (Schema ~> Schema) {

private def void[C, A](

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think at this point we should move this to RefinementProvider. It was always a bit of a hack, having it as a more first-class thing would feel better

underlying: RefinementProvider[C, A, ?]
): RefinementProvider.Simple[C, A] =
Refinement
.drivenBy[C]
.contextual[A, A](c =>
Surjection(v => underlying.make(c).apply(v).map(_ => v), identity)
)(underlying.tag)

private implicit class SchemaOps[A](schema: Schema[A]) {
def reifyHint[B](rp: RefinementProvider[B, A, ?]): Schema[A] =
schema.hints.get(rp.tag).fold(schema)(schema.validated(_)(void(rp)))
}

private def collection[C[_], B](
schema: Schema.CollectionSchema[C, B]
): Schema[C[B]] =
schema.tag match {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK that's not an exhaustive match (CollectionTag is open in 0.19). We should have another case for arbitrary tags.

case ListTag =>
schema.reifyHint(RefinementProvider.iterableLengthConstraint[List, B])
case VectorTag =>
schema.reifyHint(
RefinementProvider.iterableLengthConstraint[Vector, B]
)
case SetTag =>
schema.reifyHint(RefinementProvider.iterableLengthConstraint[Set, B])
case IndexedSeqTag =>
schema.reifyHint(
RefinementProvider.iterableLengthConstraint[IndexedSeq, B]
)
}

private def enumSchema[B <: Enum[?]](
schema: Schema.EnumerationSchema[B]
): Schema[B] =
schema
.reifyHint(RefinementProvider.lengthConstraint[B](_.toString.length))
.reifyHint(RefinementProvider.rangeConstraint[B, Int](_.ordinal()))
.reifyHint(RefinementProvider.patternConstraint[B](e => e.toString))

def apply[A](schema: Schema[A]): Schema[A] =
schema match {
case t @ PrimitiveSchema(_, _, tag) =>
tag match {
case PString =>
t.reifyHint(RefinementProvider.stringLengthConstraint)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also: @idRef

.reifyHint(RefinementProvider.stringPatternConstraints)
case PByte =>
schema.reifyHint(RefinementProvider.numericRangeConstraints[Byte])
case PShort =>
schema.reifyHint(
RefinementProvider.numericRangeConstraints[Short]
)
case PInt =>
schema.reifyHint(RefinementProvider.numericRangeConstraints[Int])
case PLong =>
schema.reifyHint(RefinementProvider.numericRangeConstraints[Long])
case PFloat =>
schema.reifyHint(
RefinementProvider.numericRangeConstraints[Float]
)
case PDouble =>
schema.reifyHint(
RefinementProvider.numericRangeConstraints[Double]
)
case PBigInt =>
schema.reifyHint(
RefinementProvider.numericRangeConstraints[BigInt]
)
case PBigDecimal =>
schema.reifyHint(
RefinementProvider.numericRangeConstraints[BigDecimal]
)
case PBlob =>
schema.reifyHint(RefinementProvider.blobLengthConstraint)
case PTimestamp | PDocument | PBoolean | PUUID | PLocalDate |
PLocalTime | PDuration | POffsetDateTime =>
schema
}
case e: EnumerationSchema[?] =>
enumSchema(e.asInstanceOf[EnumerationSchema[Enum[?]]])
.asInstanceOf[Schema[A]]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Casts are pretty risky in Dynamic land. Any way we can make this work without them?

case c @ CollectionSchema(_, _, _, _) => collection(c)
case m: MapSchema[c, k, v] =>
m.reifyHint(
RefinementProvider.lengthConstraint[c[k, v]](m.tag.iterator(_).size)
)
case other => other

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd handle the remainder explicitly, like in smithy-playground

}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright 2021-2026 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.dynamic

import smithy4s.Document
import smithy4s.ShapeId
import software.amazon.smithy.model.Model

class DynamicValidationSpec extends DummyIO.Suite {

test("loadModel does not enforce constraint traits by default") {
val decoded = decodeInvalidDocument(performValidation = false)
assert(decoded.isRight, s"Expected decoding to succeed, got: $decoded")
}

test("loadModel(performValidation = true) enforces constraint traits") {
val decoded = decodeInvalidDocument(performValidation = true)
assert(decoded.isLeft, s"Expected decoding to fail, got: $decoded")
}

private def decodeInvalidDocument(performValidation: Boolean) = {

val smithy = """
$version: "2"
namespace example

structure Foo {
bar: ShortString
}

@length(min: 1, max: 3)
string ShortString
"""

val model =
Model
.assembler()
.addUnparsedModel("dynamic.smithy", smithy)
.discoverModels(this.getClass().getClassLoader())
.assemble()
.unwrap()

val index =
DynamicSchemaIndex.loadModel(model, performValidation = performValidation)

val fooShapeId = ShapeId("example", "Foo")

val invalidDocument = Document.obj(
"bar" -> Document.fromString("foobar")
)
val schema = index
.getSchema(fooShapeId)
.getOrElse(fail("Error: shape missing"))
Document.Decoder.fromSchema(schema).decode(invalidDocument)
}

}
Loading