From f3fefe1c31e8dce26ca8d6e745065f9b793054c0 Mon Sep 17 00:00:00 2001 From: msosnicki Date: Tue, 28 Jul 2026 16:04:18 +0200 Subject: [PATCH 1/7] Dynamic schema validation --- .../DynamicSchemaIndexCompanionPlatform.scala | 20 ++- .../src-jvm/DynamicSchemaValidation.scala | 153 ++++++++++++++++++ .../dynamic/DynamicValidationSpec.scala | 70 ++++++++ 3 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 modules/dynamic/src-jvm/DynamicSchemaValidation.scala create mode 100644 modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala diff --git a/modules/dynamic/src-jvm/DynamicSchemaIndexCompanionPlatform.scala b/modules/dynamic/src-jvm/DynamicSchemaIndexCompanionPlatform.scala index 62c327500..16fe89d73 100644 --- a/modules/dynamic/src-jvm/DynamicSchemaIndexCompanionPlatform.scala +++ b/modules/dynamic/src-jvm/DynamicSchemaIndexCompanionPlatform.scala @@ -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`, + * `@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( + model: software.amazon.smithy.model.Model, + performValidation: Boolean ): DynamicSchemaIndex = { val flattenedModel = ModelTransformer.create().flattenAndRemoveMixins(model); @@ -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 } } diff --git a/modules/dynamic/src-jvm/DynamicSchemaValidation.scala b/modules/dynamic/src-jvm/DynamicSchemaValidation.scala new file mode 100644 index 000000000..98e7802af --- /dev/null +++ b/modules/dynamic/src-jvm/DynamicSchemaValidation.scala @@ -0,0 +1,153 @@ +/* + * 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.Surjection +import smithy4s.ShapeId +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 + def allSchemas: Iterable[Schema[_]] = + index.allSchemas.map(reifySchema(_)) + def getSchema(shapeId: ShapeId): Option[Schema[_]] = + index.getSchema(shapeId).map(reifySchema(_)) + 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]( + 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 { + 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) + .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]] + case c @ CollectionSchema(_, _, _, _) => collection(c) + case m: MapSchema[c, k, v] => + m.reifyHint( + RefinementProvider.lengthConstraint[c[k, v]](m.tag.iterator(_).size) + ) + case b: BijectionSchema[?, ?] => b + case r: RefinementSchema[?, ?] => r + case s: StructSchema[?] => s + case l: LazySchema[?] => l + case u: UnionSchema[?] => u + case n: OptionSchema[?, ?] => n + } + } + +} diff --git a/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala b/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala new file mode 100644 index 000000000..5c7b18abb --- /dev/null +++ b/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala @@ -0,0 +1,70 @@ +/* + * 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 { + + 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 fooShapeId = ShapeId("example", "Foo") + + val invalidDocument = Document.obj( + "bar" -> Document.fromString("foobar") + ) + + def decodeInvalidDocument(index: DynamicSchemaIndex) = { + val schema = index + .getSchema(fooShapeId) + .getOrElse(fail("Error: shape missing")) + Document.Decoder.fromSchema(schema).decode(invalidDocument) + } + + test("loadModel does not enforce constraint traits by default") { + val index = DynamicSchemaIndex.loadModel(model) + val decoded = decodeInvalidDocument(index) + assert(decoded.isRight, s"Expected decoding to succeed, got: $decoded") + } + + test("loadModel(performValidation = true) enforces constraint traits") { + val index = DynamicSchemaIndex.loadModel(model, performValidation = true) + val decoded = decodeInvalidDocument(index) + assert(decoded.isLeft, s"Expected decoding to fail, got: $decoded") + } + +} From dd0c5d1704d9595b3aa2e8c9760bb23c2eb926bf Mon Sep 17 00:00:00 2001 From: msosnicki Date: Tue, 28 Jul 2026 16:47:55 +0200 Subject: [PATCH 2/7] Update changelog --- CHANGELOG.md | 1 + .../src-jvm/DynamicSchemaValidation.scala | 7 +- .../dynamic/DynamicValidationSpec.scala | 67 ++++++++++--------- 3 files changed, 36 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41490aae1..190fa6d5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. # 0.19.10 diff --git a/modules/dynamic/src-jvm/DynamicSchemaValidation.scala b/modules/dynamic/src-jvm/DynamicSchemaValidation.scala index 98e7802af..9fa7c70d2 100644 --- a/modules/dynamic/src-jvm/DynamicSchemaValidation.scala +++ b/modules/dynamic/src-jvm/DynamicSchemaValidation.scala @@ -141,12 +141,7 @@ private[dynamic] object DynamicSchemaValidation { m.reifyHint( RefinementProvider.lengthConstraint[c[k, v]](m.tag.iterator(_).size) ) - case b: BijectionSchema[?, ?] => b - case r: RefinementSchema[?, ?] => r - case s: StructSchema[?] => s - case l: LazySchema[?] => l - case u: UnionSchema[?] => u - case n: OptionSchema[?, ?] => n + case other => other } } diff --git a/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala b/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala index 5c7b18abb..86e195e7b 100644 --- a/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala +++ b/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala @@ -22,49 +22,50 @@ import software.amazon.smithy.model.Model class DynamicValidationSpec extends DummyIO.Suite { - val smithy = """ - $version: "2" - namespace example + 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 - } + structure Foo { + bar: ShortString + } - @length(min: 1, max: 3) - string ShortString - """ + @length(min: 1, max: 3) + string ShortString + """ - val model = - Model - .assembler() - .addUnparsedModel("dynamic.smithy", smithy) - .discoverModels(this.getClass().getClassLoader()) - .assemble() - .unwrap() + val model = + Model + .assembler() + .addUnparsedModel("dynamic.smithy", smithy) + .discoverModels(this.getClass().getClassLoader()) + .assemble() + .unwrap() - val fooShapeId = ShapeId("example", "Foo") + val index = + DynamicSchemaIndex.loadModel(model, performValidation = performValidation) - val invalidDocument = Document.obj( - "bar" -> Document.fromString("foobar") - ) + val fooShapeId = ShapeId("example", "Foo") - def decodeInvalidDocument(index: DynamicSchemaIndex) = { + val invalidDocument = Document.obj( + "bar" -> Document.fromString("foobar") + ) val schema = index .getSchema(fooShapeId) .getOrElse(fail("Error: shape missing")) Document.Decoder.fromSchema(schema).decode(invalidDocument) } - test("loadModel does not enforce constraint traits by default") { - val index = DynamicSchemaIndex.loadModel(model) - val decoded = decodeInvalidDocument(index) - assert(decoded.isRight, s"Expected decoding to succeed, got: $decoded") - } - - test("loadModel(performValidation = true) enforces constraint traits") { - val index = DynamicSchemaIndex.loadModel(model, performValidation = true) - val decoded = decodeInvalidDocument(index) - assert(decoded.isLeft, s"Expected decoding to fail, got: $decoded") - } - } From 7b4d055158b0b511c41846ae659990bdb623f01f Mon Sep 17 00:00:00 2001 From: msosnicki Date: Tue, 28 Jul 2026 17:48:41 +0200 Subject: [PATCH 3/7] scalafix --- modules/dynamic/src-jvm/DynamicSchemaValidation.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/dynamic/src-jvm/DynamicSchemaValidation.scala b/modules/dynamic/src-jvm/DynamicSchemaValidation.scala index 9fa7c70d2..a7a48448c 100644 --- a/modules/dynamic/src-jvm/DynamicSchemaValidation.scala +++ b/modules/dynamic/src-jvm/DynamicSchemaValidation.scala @@ -19,8 +19,8 @@ package smithy4s.dynamic import smithy4s.Document import smithy4s.Refinement import smithy4s.RefinementProvider -import smithy4s.Surjection import smithy4s.ShapeId +import smithy4s.Surjection import smithy4s.schema.CollectionTag._ import smithy4s.schema.Primitive._ import smithy4s.schema.Schema From afa2f7a2e3ac717a984c0702da68512143b01f38 Mon Sep 17 00:00:00 2001 From: msosnicki Date: Wed, 29 Jul 2026 07:31:36 +0200 Subject: [PATCH 4/7] Retriger workflows From 356174deefc0e859c2394b173d0be466d0f20ae3 Mon Sep 17 00:00:00 2001 From: msosnicki Date: Wed, 29 Jul 2026 08:34:37 +0200 Subject: [PATCH 5/7] Addressing comments --- CHANGELOG.md | 4 +- .../src/smithy4s/RefinementProvider.scala | 9 ++++ .../DynamicSchemaIndexCompanionPlatform.scala | 9 ++-- .../src-jvm/DynamicSchemaValidation.scala | 48 +++++++------------ .../dynamic/DynamicValidationSpec.scala | 13 +++-- 5 files changed, 43 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 190fa6d5d..0575d9dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,12 @@ When adding entries, please treat them as if they could end up in a release any Thank you! +# 0.19.12 +- dynamic: Add `DynamicSchemaIndex.loadModel(model, applySchemaRefinements: Boolean)` overload. When `applySchemaRefinements = 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. + # 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. # 0.19.10 diff --git a/modules/core/src/smithy4s/RefinementProvider.scala b/modules/core/src/smithy4s/RefinementProvider.scala index d061b7032..063e42a70 100644 --- a/modules/core/src/smithy4s/RefinementProvider.scala +++ b/modules/core/src/smithy4s/RefinementProvider.scala @@ -53,6 +53,15 @@ object RefinementProvider extends LowPriorityImplicits { def patternConstraint[A](getValue: A => String): Simple[Pattern, A] = new PatternConstraint[A](getValue) + def void[C, A]( + 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) + implicit val stringLengthConstraint: Simple[Length, String] = lengthConstraint[String](_.length) diff --git a/modules/dynamic/src-jvm/DynamicSchemaIndexCompanionPlatform.scala b/modules/dynamic/src-jvm/DynamicSchemaIndexCompanionPlatform.scala index 16fe89d73..5085c071a 100644 --- a/modules/dynamic/src-jvm/DynamicSchemaIndexCompanionPlatform.scala +++ b/modules/dynamic/src-jvm/DynamicSchemaIndexCompanionPlatform.scala @@ -27,12 +27,12 @@ private[dynamic] trait DynamicSchemaIndexCompanionPlatform { */ def loadModel( model: software.amazon.smithy.model.Model - ): DynamicSchemaIndex = loadModel(model, performValidation = false) + ): DynamicSchemaIndex = loadModel(model, applySchemaRefinements = false) /** * Loads a dynamic schema index model from a smithy model. * - * @param performValidation when true, constraint traits (`@length`, `@range`, + * @param applySchemaRefinements when true, constraint traits (`@length`, `@range`, * `@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 @@ -40,7 +40,7 @@ private[dynamic] trait DynamicSchemaIndexCompanionPlatform { */ def loadModel( model: software.amazon.smithy.model.Model, - performValidation: Boolean + applySchemaRefinements: Boolean ): DynamicSchemaIndex = { val flattenedModel = ModelTransformer.create().flattenAndRemoveMixins(model); @@ -51,7 +51,8 @@ private[dynamic] trait DynamicSchemaIndexCompanionPlatform { .map(load(_)) match { case Left(error) => throw error case Right(value) => - if (performValidation) DynamicSchemaValidation.reifyConstraints(value) + if (applySchemaRefinements) + DynamicSchemaValidation.reifyConstraints(value) else value } } diff --git a/modules/dynamic/src-jvm/DynamicSchemaValidation.scala b/modules/dynamic/src-jvm/DynamicSchemaValidation.scala index a7a48448c..b474f14a9 100644 --- a/modules/dynamic/src-jvm/DynamicSchemaValidation.scala +++ b/modules/dynamic/src-jvm/DynamicSchemaValidation.scala @@ -17,11 +17,8 @@ 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._ @@ -40,12 +37,14 @@ private[dynamic] object DynamicSchemaValidation { def reifyConstraints(index: DynamicSchemaIndex): DynamicSchemaIndex = new DynamicSchemaIndex { + private val schemaMap: Map[ShapeId, Schema[_]] = + index.allSchemas.map(s => s.shapeId -> reifySchema(s)).toMap def allServices: Iterable[DynamicSchemaIndex.ServiceWrapper] = index.allServices def allSchemas: Iterable[Schema[_]] = - index.allSchemas.map(reifySchema(_)) + schemaMap.values def getSchema(shapeId: ShapeId): Option[Schema[_]] = - index.getSchema(shapeId).map(reifySchema(_)) + schemaMap.get(shapeId) def metadata: Map[String, Document] = index.metadata } @@ -54,37 +53,19 @@ private[dynamic] object DynamicSchemaValidation { private object ReifyConstraints extends (Schema ~> Schema) { - private def void[C, A]( - 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))) + schema.hints + .get(rp.tag) + .fold(schema)(schema.validated(_)(RefinementProvider.void(rp))) } private def collection[C[_], B]( schema: Schema.CollectionSchema[C, B] ): Schema[C[B]] = - schema.tag match { - 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] - ) - } + schema.reifyHint( + RefinementProvider.lengthConstraint[C[B]](schema.tag.iterator(_).size) + ) private def enumSchema[B <: Enum[?]]( schema: Schema.EnumerationSchema[B] @@ -101,6 +82,7 @@ private[dynamic] object DynamicSchemaValidation { case PString => t.reifyHint(RefinementProvider.stringLengthConstraint) .reifyHint(RefinementProvider.stringPatternConstraints) + .reifyHint(RefinementProvider.idRefRefinement) case PByte => schema.reifyHint(RefinementProvider.numericRangeConstraints[Byte]) case PShort => @@ -141,7 +123,13 @@ private[dynamic] object DynamicSchemaValidation { m.reifyHint( RefinementProvider.lengthConstraint[c[k, v]](m.tag.iterator(_).size) ) - case other => other + // explicitly handling each remaining case, in order to get a "missing match" warning if the schema model changes + case b: BijectionSchema[_, _] => b + case r: RefinementSchema[_, _] => r + case s: StructSchema[_] => s + case l: LazySchema[_] => l + case u: UnionSchema[_] => u + case n: OptionSchema[_, _] => n } } diff --git a/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala b/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala index 86e195e7b..9d7b7517d 100644 --- a/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala +++ b/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala @@ -23,16 +23,16 @@ 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) + val decoded = decodeInvalidDocument(applySchemaRefinements = false) assert(decoded.isRight, s"Expected decoding to succeed, got: $decoded") } - test("loadModel(performValidation = true) enforces constraint traits") { - val decoded = decodeInvalidDocument(performValidation = true) + test("loadModel(applySchemaRefinements = true) enforces constraint traits") { + val decoded = decodeInvalidDocument(applySchemaRefinements = true) assert(decoded.isLeft, s"Expected decoding to fail, got: $decoded") } - private def decodeInvalidDocument(performValidation: Boolean) = { + private def decodeInvalidDocument(applySchemaRefinements: Boolean) = { val smithy = """ $version: "2" @@ -55,7 +55,10 @@ class DynamicValidationSpec extends DummyIO.Suite { .unwrap() val index = - DynamicSchemaIndex.loadModel(model, performValidation = performValidation) + DynamicSchemaIndex.loadModel( + model, + applySchemaRefinements = applySchemaRefinements + ) val fooShapeId = ShapeId("example", "Foo") From 93b9371974e7a35c42531603fdd29ca77e95e012 Mon Sep 17 00:00:00 2001 From: msosnicki Date: Wed, 29 Jul 2026 13:22:39 +0200 Subject: [PATCH 6/7] Expand test suite --- .../src-jvm/DynamicSchemaValidation.scala | 25 +- .../dynamic/DynamicValidationSpec.scala | 308 ++++++++++++++++-- 2 files changed, 300 insertions(+), 33 deletions(-) diff --git a/modules/dynamic/src-jvm/DynamicSchemaValidation.scala b/modules/dynamic/src-jvm/DynamicSchemaValidation.scala index b474f14a9..4eacfb515 100644 --- a/modules/dynamic/src-jvm/DynamicSchemaValidation.scala +++ b/modules/dynamic/src-jvm/DynamicSchemaValidation.scala @@ -19,6 +19,7 @@ package smithy4s.dynamic import smithy4s.Document import smithy4s.RefinementProvider import smithy4s.ShapeId +import smithy4s.schema.EnumValue import smithy4s.schema.Primitive._ import smithy4s.schema.Schema import smithy4s.schema.Schema._ @@ -67,13 +68,22 @@ private[dynamic] object DynamicSchemaValidation { RefinementProvider.lengthConstraint[C[B]](schema.tag.iterator(_).size) ) - private def enumSchema[B <: Enum[?]]( + private def enumSchema[B]( schema: Schema.EnumerationSchema[B] - ): Schema[B] = + ): Schema[B] = { + val byValue: Map[B, EnumValue[B]] = + schema.values.map(v => v.value -> v).toMap schema - .reifyHint(RefinementProvider.lengthConstraint[B](_.toString.length)) - .reifyHint(RefinementProvider.rangeConstraint[B, Int](_.ordinal())) - .reifyHint(RefinementProvider.patternConstraint[B](e => e.toString)) + .reifyHint( + RefinementProvider.lengthConstraint[B](byValue(_).stringValue.length) + ) + .reifyHint( + RefinementProvider.rangeConstraint[B, Int](byValue(_).intValue) + ) + .reifyHint( + RefinementProvider.patternConstraint[B](byValue(_).stringValue) + ) + } def apply[A](schema: Schema[A]): Schema[A] = schema match { @@ -115,9 +125,8 @@ private[dynamic] object DynamicSchemaValidation { PLocalTime | PDuration | POffsetDateTime => schema } - case e: EnumerationSchema[?] => - enumSchema(e.asInstanceOf[EnumerationSchema[Enum[?]]]) - .asInstanceOf[Schema[A]] + case e: EnumerationSchema[a] => + enumSchema(e).asInstanceOf[Schema[A]] case c @ CollectionSchema(_, _, _, _) => collection(c) case m: MapSchema[c, k, v] => m.reifyHint( diff --git a/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala b/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala index 9d7b7517d..f273b7b7d 100644 --- a/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala +++ b/modules/dynamic/test/src-jvm/smithy4s/dynamic/DynamicValidationSpec.scala @@ -18,57 +18,315 @@ package smithy4s.dynamic import smithy4s.Document import smithy4s.ShapeId +import smithy4s.schema.Schema import software.amazon.smithy.model.Model class DynamicValidationSpec extends DummyIO.Suite { - test("loadModel does not enforce constraint traits by default") { - val decoded = decodeInvalidDocument(applySchemaRefinements = false) - assert(decoded.isRight, s"Expected decoding to succeed, got: $decoded") + test("shape constraints are enforced") { + val spec = """ + $version: "2" + namespace example + + structure Foo { + bar: ShortString + } + + @length(min: 1, max: 3) + string ShortString + """ + + indexTest( + spec, + ShapeId("example", "Foo"), + Document.obj( + "bar" -> Document.fromString("foobar") + ), + "length required to be >= 1 and <= 3, but was 6" + ) + } - test("loadModel(applySchemaRefinements = true) enforces constraint traits") { - val decoded = decodeInvalidDocument(applySchemaRefinements = true) - assert(decoded.isLeft, s"Expected decoding to fail, got: $decoded") + test("struct-field inline constraint is enforced") { + val smithy = """ + $version: "2" + namespace example + + structure Foo { + @length(min: 1, max: 3) + bar: String + } + """ + + indexTest( + smithy, + ShapeId("example", "Foo"), + Document.obj("bar" -> Document.fromString("foobar")), + "length required to be >= 1 and <= 3, but was 6" + ) } - private def decodeInvalidDocument(applySchemaRefinements: Boolean) = { + test("list member constraint is enforced") { + val smithy = """ + $version: "2" + namespace example + structure Foo { + tags: Tags + } + + list Tags { + @length(min: 1, max: 3) + member: String + } + """ + + indexTest( + smithy, + ShapeId("example", "Foo"), + Document.obj("tags" -> Document.array(Document.fromString("foobar"))), + "length required to be >= 1 and <= 3, but was 6" + ) + } + + test("map key/value constraints are enforced") { val smithy = """ $version: "2" namespace example structure Foo { - bar: ShortString + extra: Extra + } + + map Extra { + @length(min: 2) + key: String + @length(min: 2, max: 10) + value: String + } + """ + + indexTest( + smithy, + ShapeId("example", "Foo"), + Document.obj( + "extra" -> Document.obj("ab" -> Document.fromString("foobarbazqux")) + ), + "length required to be >= 2 and <= 10, but was 12" + ) + } + + test("enum constraint is enforced") { + val smithy = """ + $version: "2" + namespace example + + structure Foo { + @length(min: 2) + letter: Letters + } + + enum Letters { + A + B + C } + """ + + indexTest( + smithy, + ShapeId("example", "Foo"), + Document.obj("letter" -> Document.fromString("A")), + "length required to be >= 2, but was 1" + ) + } + + test( + "a member-level constraint layered on top of a shape-level constraint are both independently enforced" + ) { + val smithy = """ + $version: "2" + namespace example @length(min: 1, max: 3) string ShortString + + structure Foo { + tags: Tags + } + + list Tags { + @pattern("^[a-z]+$") + member: ShortString + } """ + val shapeId = ShapeId("example", "Foo") + def taggedWith(value: String) = + Document.obj("tags" -> Document.array(Document.fromString(value))) + + // violates the shape-level @length + indexTest( + smithy, + shapeId, + taggedWith("abcdef"), + "length required to be >= 1 and <= 3, but was 6" + ) + // violates the member-level @pattern + indexTest( + smithy, + shapeId, + taggedWith("AB"), + "String 'AB' does not match pattern '^[a-z]+$'" + ) + + // satisfies both the shape-level @length and the member-level @pattern, + // regardless of applySchemaRefinements + val valid = decodeAgainstShape( + smithy, + shapeId, + taggedWith("abc"), + applySchemaRefinements = true + ) + assert(valid.isRight, s"Expected decoding to succeed, got: $valid") + } + + test("operation input constraints are enforced") { + val smithy = """ + $version: "2" + namespace example - val model = - Model - .assembler() - .addUnparsedModel("dynamic.smithy", smithy) - .discoverModels(this.getClass().getClassLoader()) - .assemble() - .unwrap() - - val index = - DynamicSchemaIndex.loadModel( - model, - applySchemaRefinements = applySchemaRefinements + service Foo { + operations: [Op] + } + + operation Op { + input: OpInput + } + + structure OpInput { + @length(min: 1, max: 3) + bar: String + } + """ + + indexOperationTest( + smithy, + ShapeId("example", "Foo"), + Document.obj("bar" -> Document.fromString("foobar")), + "length required to be >= 1 and <= 3, but was 6" + ) + } + + private def assertFailsWith( + result: Either[smithy4s.codecs.PayloadError, _], + expectedMessageContains: String + ) = result match { + case Left(error) => + assert( + error.getMessage.contains(expectedMessageContains), + s"Expected error message to contain '$expectedMessageContains', got: ${error.getMessage}" ) + case Right(_) => + fail(s"Expected decoding to fail, got: $result") + } + + private def indexTest( + smithy: String, + shapeId: ShapeId, + document: Document, + expectedMessageContains: String + ) = { + val turnedOn = decodeAgainstShape( + smithy, + shapeId, + document, + applySchemaRefinements = true + ) + assertFailsWith(turnedOn, expectedMessageContains) + val turnedOff = decodeAgainstShape( + smithy, + shapeId, + document, + applySchemaRefinements = false + ) + assert( + turnedOff.isRight, + s"Expected decoding to succeed, got: $turnedOff" + ) + } + + private def indexOperationTest( + smithy: String, + serviceId: ShapeId, + document: Document, + expectedMessageContains: String + ) = { + val turnedOn = decodeAgainstOperationInput( + smithy, + serviceId, + document, + applySchemaRefinements = true + ) + assertFailsWith(turnedOn, expectedMessageContains) + val turnedOff = decodeAgainstOperationInput( + smithy, + serviceId, + document, + applySchemaRefinements = false + ) + assert( + turnedOff.isRight, + s"Expected decoding to succeed, got: $turnedOff" + ) + } - val fooShapeId = ShapeId("example", "Foo") + private def parseSmithy(smithy: String): Model = + Model + .assembler() + .addUnparsedModel("dynamic.smithy", smithy) + .discoverModels(this.getClass().getClassLoader()) + .assemble() + .unwrap() - val invalidDocument = Document.obj( - "bar" -> Document.fromString("foobar") + private def loadIndex( + smithy: String, + applySchemaRefinements: Boolean + ): DynamicSchemaIndex = + DynamicSchemaIndex.loadModel( + parseSmithy(smithy), + applySchemaRefinements = applySchemaRefinements ) + + private def decodeAgainstSchema( + schema: Schema[_], + document: Document + ) = Document.Decoder.fromSchema(schema).decode(document) + + private def decodeAgainstShape( + smithy: String, + shapeId: ShapeId, + document: Document, + applySchemaRefinements: Boolean + ) = { + val index = loadIndex(smithy, applySchemaRefinements) val schema = index - .getSchema(fooShapeId) + .getSchema(shapeId) .getOrElse(fail("Error: shape missing")) - Document.Decoder.fromSchema(schema).decode(invalidDocument) + decodeAgainstSchema(schema, document) + } + + private def decodeAgainstOperationInput( + smithy: String, + serviceId: ShapeId, + document: Document, + applySchemaRefinements: Boolean + ) = { + val index = loadIndex(smithy, applySchemaRefinements) + val service = index + .getService(serviceId) + .getOrElse(fail("Error: service missing")) + val inputSchema = service.service.endpoints.head.input + decodeAgainstSchema(inputSchema, document) } } From 34394382c2af29c703c291d96d2850a80d3a40c7 Mon Sep 17 00:00:00 2001 From: msosnicki Date: Wed, 29 Jul 2026 15:11:04 +0200 Subject: [PATCH 7/7] Move logic to compiler --- .../DynamicSchemaIndexCompanionPlatform.scala | 9 +- .../smithy4s/dynamic/DynamicSchemaIndex.scala | 16 +++ .../internals/ConstraintReification.scala | 123 ++++++++++++++++++ .../internals/DynamicModelCompiler.scala | 54 ++++++-- 4 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 modules/dynamic/src/smithy4s/dynamic/internals/ConstraintReification.scala diff --git a/modules/dynamic/src-jvm/DynamicSchemaIndexCompanionPlatform.scala b/modules/dynamic/src-jvm/DynamicSchemaIndexCompanionPlatform.scala index 5085c071a..09f5b3a7c 100644 --- a/modules/dynamic/src-jvm/DynamicSchemaIndexCompanionPlatform.scala +++ b/modules/dynamic/src-jvm/DynamicSchemaIndexCompanionPlatform.scala @@ -48,12 +48,9 @@ private[dynamic] trait DynamicSchemaIndexCompanionPlatform { val document = NodeToDocument(node) smithy4s.Document .decode[smithy4s.dynamic.model.Model](document) - .map(load(_)) match { - case Left(error) => throw error - case Right(value) => - if (applySchemaRefinements) - DynamicSchemaValidation.reifyConstraints(value) - else value + .map(load(_, applySchemaRefinements)) match { + case Left(error) => throw error + case Right(value) => value } } diff --git a/modules/dynamic/src/smithy4s/dynamic/DynamicSchemaIndex.scala b/modules/dynamic/src/smithy4s/dynamic/DynamicSchemaIndex.scala index f07da282f..9a41bae26 100644 --- a/modules/dynamic/src/smithy4s/dynamic/DynamicSchemaIndex.scala +++ b/modules/dynamic/src/smithy4s/dynamic/DynamicSchemaIndex.scala @@ -47,6 +47,22 @@ object DynamicSchemaIndex extends DynamicSchemaIndexCompanionPlatform { ): DynamicSchemaIndex = internals.Compiler.compile(model) + /** + * Loads the model from a dynamic representation of smithy models + * (typically json blobs). This representation is modelled in smithy itself, + * and code generated by smithy4s. + * + * @param model + * @param applySchemaRefinements when true, constraint traits (`@length`, `@range`, + * `@pattern` etc) are reified into `Schema` objects that get enforced upon + * decoding, instead of being kept as inert hints. + */ + def load( + model: dynamic.model.Model, + applySchemaRefinements: Boolean + ): DynamicSchemaIndex = + internals.Compiler.compile(model, applySchemaRefinements) + /** * A construct that hides the types a service instance works, * virtually turning them into existential types. diff --git a/modules/dynamic/src/smithy4s/dynamic/internals/ConstraintReification.scala b/modules/dynamic/src/smithy4s/dynamic/internals/ConstraintReification.scala new file mode 100644 index 000000000..598e9a8d3 --- /dev/null +++ b/modules/dynamic/src/smithy4s/dynamic/internals/ConstraintReification.scala @@ -0,0 +1,123 @@ +/* + * 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.internals + +import smithy4s.RefinementProvider +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: `DynamicModelCompiler` only attaches them via `addHints`/ + * `addMemberHints`, unlike smithy4s codegen, which reifies such traits into + * `RefinementSchema` wrappers that get enforced upon decoding. + * + * This is a single-node (non-recursive) transform: it only inspects the hints already + * present on the schema it's given. `DynamicModelCompiler` applies it locally, at each + * point a schema's hints have just finished being merged (a shape's own top-level hints, + * or a member/field's merged hints) and are about to be handed to a consumer — composition + * across nesting levels then falls out for free, since a nested schema is already reified + * by the time its parent embeds it. + */ +private[dynamic] object ConstraintReification extends (Schema ~> Schema) { + + 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(_)(RefinementProvider.void(rp))) + } + + private def collection[C[_], B]( + schema: Schema.CollectionSchema[C, B] + ): Schema[C[B]] = + schema.reifyHint( + RefinementProvider.lengthConstraint[C[B]](schema.tag.iterator(_).size) + ) + + def apply[A](schema: Schema[A]): Schema[A] = + schema match { + case t @ PrimitiveSchema(_, _, tag) => + tag match { + case PString => + t.reifyHint(RefinementProvider.stringLengthConstraint) + .reifyHint(RefinementProvider.stringPatternConstraints) + .reifyHint(RefinementProvider.idRefRefinement) + 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[_] => + val byValue = e.values.map(v => v.value -> v).toMap + schema + .reifyHint( + RefinementProvider.lengthConstraint[A]( + byValue(_).stringValue.length + ) + ) + .reifyHint( + RefinementProvider.rangeConstraint[A, Int](byValue(_).intValue) + ) + .reifyHint( + RefinementProvider.patternConstraint[A](byValue(_).stringValue) + ) + case c @ CollectionSchema(_, _, _, _) => collection(c) + case m: MapSchema[c, k, v] => + m.reifyHint( + RefinementProvider.lengthConstraint[c[k, v]](m.tag.iterator(_).size) + ) + // We need to unpack the underlying schema and apply all the hints turning them into refinements. + // This is because, for example in the container context we might have member level hints. + // For example, if we have a list that targets constrained type and adds another constraint on member level + // if we just retrieved a target member it would miss the member level constraint needed for the container case. + case r: RefinementSchema[_, _] => + // Only used in dynamic context, A is fixed to DynData = Any + apply(r.underlying).asInstanceOf[Schema[A]] + case b: BijectionSchema[_, _] => b + case s: StructSchema[_] => s + case l: LazySchema[_] => l + case u: UnionSchema[_] => u + case n: OptionSchema[_, _] => n + } + +} diff --git a/modules/dynamic/src/smithy4s/dynamic/internals/DynamicModelCompiler.scala b/modules/dynamic/src/smithy4s/dynamic/internals/DynamicModelCompiler.scala index a4a9b4709..00075398d 100644 --- a/modules/dynamic/src/smithy4s/dynamic/internals/DynamicModelCompiler.scala +++ b/modules/dynamic/src/smithy4s/dynamic/internals/DynamicModelCompiler.scala @@ -87,6 +87,17 @@ private[dynamic] object Compiler { */ protected[dynamic] def compile( model: Model + ): DynamicSchemaIndex = compile(model, applySchemaRefinements = false) + + /** + * @param knownHints hints supported by the caller. + * @param applySchemaRefinements when true, constraint traits (`@length`, `@range`, + * `@pattern` etc) are reified into `Schema` objects that get enforced upon + * decoding, instead of being kept as inert hints. + */ + protected[dynamic] def compile( + model: Model, + applySchemaRefinements: Boolean ): DynamicSchemaIndex = { val schemaMap = MMap.empty[ShapeId, Eval[Schema[DynData]]] // val endpointMap = MMap.empty[ShapeId, Eval[DynamicEndpoint]] @@ -97,7 +108,8 @@ private[dynamic] object Compiler { model, schemaMap, // endpointMap, - serviceMap + serviceMap, + applySchemaRefinements ) // Loosely inspired by @@ -132,15 +144,16 @@ private[dynamic] object Compiler { } new DynamicSchemaIndexImpl( model.metadata, - serviceMap.toMap.fmap(_.value), - schemaMap.toMap.fmap(_.value) + visitor.exportServices, + visitor.exportSchemas ) } private class CompileVisitor( model: Model, schemaMap: MMap[ShapeId, Eval[Schema[DynData]]], - serviceMap: MMap[ShapeId, Eval[DynamicService]] + serviceMap: MMap[ShapeId, Eval[DynamicService]], + applySchemaRefinements: Boolean ) extends ShapeVisitor.Default[Unit] { private val closureMap: Map[ShapeId, Set[ShapeId]] = model.shapes.collect { @@ -160,8 +173,19 @@ private[dynamic] object Compiler { ) } + private def resolve(schema: Schema[DynData]): Schema[DynData] = + if (applySchemaRefinements) ConstraintReification(schema) else schema + + def exportSchemas: Map[ShapeId, Schema[DynData]] = + schemaMap.toMap.fmap(_.value) + + def exportServices: Map[ShapeId, DynamicService] = + serviceMap.toMap.fmap(_.value) + private def memberSchema(member: MemberShape): Eval[Schema[DynData]] = - schema(member.target).map(_.addMemberHints(allHints(member.traits))) + schema(member.target) + .map(_.addMemberHints(allHints(member.traits))) + .map(resolve) private def allHints(traits: Map[IdRef, Document]): Hints = { val ignoredHints = List(IdRef("smithy.api#enumValue")) @@ -179,10 +203,12 @@ private[dynamic] object Compiler { lSchema: Eval[Schema[A]] ): Unit = { schemaMap += (shapeId -> lSchema.map { sch => - sch - .withId(shapeId) - .addHints(allHints(traits)) - .asInstanceOf[Schema[DynData]] + resolve( + sch + .withId(shapeId) + .addHints(allHints(traits)) + .asInstanceOf[Schema[DynData]] + ) }) } @@ -352,7 +378,7 @@ private[dynamic] object Compiler { } override def setShape(id: ShapeId, shape: SetShape): Unit = - update(id, shape.traits, schema(shape.member.target).map(s => set(s))) + update(id, shape.traits, memberSchema(shape.member).map(s => set(s))) override def mapShape(id: ShapeId, shape: MapShape): Unit = update( @@ -376,7 +402,7 @@ private[dynamic] object Compiler { shape: OperationShape ): Eval[DynamicEndpoint] = { def getSchemaFromId(shapeId: ShapeId): Eval[Schema[DynData]] = - Eval.defer(schemaMap(shapeId)) + Eval.defer(schemaMap(shapeId)).map(resolve) def getSchema(maybeShapeId: Option[IdRef]): Eval[Schema[DynData]] = maybeShapeId @@ -497,7 +523,9 @@ private[dynamic] object Compiler { index: Int ): Eval[Field[DynStruct, DynData]] = { val (label, mShape) = labelledShape - val field = schema(mShape.target) + schema(mShape.target) + .map(_.addMemberHints(allHints(mShape.traits))) + .map(resolve) .map { sch => if (mShape.traits.contains(IdRef("alloy#nullable"))) sch.nullable.asInstanceOf[Schema[DynData]] @@ -512,8 +540,6 @@ private[dynamic] object Compiler { .optional[DynStruct](label, OptionalAccessor(index)) .asInstanceOf[Field[DynStruct, DynData]] } - val memberHints = allHints(mShape.traits) - field.map(_.addHints(memberHints.all.toSeq: _*)) } update( id,