Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UrlForm and AWS query support #1101

Closed
wants to merge 6 commits into from
Closed
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
24 changes: 23 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ lazy val allModules = Seq(
decline,
codegenPlugin,
benchmark,
smoking,
protocol,
protocolTests,
`aws-kernel`,
Expand Down Expand Up @@ -840,7 +841,8 @@ lazy val bootstrapped = projectMatrix
) ++ scala3MigrationOption(scalaVersion.value),
libraryDependencies ++=
munitDeps.value ++ Seq(
Dependencies.Cats.core.value % Test
Dependencies.Cats.core.value % Test,
Dependencies.Weaver.cats.value % Test
)
)
.jvmPlatform(allJvmScalaVersions, jvmDimSettings)
Expand Down Expand Up @@ -882,6 +884,26 @@ lazy val benchmark = projectMatrix
.jvmPlatform(List(Scala213), jvmDimSettings)
.settings(Smithy4sBuildPlugin.doNotPublishArtifact)

lazy val smoking = projectMatrix
.in(file("modules/smoking"))
.dependsOn(`aws-http4s`)
.settings(
Compile / allowedNamespaces := Seq(
"com.amazonaws.cloudwatch"
),
genSmithy(Compile),
// Ignore deprecation warnings here - it's all generated code, anyway.
scalacOptions ++= Seq(
"-Wconf:cat=deprecation:silent"
) ++ scala3MigrationOption(scalaVersion.value),
smithy4sDependencies +=
"com.disneystreaming.smithy" % "aws-cloudwatch-spec" % "2023.02.10",
libraryDependencies += Dependencies.Http4s.emberClient.value,
run / fork := true
)
.jvmPlatform(List(Scala213), jvmDimSettings)
.settings(Smithy4sBuildPlugin.doNotPublishArtifact)

def genSmithy(config: Configuration) = Def.settings(
Seq(
config / sourceGenerators := Seq(genSmithyScala(config).taskValue),
Expand Down
5 changes: 3 additions & 2 deletions modules/aws-http4s/src/smithy4s/aws/AwsClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,14 @@ object AwsClient {
case AwsProtocol.AWS_JSON_1_1(_) =>
AwsJsonCodecs.make[F]("application/x-amz-json-1.1")

case AwsProtocol.AWS_QUERY(_) =>
AwsQueryCodecs.make[F](version = service.version)

case AwsProtocol.AWS_REST_JSON_1(_) =>
AwsRestJsonCodecs.make[F]("application/json")

case AwsProtocol.AWS_REST_XML(_) =>
AwsXmlCodecs.make[F]()

case _ => ???
}
service.functorInterpreter {
new service.FunctorEndpointCompiler[F] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ class AwsCredentialsProvider[F[_]](implicit F: Temporal[F]) {
Resource
.eval(fromEnv)
.map(F.pure)
// TODO: Ensure minimal delay when these endpoints don't exist, e.g.
// when running locally.
Comment on lines +65 to +66
Copy link
Member

Choose a reason for hiding this comment

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

related: #1075

.orElse(refreshing(fromECS(httpClient)))
.orElse(refreshing(fromEC2(httpClient)))
.orElse(Resource.eval(_fromDisk).map(F.pure))
Expand Down Expand Up @@ -104,7 +106,7 @@ class AwsCredentialsProvider[F[_]](implicit F: Temporal[F]) {
}
}

private def getProfileFromEnv: Option[String] =
def getProfileFromEnv: Option[String] =
SysEnv.envValue(AWS_PROFILE).toOption

val AWS_CONTAINER_CREDENTIALS_RELATIVE_URI =
Expand Down
262 changes: 262 additions & 0 deletions modules/aws-http4s/src/smithy4s/aws/internals/AwsQueryCodecs.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/*
* Copyright 2023 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
package aws
package internals

import cats.effect.Concurrent
import cats.syntax.all._
import fs2.compression.Compression
import smithy4s.Endpoint
import smithy4s.schema.CachedSchemaCompiler
import org.http4s.EntityDecoder
import smithy4s.xml.XmlDocument
import org.http4s.MediaRange
import fs2.data.xml._
import fs2.data.xml.dom._
// import smithy4s.codecs._
import smithy4s.http._
// import smithy4s.http.internals._
import smithy4s.http4s.kernel._
import cats.data.EitherT
import smithy4s.kinds.PolyFunction
import org.http4s.EntityEncoder
import smithy4s.capability.Covariant
import _root_.aws.protocols.AwsQueryError

import smithy4s.http.Metadata

private[aws] object AwsQueryCodecs {

def make[F[_]: Concurrent: Compression](
version: String
): UnaryClientCodecs.Make[F] =
new UnaryClientCodecs.Make[F] {
def apply[I, E, O, SI, SO](
endpoint: Endpoint.Base[I, E, O, SI, SO]
): UnaryClientCodecs[F, I, E, O] = {

val stringAndBlobsEntityDecoders =
smithy4s.http.StringAndBlobCodecs.ReaderCompiler
.mapK(
Covariant.liftPolyFunction[Option](
EntityDecoders.fromHttpMediaReaderK[F]
)
)

val urlFormEntityEncoders: CachedSchemaCompiler[EntityEncoder[F, *]] =
UrlForm.Encoder
.mapK(
new PolyFunction[UrlForm.Encoder, EntityEncoder[F, *]] {
def apply[A](fa: UrlForm.Encoder[A]): EntityEncoder[F, A] =
urlFormEntityEncoder[F].contramap(fa.encode)
}
)
.contramapSchema(
smithy4s.schema.Schema.transformHintsLocallyK(
_ ++ smithy4s.Hints(
smithy4s.http.UrlForm.Action(endpoint.id.name),
smithy4s.http.UrlForm.Version(version)
)
)
)

// TODO: Not needed here, but will be needed for an OAuth server
// val urlFormEntityDecoders: CachedSchemaCompiler[EntityDecoder[F, *]] =
// UrlForm.Decoder.mapK(
// new PolyFunction[UrlForm.Decoder, EntityDecoder[F, *]] {
// def apply[A](
// fa: UrlForm.Decoder[A]
// ): EntityDecoder[F, A] =
// urlFormEntityDecoder[F].flatMapR(urlForm =>
// EitherT.liftF {
// fa.decode(urlForm)
// .leftMap(fromUrlFormToHttpError)
// .liftTo[F]
// }
// )
// }
// )

val xmlEntityDecoders: CachedSchemaCompiler[EntityDecoder[F, *]] =
XmlDocument.Decoder.mapK(
new PolyFunction[XmlDocument.Decoder, EntityDecoder[F, *]] {
def apply[A](
fa: XmlDocument.Decoder[A]
): EntityDecoder[F, A] =
xmlEntityDecoder[F].flatMapR(xmlDocument =>
EitherT.liftF {
fa.decode(xmlDocument)
.leftMap(fromXmlToHttpError)
.liftTo[F]
}
)
}
)

val decoders = CachedSchemaCompiler.getOrElse(
stringAndBlobsEntityDecoders,
xmlEntityDecoders
)

val restEncoders =
RequestEncoder
.restSchemaCompiler[F](
Metadata.AwsEncoder,
urlFormEntityEncoders,
writeEmptyStructs = true
)

val restDecoders =
ResponseDecoder.restSchemaCompiler(Metadata.AwsDecoder, decoders)

val responseTag = endpoint.name + "Response"
val resultTag = endpoint.name + "Result"
val successDecoders = restDecoders.contramapSchema(
smithy4s.schema.Schema.transformHintsLocallyK(
_ ++ smithy4s.Hints(
smithy4s.xml.internals.XmlStartingPath(
List(responseTag, resultTag)
)
)
)
)

val errorDecoders = restDecoders.contramapSchema(
smithy4s.schema.Schema.transformHintsLocallyK(
_ ++ smithy4s.Hints(
smithy4s.xml.internals.XmlStartingPath(
List("ErrorResponse", "Error")
)
)
)
)

// Takes the `@awsQueryError` trait into consideration to decide
// how to discriminate error responses.
val errorNameMapping: String => String = {
endpoint.errorable match {
case None => identity[String]
case Some(err) =>
val mapping = err.error.alternatives.flatMap { alt =>
val shapeName = alt.schema.shapeId.name
alt.hints.get(AwsQueryError).map(_.code).map(_ -> shapeName)
}.toMap
(errorCode: String) => mapping.getOrElse(errorCode, errorCode)
}
}

val discriminator = AwsErrorTypeDecoder
.fromResponse(errorDecoders)
.andThen(_.map(_.map {
case HttpDiscriminator.NameOnly(name) =>
HttpDiscriminator.NameOnly(errorNameMapping(name))
case other => other
}))

val transformEncoders =
applyCompression[F](endpoint.hints, retainUserEncoding = false)
val finalEncoders = transformEncoders(restEncoders)

val make =
UnaryClientCodecs
.Make[F](
finalEncoders,
successDecoders,
errorDecoders,
discriminator
)
make.apply(endpoint)
}
}

private def xmlEntityDecoder[F[_]: Concurrent]
: EntityDecoder[F, XmlDocument] =
EntityDecoder.decodeBy(
MediaRange.parse("application/xml").getOrElse(sys.error("TODO"))
) { media =>
EitherT.liftF(
media.body
.through(fs2.text.utf8.decode[F])
.through(events[F, String]())
.through(referenceResolver())
.through(normalize)
.through(documents[F, XmlDocument])
.head
.compile
.last
.map(
_.getOrElse(
// TODO: This isn't right
XmlDocument(
XmlDocument.XmlElem(
XmlDocument.XmlQName(None, "Unit"),
List.empty,
List.empty
)
)
)
)
)
}

// TODO: Not needed here, but will be needed for an OAuth server
// private def urlFormEntityDecoder[F[_]: Concurrent]: EntityDecoder[F, UrlForm] = EntityDecoders.fromHttpMediaReader(
// HttpMediaTyped(
// HttpMediaType("application/x-www-form-urlencoded"),
// // TODO: Avoid going to string and back
// // TODO: Make error mapping consistent with elsewhere
// blob => UrlFormParser.parseUrlForm(blob.toUTF8String).left.map(parseFailure =>
// HttpPayloadError(
// PayloadPath.root,
// "",
// parseFailure.message
// )
// )
// )
// )

private def urlFormEntityEncoder[F[_]: Concurrent]
: EntityEncoder[F, UrlForm] = EntityEncoders.fromHttpMediaWriter(
HttpMediaTyped(
HttpMediaType("application/x-www-form-urlencoded"),
(_: Any, urlForm: UrlForm) => Blob(urlForm.values.render)
)
)

private def fromXmlToHttpError(
xmlDecodeError: smithy4s.xml.XmlDecodeError
): smithy4s.http.HttpContractError = {
smithy4s.http.HttpPayloadError(
xmlDecodeError.path.toPayloadPath,
"",
xmlDecodeError.message
)
}

// TODO: Not needed here, but will be needed for an OAuth server
// private def fromUrlFormToHttpError(
// urlFormDecodeError: smithy4s.http.UrlFormDecodeError
// ): smithy4s.http.HttpContractError = {
// smithy4s.http.HttpPayloadError(
// urlFormDecodeError.path,
// "",
// urlFormDecodeError.message
// )
// }

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@
package smithy4s.aws
package internals

import java.net.URLEncoder
import smithy4s._
import org.http4s._
import org.http4s.client.Client
import cats.effect.Resource
import smithy4s.aws.kernel.AwsCrypto._
import smithy4s.http.internals.URIEncoderDecoder.{encode => uriEncode}
import cats.effect.Concurrent
import fs2.Chunk
import cats.syntax.all._
Expand Down Expand Up @@ -101,7 +101,7 @@ private[aws] object AwsSigningClient {
else
queryParams
.map { case (k, v) =>
uriEncode(k) + "=" + uriEncode(v)
URLEncoder.encode(k, "UTF8") + "=" + URLEncoder.encode(v, "UTF8")
}
.mkString("&")

Expand Down
6 changes: 4 additions & 2 deletions modules/aws-http4s/src/smithy4s/aws/internals/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ package object internals {
CachedSchemaCompiler[RequestEncoder[F, *]]

private[internals] def applyCompression[F[_]: Compression](
hints: Hints
hints: Hints,
retainUserEncoding: Boolean = true
): RequestEncoderCompiler[F] => RequestEncoderCompiler[F] = {
val compression = smithy4s.http4s.kernel.GzipRequestCompression[F]()
val compression =
smithy4s.http4s.kernel.GzipRequestCompression[F](retainUserEncoding)
import smithy4s.codecs.Writer
hints.get(smithy.api.RequestCompression) match {
case Some(rc) if rc.encodings.contains("gzip") =>
Expand Down
Loading
Loading