diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a9316715..2bc49ae0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ These are now fixed in [#1344](https://github.com/disneystreaming/smithy4s/pull/ * In some concurrent scenarios, especially those of concurrent initialization of objects (e.g. tests), your application would previously be at risk of deadlocking due to [#537](https://github.com/disneystreaming/smithy4s/issues/537). This is now fixed by suspending evaluation of hints in companion objects using the `.lazily` construct: see [#1326](https://github.com/disneystreaming/smithy4s/pull/1326). +* Allow to configure how the default values (and nulls for optional fields) are rendered. Fixed in [#1315](https://github.com/disneystreaming/smithy4s/pull/1315) + # 0.18.5 * When encoding to `application/x-www-form-urlencoded`, omit optional fields set to the field's default value. diff --git a/modules/bootstrapped/resources/smithy4s.example.ServiceWithNullsAndDefaults.json b/modules/bootstrapped/resources/smithy4s.example.ServiceWithNullsAndDefaults.json new file mode 100644 index 000000000..a2a62f672 --- /dev/null +++ b/modules/bootstrapped/resources/smithy4s.example.ServiceWithNullsAndDefaults.json @@ -0,0 +1,156 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "ServiceWithNullsAndDefaults", + "version": "1.0.0" + }, + "paths": { + "/operation/{requiredLabel}": { + "post": { + "operationId": "Operation", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "requiredLabel", + "in": "path", + "schema": { + "type": "string", + "default": "required-label-with-default" + }, + "required": true + }, + { + "name": "optional-query", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "optional-query-with-default", + "in": "query", + "schema": { + "type": "string", + "default": "optional-query-with-default" + } + }, + { + "name": "required-query-with-default", + "in": "query", + "schema": { + "type": "string", + "default": "required-query-with-default" + } + }, + { + "name": "optional-header", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "optional-header-with-default", + "in": "header", + "schema": { + "type": "string", + "default": "optional-header-with-default" + } + }, + { + "name": "required-header-with-default", + "in": "header", + "schema": { + "type": "string", + "default": "required-header-with-default" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Operation 200 response", + "headers": { + "optional-header": { + "schema": { + "type": "string" + } + }, + "optional-header-with-default": { + "schema": { + "type": "string", + "default": "optional-header-with-default" + } + }, + "required-header-with-default": { + "schema": { + "type": "string", + "default": "required-header-with-default" + }, + "required": true + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationResponseContent" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "OperationRequestContent": { + "type": "object", + "properties": { + "optional": { + "type": "string" + }, + "optionalWithDefault": { + "type": "string", + "default": "optional-default" + }, + "requiredWithDefault": { + "type": "string", + "default": "required-default" + } + }, + "required": [ + "requiredWithDefault" + ] + }, + "OperationResponseContent": { + "type": "object", + "properties": { + "optional": { + "type": "string" + }, + "optionalWithDefault": { + "type": "string", + "default": "optional-default" + }, + "requiredWithDefault": { + "type": "string", + "default": "required-default" + } + }, + "required": [ + "requiredWithDefault" + ] + } + } + } +} \ No newline at end of file diff --git a/modules/bootstrapped/src/generated/smithy4s/example/OperationInput.scala b/modules/bootstrapped/src/generated/smithy4s/example/OperationInput.scala new file mode 100644 index 000000000..6640a2556 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/OperationInput.scala @@ -0,0 +1,33 @@ +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 + + implicit val schema: Schema[OperationInput] = struct( + 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.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.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")), + string.optional[OperationInput]("optional", _.optional), + string.optional[OperationInput]("optionalHeader", _.optionalHeader).addHints(smithy.api.HttpHeader("optional-header")), + string.optional[OperationInput]("optionalQuery", _.optionalQuery).addHints(smithy.api.HttpQuery("optional-query")), + ){ + OperationInput.apply + }.withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/OperationOutput.scala b/modules/bootstrapped/src/generated/smithy4s/example/OperationOutput.scala new file mode 100644 index 000000000..c059e1ac9 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/OperationOutput.scala @@ -0,0 +1,29 @@ +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 + + implicit val schema: Schema[OperationOutput] = struct( + 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.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")), + string.optional[OperationOutput]("optional", _.optional), + string.optional[OperationOutput]("optionalHeader", _.optionalHeader).addHints(smithy.api.HttpHeader("optional-header")), + ){ + OperationOutput.apply + }.withId(id).addHints(hints) +} diff --git a/modules/bootstrapped/src/generated/smithy4s/example/ServiceWithNullsAndDefaults.scala b/modules/bootstrapped/src/generated/smithy4s/example/ServiceWithNullsAndDefaults.scala new file mode 100644 index 000000000..61fc1b5e3 --- /dev/null +++ b/modules/bootstrapped/src/generated/smithy4s/example/ServiceWithNullsAndDefaults.scala @@ -0,0 +1,85 @@ +package smithy4s.example + +import smithy4s.Endpoint +import smithy4s.Hints +import smithy4s.Schema +import smithy4s.Service +import smithy4s.ShapeId +import smithy4s.Transformation +import smithy4s.kinds.PolyFunction5 +import smithy4s.kinds.toPolyFunction5.const5 +import smithy4s.schema.OperationSchema + +trait ServiceWithNullsAndDefaultsGen[F[_, _, _, _, _]] { + self => + + def operation(input: OperationInput): F[OperationInput, Nothing, OperationOutput, Nothing, Nothing] + + def transform: Transformation.PartiallyApplied[ServiceWithNullsAndDefaultsGen[F]] = Transformation.of[ServiceWithNullsAndDefaultsGen[F]](this) +} + +object ServiceWithNullsAndDefaultsGen extends Service.Mixin[ServiceWithNullsAndDefaultsGen, ServiceWithNullsAndDefaultsOperation] { + + val id: ShapeId = ShapeId("smithy4s.example", "ServiceWithNullsAndDefaults") + val version: String = "1.0.0" + + val hints: Hints = Hints( + alloy.SimpleRestJson(), + ).lazily + + def apply[F[_]](implicit F: Impl[F]): F.type = F + + object ErrorAware { + def apply[F[_, _]](implicit F: ErrorAware[F]): F.type = F + type Default[F[+_, +_]] = Constant[smithy4s.kinds.stubs.Kind2[F]#toKind5] + } + + val endpoints: Vector[smithy4s.Endpoint[ServiceWithNullsAndDefaultsOperation, _, _, _, _, _]] = Vector( + ServiceWithNullsAndDefaultsOperation.Operation, + ) + + def input[I, E, O, SI, SO](op: ServiceWithNullsAndDefaultsOperation[I, E, O, SI, SO]): I = op.input + def ordinal[I, E, O, SI, SO](op: ServiceWithNullsAndDefaultsOperation[I, E, O, SI, SO]): Int = op.ordinal + override def endpoint[I, E, O, SI, SO](op: ServiceWithNullsAndDefaultsOperation[I, E, O, SI, SO]) = op.endpoint + class Constant[P[-_, +_, +_, +_, +_]](value: P[Any, Nothing, Nothing, Nothing, Nothing]) extends ServiceWithNullsAndDefaultsOperation.Transformed[ServiceWithNullsAndDefaultsOperation, P](reified, const5(value)) + type Default[F[+_]] = Constant[smithy4s.kinds.stubs.Kind1[F]#toKind5] + def reified: ServiceWithNullsAndDefaultsGen[ServiceWithNullsAndDefaultsOperation] = ServiceWithNullsAndDefaultsOperation.reified + def mapK5[P[_, _, _, _, _], P1[_, _, _, _, _]](alg: ServiceWithNullsAndDefaultsGen[P], f: PolyFunction5[P, P1]): ServiceWithNullsAndDefaultsGen[P1] = new ServiceWithNullsAndDefaultsOperation.Transformed(alg, f) + def fromPolyFunction[P[_, _, _, _, _]](f: PolyFunction5[ServiceWithNullsAndDefaultsOperation, P]): ServiceWithNullsAndDefaultsGen[P] = new ServiceWithNullsAndDefaultsOperation.Transformed(reified, f) + def toPolyFunction[P[_, _, _, _, _]](impl: ServiceWithNullsAndDefaultsGen[P]): PolyFunction5[ServiceWithNullsAndDefaultsOperation, P] = ServiceWithNullsAndDefaultsOperation.toPolyFunction(impl) + +} + +sealed trait ServiceWithNullsAndDefaultsOperation[Input, Err, Output, StreamedInput, StreamedOutput] { + def run[F[_, _, _, _, _]](impl: ServiceWithNullsAndDefaultsGen[F]): F[Input, Err, Output, StreamedInput, StreamedOutput] + def ordinal: Int + def input: Input + def endpoint: Endpoint[ServiceWithNullsAndDefaultsOperation, Input, Err, Output, StreamedInput, StreamedOutput] +} + +object ServiceWithNullsAndDefaultsOperation { + + object reified extends ServiceWithNullsAndDefaultsGen[ServiceWithNullsAndDefaultsOperation] { + def operation(input: OperationInput): Operation = Operation(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 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) + def ordinal: Int = 0 + def endpoint: smithy4s.Endpoint[ServiceWithNullsAndDefaultsOperation,OperationInput, Nothing, OperationOutput, Nothing, Nothing] = Operation + } + 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) + .withHints(smithy.api.Http(method = smithy.api.NonEmptyString("POST"), uri = smithy.api.NonEmptyString("/operation/{requiredLabel}"), code = 200)) + def wrap(input: OperationInput): Operation = Operation(input) + } +} + diff --git a/modules/bootstrapped/src/generated/smithy4s/example/package.scala b/modules/bootstrapped/src/generated/smithy4s/example/package.scala index 7612e683e..900b37050 100644 --- a/modules/bootstrapped/src/generated/smithy4s/example/package.scala +++ b/modules/bootstrapped/src/generated/smithy4s/example/package.scala @@ -3,6 +3,8 @@ package smithy4s package object example { type ErrorHandlingService[F[_]] = smithy4s.kinds.FunctorAlgebra[ErrorHandlingServiceGen, F] val ErrorHandlingService = ErrorHandlingServiceGen + type ServiceWithNullsAndDefaults[F[_]] = smithy4s.kinds.FunctorAlgebra[ServiceWithNullsAndDefaultsGen, F] + val ServiceWithNullsAndDefaults = ServiceWithNullsAndDefaultsGen @deprecated(message = "N/A", since = "N/A") type DeprecatedService[F[_]] = smithy4s.kinds.FunctorAlgebra[DeprecatedServiceGen, F] val DeprecatedService = DeprecatedServiceGen diff --git a/modules/bootstrapped/test/src/smithy4s/DocumentSpec.scala b/modules/bootstrapped/test/src/smithy4s/DocumentSpec.scala index fa7fd29ed..93f85b226 100644 --- a/modules/bootstrapped/test/src/smithy4s/DocumentSpec.scala +++ b/modules/bootstrapped/test/src/smithy4s/DocumentSpec.scala @@ -21,6 +21,7 @@ import smithy.api.Default import smithy4s.example.IntList import alloy.Discriminated import munit._ +import smithy4s.example.OperationOutput class DocumentSpec() extends FunSuite { @@ -395,4 +396,148 @@ class DocumentSpec() extends FunSuite { expect.same(encoded, Document.obj()) } + test("document encoder - all default") { + val result = Document.Encoder + .fromSchema(OperationOutput.schema) + .encode(OperationOutput()) + + expect.same( + Document.obj( + "requiredWithDefault" -> Document.fromString("required-default"), + "requiredHeaderWithDefault" -> Document.fromString( + "required-header-with-default" + ) + ), + result + ) + + } + + test( + "document encoder - all default values + explicit defaults encoding = true" + ) { + val result = Document.Encoder + .withExplicitDefaultsEncoding(true) + .fromSchema( + OperationOutput.schema + ) + .encode(OperationOutput()) + expect.same( + Document.obj( + "optional" -> Document.nullDoc, + "optionalHeader" -> Document.nullDoc, + "optionalWithDefault" -> Document.fromString("optional-default"), + "requiredWithDefault" -> Document.fromString("required-default"), + "optionalHeaderWithDefault" -> Document.fromString( + "optional-header-with-default" + ), + "requiredHeaderWithDefault" -> Document.fromString( + "required-header-with-default" + ) + ), + result + ) + } + + test( + "document encoder - all default values + explicit defaults encoding = false" + ) { + val result = Document.Encoder + .withExplicitDefaultsEncoding(false) + .fromSchema( + OperationOutput.schema + ) + .encode(OperationOutput()) + expect.same( + Document.obj( + "requiredWithDefault" -> Document.fromString("required-default"), + "requiredHeaderWithDefault" -> Document.fromString( + "required-header-with-default" + ) + ), + result + ) + } + + test( + "document encoder - default values overrides + explicit defaults encoding = true" + ) { + val result = Document.Encoder + .withExplicitDefaultsEncoding(true) + .fromSchema(OperationOutput.schema) + .encode( + OperationOutput( + optional = Some("optional-override"), + optionalWithDefault = "optional-default-override", + requiredWithDefault = "required-default-override", + optionalHeader = Some("optional-header-override"), + optionalHeaderWithDefault = "optional-header-with-default-override", + requiredHeaderWithDefault = "required-header-with-default-override" + ) + ) + + expect.same( + Document.obj( + "optional" -> Document.fromString("optional-override"), + "optionalWithDefault" -> Document.fromString( + "optional-default-override" + ), + "requiredWithDefault" -> Document.fromString( + "required-default-override" + ), + "optionalHeader" -> Document.fromString( + "optional-header-override" + ), + "optionalHeaderWithDefault" -> Document.fromString( + "optional-header-with-default-override" + ), + "requiredHeaderWithDefault" -> Document.fromString( + "required-header-with-default-override" + ) + ), + result + ) + + } + test( + "document encoder - default values overrides + explicit defaults encoding = false" + ) { + val result = Document.Encoder + .withExplicitDefaultsEncoding(false) + .fromSchema(OperationOutput.schema) + .encode( + OperationOutput( + optional = Some("optional-override"), + optionalWithDefault = "optional-default-override", + requiredWithDefault = "required-default-override", + optionalHeader = Some("optional-header-override"), + optionalHeaderWithDefault = "optional-header-with-default-override", + requiredHeaderWithDefault = "required-header-with-default-override" + ) + ) + + expect.same( + Document.obj( + "optional" -> Document.fromString("optional-override"), + "optionalWithDefault" -> Document.fromString( + "optional-default-override" + ), + "requiredWithDefault" -> Document.fromString( + "required-default-override" + ), + "optionalHeader" -> Document.fromString( + "optional-header-override" + ), + "optionalHeaderWithDefault" -> Document.fromString( + "optional-header-with-default-override" + ), + "requiredHeaderWithDefault" -> Document.fromString( + "required-header-with-default-override" + ) + ), + result + ) + + } + } diff --git a/modules/core/src/smithy4s/Document.scala b/modules/core/src/smithy4s/Document.scala index e9e362ec3..e3e150ed8 100644 --- a/modules/core/src/smithy4s/Document.scala +++ b/modules/core/src/smithy4s/Document.scala @@ -98,7 +98,19 @@ object Document { def encode(a: A): Document } - object Encoder extends CachedSchemaCompiler.DerivingImpl[Encoder] { + trait EncoderCompiler extends CachedSchemaCompiler[Encoder] { + def withExplicitDefaultsEncoding( + explicitDefaultsEncoding: Boolean + ): EncoderCompiler + } + + object Encoder + extends CachedEncoderCompilerImpl(explicitDefaultsEncoding = false) + + private[smithy4s] class CachedEncoderCompilerImpl( + explicitDefaultsEncoding: Boolean + ) extends CachedSchemaCompiler.DerivingImpl[Encoder] + with EncoderCompiler { protected type Aux[A] = internals.DocumentEncoder[A] @@ -107,7 +119,9 @@ object Document { cache: Cache ): Encoder[A] = { val makeEncoder = - schema.compile(new DocumentEncoderSchemaVisitor(cache)) + schema.compile( + new DocumentEncoderSchemaVisitor(cache, explicitDefaultsEncoding) + ) new Encoder[A] { def encode(a: A): Document = { makeEncoder.apply(a) @@ -115,6 +129,11 @@ object Document { } } + def withExplicitDefaultsEncoding( + explicitDefaultsEncoding: Boolean + ): EncoderCompiler = new CachedEncoderCompilerImpl( + explicitDefaultsEncoding = explicitDefaultsEncoding + ) } type Decoder[A] = diff --git a/modules/core/src/smithy4s/http/Metadata.scala b/modules/core/src/smithy4s/http/Metadata.scala index 0513328d5..bc68a67e0 100644 --- a/modules/core/src/smithy4s/http/Metadata.scala +++ b/modules/core/src/smithy4s/http/Metadata.scala @@ -211,19 +211,41 @@ object Metadata { */ type Encoder[A] = smithy4s.codecs.Encoder[Metadata, A] - object Encoder extends CachedEncoderCompilerImpl(awsHeaderEncoding = false) { + trait EncoderCompiler extends CachedSchemaCompiler[Metadata.Encoder] { + def withExplicitDefaultsEncoding(explicitDefaults: Boolean): EncoderCompiler + } + + object Encoder + extends CachedEncoderCompilerImpl( + awsHeaderEncoding = false, + explicitDefaultsEncoding = false + ) { type Compiler = CachedSchemaCompiler[Encoder] } + private[smithy4s] object AwsEncoder - extends CachedEncoderCompilerImpl(awsHeaderEncoding = true) + extends CachedEncoderCompilerImpl( + awsHeaderEncoding = true, + explicitDefaultsEncoding = false + ) - private[http] class CachedEncoderCompilerImpl(awsHeaderEncoding: Boolean) - extends CachedSchemaCompiler.DerivingImpl[Encoder] { + private[http] class CachedEncoderCompilerImpl( + awsHeaderEncoding: Boolean, + explicitDefaultsEncoding: Boolean + ) extends CachedSchemaCompiler.DerivingImpl[Encoder] + with EncoderCompiler { type Aux[A] = internals.MetaEncode[A] def apply[A](implicit instance: Encoder[A]): Encoder[A] = instance + def withExplicitDefaultsEncoding( + explicitDefaultsEncoding: Boolean + ): EncoderCompiler = new CachedEncoderCompilerImpl( + awsHeaderEncoding = awsHeaderEncoding, + explicitDefaultsEncoding = explicitDefaultsEncoding + ) + def fromSchema[A]( schema: Schema[A], cache: Cache @@ -240,7 +262,8 @@ object Metadata { } val schemaVisitor = new SchemaVisitorMetadataWriter( cache, - commaDelimitedEncoding = awsHeaderEncoding + commaDelimitedEncoding = awsHeaderEncoding, + explicitDefaultsEncoding = explicitDefaultsEncoding ) schemaVisitor(schema) match { case StructureMetaEncode(f) if awsHeaderEncoding => { (a: A) => diff --git a/modules/core/src/smithy4s/http/internals/SchemaVisitorMetadataWriter.scala b/modules/core/src/smithy4s/http/internals/SchemaVisitorMetadataWriter.scala index c9d8348af..4e5d90577 100644 --- a/modules/core/src/smithy4s/http/internals/SchemaVisitorMetadataWriter.scala +++ b/modules/core/src/smithy4s/http/internals/SchemaVisitorMetadataWriter.scala @@ -47,7 +47,8 @@ import java.util.Base64 */ class SchemaVisitorMetadataWriter( val cache: CompilationCache[MetaEncode], - commaDelimitedEncoding: Boolean + commaDelimitedEncoding: Boolean, + explicitDefaultsEncoding: Boolean ) extends SchemaVisitor.Cached[MetaEncode] { self => override def primitive[P]( @@ -169,10 +170,16 @@ class SchemaVisitorMetadataWriter( val encoder = self(field.schema.addHints(Hints(binding))) val updateFunction = encoder.updateMetadata(binding) (metadata, s) => - field.getUnlessDefault(s) match { - case Some(nonDefaultA) => updateFunction(metadata, nonDefaultA) - case None => metadata - } + if (explicitDefaultsEncoding) + field.get(s) match { + case None => metadata + case _ => updateFunction(metadata, field.get(s)) + } + else + field.getUnlessDefault(s) match { + case Some(nonDefaultA) => updateFunction(metadata, nonDefaultA) + case None => metadata + } } } // pull out the query params field as it must be applied last to the metadata diff --git a/modules/core/src/smithy4s/internals/DocumentEncoderSchemaVisitor.scala b/modules/core/src/smithy4s/internals/DocumentEncoderSchemaVisitor.scala index 1d515b440..37204c562 100644 --- a/modules/core/src/smithy4s/internals/DocumentEncoderSchemaVisitor.scala +++ b/modules/core/src/smithy4s/internals/DocumentEncoderSchemaVisitor.scala @@ -74,9 +74,14 @@ object DocumentEncoder { } class DocumentEncoderSchemaVisitor( - val cache: CompilationCache[DocumentEncoder] + val cache: CompilationCache[DocumentEncoder], + val explicitDefaultsEncoding: Boolean ) extends SchemaVisitor.Cached[DocumentEncoder] { self => + + def this(cache: CompilationCache[DocumentEncoder]) = + this(cache, explicitDefaultsEncoding = false) + override def primitive[P]( shapeId: ShapeId, hints: Hints, @@ -197,9 +202,12 @@ class DocumentEncoderSchemaVisitor( .map(_.value) .getOrElse(field.label) (s, builder) => - field.getUnlessDefault(s).foreach { value => - builder.+=(jsonLabel -> encoder.apply(value)) - } + if (explicitDefaultsEncoding) { + builder.+=(jsonLabel -> encoder.apply(field.get(s))) + } else + field.getUnlessDefault(s).foreach { value => + builder.+=(jsonLabel -> encoder.apply(value)) + } } val encoders = fields.map(field => fieldEncoder(field)) diff --git a/modules/docs/markdown/03-protocols/04-simple-rest-json/01-overview.md b/modules/docs/markdown/03-protocols/04-simple-rest-json/01-overview.md index bbeb870ad..1726a52f4 100644 --- a/modules/docs/markdown/03-protocols/04-simple-rest-json/01-overview.md +++ b/modules/docs/markdown/03-protocols/04-simple-rest-json/01-overview.md @@ -88,7 +88,7 @@ See the section about [unions](../../04-codegen/02-unions.md) for a detailed des ## Explicit Null Encoding -By default, optional structure fields that are set to `None` will be excluded from encoded structures. If you wish to change this so that instead they are included and set to `null` explicitly, you can do so by calling `.withExplicitDefaultsEncoding(true)`. +By default, optional properties (headers, query parameters, structure fields) that are set to `None` and optional properties that are set to default value will be excluded during encoding process. If you wish to change this so that instead they are included and set to `null` explicitly, you can do so by calling `.withExplicitDefaultsEncoding(true)`. ## Supported traits diff --git a/modules/http4s/src/smithy4s/http4s/internals/SimpleRestJsonCodecs.scala b/modules/http4s/src/smithy4s/http4s/internals/SimpleRestJsonCodecs.scala index f9988185f..b7f7648fa 100644 --- a/modules/http4s/src/smithy4s/http4s/internals/SimpleRestJsonCodecs.scala +++ b/modules/http4s/src/smithy4s/http4s/internals/SimpleRestJsonCodecs.scala @@ -90,7 +90,9 @@ private[http4s] class SimpleRestJsonCodecs( .withErrorBodyDecoders(payloadDecoders) .withErrorDiscriminator(HttpDiscriminator.fromResponse(errorHeaders, _).pure[F]) .withMetadataDecoders(Metadata.Decoder) - .withMetadataEncoders(Metadata.Encoder) + .withMetadataEncoders( + Metadata.Encoder.withExplicitDefaultsEncoding(explicitDefaultsEncoding) + ) .withBaseRequest(_ => baseRequest.pure[F]) .withRequestMediaType("application/json") .withRequestTransformation(fromSmithy4sHttpRequest[F](_).pure[F]) diff --git a/modules/http4s/test/src/smithy4s/http4s/NullsAndDefaultEncodingSuite.scala b/modules/http4s/test/src/smithy4s/http4s/NullsAndDefaultEncodingSuite.scala new file mode 100644 index 000000000..88f9e29e5 --- /dev/null +++ b/modules/http4s/test/src/smithy4s/http4s/NullsAndDefaultEncodingSuite.scala @@ -0,0 +1,193 @@ +package smithy4s.http4s + +import weaver._ +import org.http4s._ +import org.http4s.implicits._ +import cats.effect.IO +import smithy4s.example.ServiceWithNullsAndDefaults +import smithy4s.example.OperationOutput +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 cats.effect.kernel.Deferred + +object NullsAndDefaultEncodingSuite extends SimpleIOSuite with CirceInstances { + + test("routes - explicit defaults encoding = false") { + runServerTest(explicitDefaults = false).map { response => + assert.same( + Map(ci"required-header-with-default" -> "required-header-with-default"), + response.headers + ) && + assert.same( + Json.obj( + "requiredWithDefault" -> Json.fromString("required-default") + ), + response.body + ) + } + } + + test("routes - explicit defaults encoding = true") { + runServerTest(explicitDefaults = true).map { response => + assert.same( + Map(ci"required-header-with-default" -> "required-header-with-default"), + response.headers + ) && + assert.same( + Json.obj( + "requiredWithDefault" -> Json.fromString("required-default"), + "optionalWithDefault" -> Json.fromString("optional-default"), + "optional" -> Json.Null + ), + response.body + ) + } + } + + test("client - explicit defaults encoding = false") { + runClientTest(explicitDefaults = false, OperationInput()) + .map { request => + assert.same( + Map( + ci"required-header-with-default" -> "required-header-with-default" + ), + request.headers + ) && + assert.same( + Map.empty, + request.query + ) && + assert.same( + List("operation", "required-label-with-default"), + request.labels + ) && + assert.same( + Json.obj( + "requiredWithDefault" -> Json.fromString("required-default") + ), + request.body + ) + } + } + + test("client - explicit defaults encoding = true") { + runClientTest(explicitDefaults = true, OperationInput()) + .map { request => + assert.same( + Map( + ci"optional-header-with-default" -> "optional-header-with-default", + ci"required-header-with-default" -> "required-header-with-default" + ), + request.headers + ) && assert.same( + Map( + "optional-query-with-default" -> "optional-query-with-default", + "required-query-with-default" -> "required-query-with-default" + ), + request.query + ) && assert.same( + List("operation", "required-label-with-default"), + request.labels + ) && assert.same( + Json.obj( + "optional" -> Json.Null, + "optionalWithDefault" -> Json.fromString("optional-default"), + "requiredWithDefault" -> Json.fromString("required-default") + ), + request.body + ) + } + } + + object Impl extends ServiceWithNullsAndDefaults[IO] { + override def operation(input: OperationInput): IO[OperationOutput] = + IO.pure(OperationOutput()) + } + + private val specHeaders = Set( + ci"optional-header", + ci"optional-header-with-default", + ci"required-header-with-default" + ) + + case class TestResponse(headers: Map[CIString, String], body: Json) + + case class TestRequest( + headers: Map[CIString, String], + query: Map[String, String], + labels: List[String], + body: Json + ) + + private def runServerTest(explicitDefaults: Boolean): IO[TestResponse] = { + def run( + routes: HttpRoutes[IO], + req: Request[IO] + ): IO[(Map[CIString, String], Json)] = + routes.orNotFound.run(req).flatMap { response => + response.as[Json].map(headersToMap(response.headers) -> _) + } + SimpleRestJsonBuilder + .withExplicitDefaultsEncoding(explicitDefaults) + .routes(Impl) + .resource + .use { routes => + for { + result <- run( + routes, + Request[IO](method = Method.POST, uri = uri"/operation/label") + ) + (headers, body) = result + } yield TestResponse(headers, body) + } + } + + private def runClientTest( + explicitDefaults: Boolean, + input: OperationInput + ): IO[TestRequest] = { + val resources = for { + promise <- Deferred[IO, Request[IO]].toResource + httpClient: Client[IO] = Client(req => + req + .toStrict(None) + .flatMap(promise.complete) + .as(Response[IO]()) + .toResource + ) + client <- SimpleRestJsonBuilder + .withExplicitDefaultsEncoding(explicitDefaults) + .apply(ServiceWithNullsAndDefaults) + .client(httpClient) + .resource + } yield (promise, client) + resources.use { case (promise, client) => + client.operation(input) >> promise.get.flatMap { req => + val labels = req.uri.path.segments + .map(_.toString) + .toList + req + .as[Json] + .map(body => + TestRequest( + headersToMap(req.headers), + queryToMap(req.uri.query), + labels, + body + ) + ) + } + } + } + + private def headersToMap(headers: Headers) = headers.headers.flatMap { h => + if (specHeaders.contains(h.name)) Some(h.name -> h.value) else None + }.toMap + + private def queryToMap(query: Query) = + query.pairs.flatMap(kv => kv._2.map(kv._1 -> _)).toMap +} diff --git a/sampleSpecs/serviceWithNullsAndDefaults.smithy b/sampleSpecs/serviceWithNullsAndDefaults.smithy new file mode 100644 index 000000000..f4e29ec66 --- /dev/null +++ b/sampleSpecs/serviceWithNullsAndDefaults.smithy @@ -0,0 +1,79 @@ +$version: "2" + +namespace smithy4s.example + +use alloy#simpleRestJson +use smithy4s.meta#packedInputs + +@simpleRestJson +@packedInputs +service ServiceWithNullsAndDefaults { + version: "1.0.0", + operations: [Operation] +} + + +@http(method: "POST", uri: "/operation/{requiredLabel}") +operation Operation { + input := { + optional: String + + @default("optional-default") + optionalWithDefault: String + + @httpLabel + @required + @default("required-label-with-default") + requiredLabel: String + + @default("required-default") + @required + requiredWithDefault: String + + @httpHeader("optional-header") + optionalHeader: String + + @httpHeader("optional-header-with-default") + @default("optional-header-with-default") + optionalHeaderWithDefault: String + + @httpHeader("required-header-with-default") + @required + @default("required-header-with-default") + requiredHeaderWithDefault: String + + @httpQuery("optional-query") + optionalQuery: String + + @httpQuery("optional-query-with-default") + @default("optional-query-with-default") + optionalQueryWithDefault: String + + @httpQuery("required-query-with-default") + @default("required-query-with-default") + requiredQueryWithDefault: String + } + + output := { + optional: String + + @default("optional-default") + optionalWithDefault: String + + @default("required-default") + @required + requiredWithDefault: String + + @httpHeader("optional-header") + optionalHeader: String + + @httpHeader("optional-header-with-default") + @default("optional-header-with-default") + optionalHeaderWithDefault: String + + @httpHeader("required-header-with-default") + @required + @default("required-header-with-default") + requiredHeaderWithDefault: String + } +}