diff --git a/build.sbt b/build.sbt index bee5da4b5..52027cac4 100644 --- a/build.sbt +++ b/build.sbt @@ -740,6 +740,11 @@ lazy val complianceTests = projectMatrix Dependencies.Pprint.core.value ) }, + moduleName := { + if (virtualAxes.value.contains(CatsEffect2Axis)) + moduleName.value + "-ce2" + else moduleName.value + }, Test / smithySpecs := Seq( (ThisBuild / baseDirectory).value / "sampleSpecs" / "test.smithy" ), diff --git a/modules/compliance-tests/src/smithy4s/compliancetests/internals/Assertions.scala b/modules/compliance-tests/src/smithy4s/compliancetests/internals/Assertions.scala index 3361c5697..7886ec62b 100644 --- a/modules/compliance-tests/src/smithy4s/compliancetests/internals/Assertions.scala +++ b/modules/compliance-tests/src/smithy4s/compliancetests/internals/Assertions.scala @@ -31,13 +31,22 @@ private[internals] object assert { private def isJson(bodyMediaType: Option[String]) = bodyMediaType.exists(_.equalsIgnoreCase("application/json")) - private def jsonEql(a: String, b: String): ComplianceResult = { - (parse(a), parse(b)) match { - case (Right(a), Right(b)) if a == b => success - case (Left(a), Left(b)) => fail(s"Both JSONs are invalid: $a, $b") - case (Left(a), _) => fail(s"First JSON is invalid: $a") - case (_, Left(b)) => fail(s"Second JSON is invalid: $b") - case (Right(a), Right(b)) => fail(s"JSONs are not equal: $a, $b") + private def jsonEql(expected: String, actual: String): ComplianceResult = { + (expected.isEmpty, actual.isEmpty) match { + case (true, true) => success + case (true, false) => fail(s"Expected empty body, but got $actual") + case (false, true) => fail(s"Expected $expected, but got empty body") + case (false, false) => + (parse(expected), parse(actual)) match { + case (Right(a), Right(b)) if a == b => success + case (Left(a), Left(b)) => fail(s"Both JSONs are invalid: $a, $b") + case (Left(a), _) => + fail(s"Expected JSON is invalid: $expected \n Error $a ") + case (_, Left(b)) => + fail(s"Actual JSON is invalid: $actual \n Error $b") + case (Right(a), Right(b)) => + fail(s"JSONs are not equal: expected json: $a \n actual json: $b") + } } } @@ -51,18 +60,6 @@ private[internals] object assert { } } - def bodyEql[A]( - expected: A, - actual: A, - bodyMediaType: Option[String] - ): ComplianceResult = { - if (isJson(bodyMediaType)) { - jsonEql(expected.toString, actual.toString) - } else { - eql(expected, actual) - } - } - def bodyEql( expected: String, actual: String, diff --git a/modules/compliance-tests/src/smithy4s/compliancetests/internals/ClientHttpComplianceTestCase.scala b/modules/compliance-tests/src/smithy4s/compliancetests/internals/ClientHttpComplianceTestCase.scala index c65f48277..30ddcdd76 100644 --- a/modules/compliance-tests/src/smithy4s/compliancetests/internals/ClientHttpComplianceTestCase.scala +++ b/modules/compliance-tests/src/smithy4s/compliancetests/internals/ClientHttpComplianceTestCase.scala @@ -17,8 +17,6 @@ package smithy4s.compliancetests package internals -import java.nio.charset.StandardCharsets - import cats.implicits._ import org.http4s.headers.`Content-Type` import org.http4s.HttpApp @@ -26,7 +24,6 @@ import org.http4s.Request import org.http4s.Response import org.http4s.Status import org.http4s.Uri -import org.typelevel.ci.CIString import smithy.test._ import smithy4s.compliancetests.ComplianceTest.ComplianceResult import smithy4s.http.CodecAPI @@ -37,7 +34,7 @@ import smithy4s.Service import scala.concurrent.duration._ import smithy4s.http.HttpMediaType import org.http4s.MediaType -import org.http4s.Header +import org.http4s.Headers private[compliancetests] class ClientHttpComplianceTestCase[ F[_], @@ -70,23 +67,12 @@ private[compliancetests] class ClientHttpComplianceTestCase[ .withPath( Uri.Path.unsafeFromString(testCase.uri) ) - .withQueryParams( - testCase.queryParams.combineAll.map { - _.split("=", 2) match { - case Array(k, v) => - ( - k, - Uri.decode( - toDecode = v, - charset = StandardCharsets.UTF_8, - plusIsSpace = true - ) - ) - } - }.toMap + .withMultiValueQueryParams( + parseQueryParams(testCase.queryParams) ) - val uriAssert = assert.eql(expectedUri, request.uri) + val uriAssert = + assert.eql(expectedUri.renderString, request.uri.renderString) val methodAssert = assert.eql( testCase.method.toLowerCase(), request.method.name.toLowerCase() @@ -107,7 +93,7 @@ private[compliancetests] class ClientHttpComplianceTestCase[ ): ComplianceTest[F] = { type R[I_, E_, O_, SE_, SO_] = F[O_] - val revisedSchema = mapAllTimestampsToEpoch(endpoint.input) + val revisedSchema = mapAllTimestampsToEpoch(endpoint.input.awsHintMask) val inputFromDocument = Document.Decoder.fromSchema(revisedSchema) ComplianceTest[F]( name = endpoint.id.toString + "(client|request): " + testCase.id, @@ -165,7 +151,7 @@ private[compliancetests] class ClientHttpComplianceTestCase[ ComplianceTest[F]( name = endpoint.id.toString + "(client|response): " + testCase.id, run = { - val revisedSchema = mapAllTimestampsToEpoch(endpoint.output) + val revisedSchema = mapAllTimestampsToEpoch(endpoint.output.awsHintMask) val buildResult: Either[Document => F[Throwable], Document => F[O]] = { errorSchema .toLeft { @@ -200,21 +186,15 @@ private[compliancetests] class ClientHttpComplianceTestCase[ .through(utf8Encode) } .getOrElse(fs2.Stream.empty) - val headers: Seq[Header.ToRaw] = - testCase.headers.toList - .flatMap(_.toList) - .map { case (key, value) => - Header.Raw(CIString(key), value) - } - .map(Header.ToRaw.rawToRaw) - .toSeq + + val headers = Headers( + `Content-Type`(MediaType.unsafeParse(mediaType.value)) + ) ++ parseHeaders(testCase.headers) + req.body.compile.drain.as( Response[F](status) .withBodyStream(body) - .putHeaders(headers: _*) - .putHeaders( - `Content-Type`(MediaType.unsafeParse(mediaType.value)) - ) + .withHeaders(headers) ) } diff --git a/modules/compliance-tests/src/smithy4s/compliancetests/internals/ServerHttpComplianceTestCase.scala b/modules/compliance-tests/src/smithy4s/compliancetests/internals/ServerHttpComplianceTestCase.scala index f8ed24df2..d69e9d896 100644 --- a/modules/compliance-tests/src/smithy4s/compliancetests/internals/ServerHttpComplianceTestCase.scala +++ b/modules/compliance-tests/src/smithy4s/compliancetests/internals/ServerHttpComplianceTestCase.scala @@ -17,8 +17,6 @@ package smithy4s.compliancetests package internals -import java.nio.charset.StandardCharsets - import cats.implicits._ import org.http4s._ import org.http4s.headers.`Content-Type` @@ -52,14 +50,9 @@ private[compliancetests] class ServerHttpComplianceTestCase[ testCase: HttpRequestTestCase ): Request[F] = { val expectedHeaders = - List( - testCase.headers.map(h => - Headers(h.toList.map(a => a: Header.ToRaw): _*) - ), - testCase.bodyMediaType.map(mt => - Headers(`Content-Type`(MediaType.unsafeParse(mt))) - ) - ).foldMap(_.combineAll) + testCase.bodyMediaType + .map(mt => Headers(`Content-Type`(MediaType.unsafeParse(mt)))) + .getOrElse(Headers.empty) ++ parseHeaders(testCase.headers) val expectedMethod = Method .fromString(testCase.method) @@ -69,20 +62,8 @@ private[compliancetests] class ServerHttpComplianceTestCase[ .withPath( Uri.Path.unsafeFromString(testCase.uri).addEndsWithSlash ) - .withQueryParams( - testCase.queryParams.combineAll.map { - _.split("=", 2) match { - case Array(k, v) => - ( - k, - Uri.decode( - toDecode = v, - charset = StandardCharsets.UTF_8, - plusIsSpace = true - ) - ) - } - }.toMap + .withMultiValueQueryParams( + parseQueryParams(testCase.queryParams) ) val body = @@ -103,7 +84,7 @@ private[compliancetests] class ServerHttpComplianceTestCase[ testCase: HttpRequestTestCase ): ComplianceTest[F] = { - val revisedSchema = mapAllTimestampsToEpoch(endpoint.input) + val revisedSchema = mapAllTimestampsToEpoch(endpoint.input.awsHintMask) val inputFromDocument = Document.Decoder.fromSchema(revisedSchema) ComplianceTest[F]( name = endpoint.id.toString + "(server|request): " + testCase.id, @@ -159,7 +140,7 @@ private[compliancetests] class ServerHttpComplianceTestCase[ errorSchema .toLeft { val outputDecoder = Document.Decoder.fromSchema( - mapAllTimestampsToEpoch(endpoint.output) + mapAllTimestampsToEpoch(endpoint.output.awsHintMask) ) (doc: Document) => outputDecoder diff --git a/modules/compliance-tests/src/smithy4s/compliancetests/internals/package.scala b/modules/compliance-tests/src/smithy4s/compliancetests/internals/package.scala index c38134e47..187582789 100644 --- a/modules/compliance-tests/src/smithy4s/compliancetests/internals/package.scala +++ b/modules/compliance-tests/src/smithy4s/compliancetests/internals/package.scala @@ -14,19 +14,83 @@ * limitations under the License. */ -package smithy4s.compliancetests +package smithy4s +package compliancetests -import smithy4s.Hints -import smithy4s.schema.Schema +import org.http4s.{Header, Headers, Uri} +import cats.implicits._ + +import java.nio.charset.StandardCharsets +import scala.collection.immutable.ListMap package object internals { // Due to AWS's usage of integer as the canonical representation of a Timestamp in smithy , we need to provide the decoder with instructions to use a Long instead. // therefore the timestamp type is switched to type epochSeconds: Long // This is just a workaround thats limited to testing scenarios - def mapAllTimestampsToEpoch[A](schema: Schema[A]): Schema[A] = { + private[compliancetests] def mapAllTimestampsToEpoch[A]( + schema: Schema[A] + ): Schema[A] = { schema.transformHintsTransitively(h => h.++(Hints(smithy.api.TimestampFormat.EPOCH_SECONDS.widen)) ) } + + // a HintMask to hold onto hints that are necessary for correct document decoding + private val awsMask = HintMask(IntEnum) + + private[compliancetests] implicit class SchemaOps[A](val schema: Schema[A]) + extends AnyVal { + + def awsHintMask: Schema[A] = + schema.transformHintsTransitively(awsMask.apply) + } + + private def splitQuery(queryString: String): (String, String) = { + queryString.split("=", 2) match { + case Array(k, v) => + ( + k, + Uri.decode( + toDecode = v, + charset = StandardCharsets.UTF_8, + plusIsSpace = true + ) + ) + case Array(k) => (k, "") + } + } + + private[compliancetests] def parseQueryParams( + queryParams: Option[List[String]] + ): ListMap[String, List[String]] = { + queryParams.combineAll + .map(splitQuery) + .foldLeft[ListMap[String, List[String]]](ListMap.empty) { + case (acc, (k, v)) => + acc.get(k) match { + case Some(value) => acc + (k -> (value :+ v)) + case None => acc + (k -> List(v)) + } + } + } + + private[compliancetests] def parseHeaders( + maybeHeaders: Option[Map[String, String]] + ): Headers = + maybeHeaders.fold(Headers.empty)(h => + Headers(h.toList.flatMap(parseSingleHeader).map(a => a: Header.ToRaw): _*) + ) + + // regex for comma not between quotes as quotes can be used to escape commas in headers + private val commaNotBetweenQuotes = ",(?=([^\"]*\"[^\"]*\")*[^\"]*$)" + + private def parseSingleHeader( + kv: (String, String) + ): List[(String, String)] = { + kv match { + case (k, v) => v.split(commaNotBetweenQuotes).toList.map((k, _)) + } + + } } diff --git a/modules/compliance-tests/test/src/smithy4s/compliancetests/WeaverComplianceTest.scala b/modules/compliance-tests/test/src-jvm-js/smithy4s/compliancetests/WeaverComplianceTest.scala similarity index 100% rename from modules/compliance-tests/test/src/smithy4s/compliancetests/WeaverComplianceTest.scala rename to modules/compliance-tests/test/src-jvm-js/smithy4s/compliancetests/WeaverComplianceTest.scala