Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ When adding entries, please treat them as if they could end up in a release any
Thank you!

# 0.19.12

- http4s: Fix the configured `FieldFilter` not being applied when a server encodes response metadata (e.g. HTTP headers) in [#1992](https://github.com/disneystreaming/smithy4s/pull/1992). `SimpleRestJsonBuilder.withFieldFilter(...)` now affects response header/metadata encoding on the server, matching the behavior already present on the client request side.
- 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

Expand Down
9 changes: 9 additions & 0 deletions modules/core/src/smithy4s/RefinementProvider.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,28 @@ private[dynamic] trait DynamicSchemaIndexCompanionPlatform {
*/
def loadModel(
model: software.amazon.smithy.model.Model
): DynamicSchemaIndex = loadModel(model, applySchemaRefinements = false)

/**
* Loads a dynamic schema index model from a smithy 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 (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,
applySchemaRefinements: Boolean
): DynamicSchemaIndex = {
val flattenedModel =
ModelTransformer.create().flattenAndRemoveMixins(model);
val node = ModelSerializer.builder().build.serialize(flattenedModel)
val document = NodeToDocument(node)
smithy4s.Document
.decode[smithy4s.dynamic.model.Model](document)
.map(load(_)) match {
.map(load(_, applySchemaRefinements)) match {
case Left(error) => throw error
case Right(value) => value
}
Expand Down
145 changes: 145 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,145 @@
/*
* 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.RefinementProvider
import smithy4s.ShapeId
import smithy4s.schema.EnumValue
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 {
private val schemaMap: Map[ShapeId, Schema[_]] =
index.allSchemas.map(s => s.shapeId -> reifySchema(s)).toMap
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[_]] =
schemaMap.values
def getSchema(shapeId: ShapeId): Option[Schema[_]] =
schemaMap.get(shapeId)
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 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)
)

private def enumSchema[B](
schema: Schema.EnumerationSchema[B]
): Schema[B] = {
val byValue: Map[B, EnumValue[B]] =
schema.values.map(v => v.value -> v).toMap
schema
.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 {
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)
.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[a] =>
enumSchema(e).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)
)
// 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
}
}

}
16 changes: 16 additions & 0 deletions modules/dynamic/src/smithy4s/dynamic/DynamicSchemaIndex.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}

}
Loading