From 10af5213ad1355ef15a0fa162c8d3538fd0d45ed Mon Sep 17 00:00:00 2001 From: Jeremy Smith Date: Tue, 4 Oct 2016 12:44:16 -0700 Subject: [PATCH 01/10] Add authentication framework - Add the ability to place filters before the HTTP service, configurable at the request level - Add the concept of an Authorizer, which is just a filter. - Add some initial Authorizers - Bearer (for pre-shared Bearer tokens) and MAC (for pre-shared MAC tokens) A future PR should contain a full OAuth2 flow implemented in an Authorizer. --- .../src/main/scala/featherbed/Client.scala | 35 +++-- .../scala/featherbed/auth/Authorizer.scala | 6 + .../main/scala/featherbed/auth/OAuth2.scala | 123 ++++++++++++++++++ .../featherbed/request/RequestSyntax.scala | 51 ++++++-- 4 files changed, 195 insertions(+), 20 deletions(-) create mode 100644 featherbed-core/src/main/scala/featherbed/auth/Authorizer.scala create mode 100644 featherbed-core/src/main/scala/featherbed/auth/OAuth2.scala diff --git a/featherbed-core/src/main/scala/featherbed/Client.scala b/featherbed-core/src/main/scala/featherbed/Client.scala index ad6162e..c584e09 100644 --- a/featherbed-core/src/main/scala/featherbed/Client.scala +++ b/featherbed-core/src/main/scala/featherbed/Client.scala @@ -5,17 +5,27 @@ import java.nio.charset.{Charset, StandardCharsets} import com.twitter.finagle._ import com.twitter.finagle.builder.ClientBuilder +import featherbed.auth.Authorizer import http.{Request, RequestBuilder, Response} import shapeless.Coproduct /** * A REST client with a given base URL. */ -class Client( +case class Client( baseUrl: URL, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] = Filter.identity[Request, Response] ) extends request.RequestTypes with request.RequestBuilding { + def addFilter(filter: Filter[Request, Response, Request, Response]): Client = + copy(filters = filter andThen filters) + + def setFilter(filter: Filter[Request, Response, Request, Response]): Client = + copy(filters = filter) + + def authorized(authorizer: Authorizer): Client = setFilter(filters andThen authorizer) + /** * Specify a GET request to be performed against the given resource * @param relativePath The path to the resource, relative to the baseUrl @@ -25,7 +35,8 @@ class Client( GetRequest[Coproduct.`"*/*"`.T]( baseUrl.toURI.resolve(relativePath).toURL, List.empty, - charset + charset, + filters ) /** @@ -38,7 +49,8 @@ class Client( baseUrl.toURI.resolve(relativePath).toURL, None, List.empty, - charset + charset, + filters ) /** @@ -51,7 +63,8 @@ class Client( baseUrl.toURI.resolve(relativePath).toURL, None, List.empty, - charset + charset, + filters ) /** @@ -60,7 +73,7 @@ class Client( * @return A [[HeadRequest]] object, which can further specify and send the request */ def head(relativePath: String): HeadRequest = - HeadRequest(baseUrl.toURI.resolve(relativePath).toURL, List.empty) + HeadRequest(baseUrl.toURI.resolve(relativePath).toURL, List.empty, charset, filters) /** * Specify a DELETE request to be performed against the given resource @@ -68,7 +81,7 @@ class Client( * @return A [[DeleteRequest]] object, which can further specify and send the request */ def delete(relativePath: String): DeleteRequest[Coproduct.`"*/*"`.T] = - DeleteRequest[Coproduct.`"*/*"`.T](baseUrl.toURI.resolve(relativePath).toURL, List.empty) + DeleteRequest[Coproduct.`"*/*"`.T](baseUrl.toURI.resolve(relativePath).toURL, List.empty, charset, filters) /** * Close this client releasing allocated resources. @@ -78,11 +91,11 @@ class Client( protected def clientTransform(client: Http.Client): Http.Client = client - protected def serviceTransform(service: Service[Request, Response]): Service[Request, Response] = service - - protected val client = clientTransform(Client.forUrl(baseUrl)) + protected lazy val client = + clientTransform(Client.forUrl(baseUrl)) - protected[featherbed] val httpClient = serviceTransform(client.newService(Client.hostAndPort(baseUrl))) + protected[featherbed] lazy val httpClient = + client.newService(Client.hostAndPort(baseUrl)) } object Client { diff --git a/featherbed-core/src/main/scala/featherbed/auth/Authorizer.scala b/featherbed-core/src/main/scala/featherbed/auth/Authorizer.scala new file mode 100644 index 0000000..a96c320 --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/auth/Authorizer.scala @@ -0,0 +1,6 @@ +package featherbed.auth + +import com.twitter.finagle.Filter +import com.twitter.finagle.http.{Request, Response} + +trait Authorizer extends Filter[Request, Response, Request, Response] \ No newline at end of file diff --git a/featherbed-core/src/main/scala/featherbed/auth/OAuth2.scala b/featherbed-core/src/main/scala/featherbed/auth/OAuth2.scala new file mode 100644 index 0000000..e4e7044 --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/auth/OAuth2.scala @@ -0,0 +1,123 @@ +package featherbed.auth + +import java.nio.charset.{Charset, StandardCharsets} +import java.security.MessageDigest +import java.time.Instant +import java.util.{Base64, UUID} + +import com.twitter.finagle.Service +import com.twitter.finagle.http.{Request, Response} +import com.twitter.util.Future +import javax.crypto.spec.SecretKeySpec + +object OAuth2 { + + /** + * RFC 6750 - OAuth2 Bearer Token + * https://tools.ietf.org/html/rfc6750 + * + * @param token The OAuth2 Bearer Token + */ + case class Bearer(token: String) extends Authorizer { + def apply( + request: Request, + service: Service[Request, Response] + ): Future[Response] = { + request.authorization = s"Bearer $token" + service(request) + } + } + + /** + * IETF Draft for OAuth2 MAC Tokens + * https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-02 + * + * @param keyIdentifier The MAC Key Identifier + * @param macKey The MAC Secret Key + * @param algorithm The MAC Algorithm (Mac.Sha1 or Mac.SHA256) + * @param ext A function which computes some "extension text" to be covered by the MAC signature + */ + case class Mac( + keyIdentifier: String, + macKey: String, + algorithm: Mac.Algorithm, + ext: Request => Option[String] = (req) => None + ) extends Authorizer { + + import Mac._ + + def apply( + request: Request, + service: Service[Request, Response] + ): Future[Response] = { + val keyBytes = macKey.getBytes(requestCharset(request)) + val timestamp = Instant.now() + val nonce = UUID.randomUUID().toString + val signature = sign( + keyBytes, algorithm, request, timestamp, nonce, ext + ) + val authFields = List( + "id" -> keyIdentifier, + "timestamp" -> timestamp.getEpochSecond.toString, + "nonce" -> nonce, + "mac" -> Base64.getEncoder.encodeToString(signature) + ) ++ List(ext(request).map("ext" -> _)).flatten + + val auth = "MAC " + authFields.map { + case (key, value) => s""""$key"="$value"""" + }.mkString(", ") + request.authorization = auth + service(request) + } + } + + object Mac { + sealed trait Algorithm { + def name: String + } + case object Sha1 extends Algorithm { val name = "HmacSHA1" } + case object Sha256 extends Algorithm { val name = "HmacSHA256" } + + private def requestCharset(request: Request) = + request.charset.map(Charset.forName).getOrElse(StandardCharsets.UTF_8) + + private def sign( + key: Array[Byte], + algorithm: Mac.Algorithm, + request: Request, + timestamp: Instant, + nonce: String, + ext: Request => Option[String] + ) = { + val stringToSign = normalizedRequestString(request, timestamp, nonce, ext) + val signingKey = new SecretKeySpec(key, algorithm.name) + val mac = javax.crypto.Mac.getInstance(algorithm.name) + mac.init(signingKey) + mac.doFinal(stringToSign.getBytes(requestCharset(request))) + } + + private def normalizedRequestString( + request: Request, + timestamp: Instant, + nonce: String, + ext: Request => Option[String] + ) = { + val hostAndPort = request.host.map(_.span(_ == ':')).map { + case (h, p) => h -> Option(p.stripPrefix(":")).filter(_.nonEmpty) + } + val host = hostAndPort.map(_._1) + val port = hostAndPort.flatMap(_._2) + Seq( + timestamp.getEpochSecond.toString, + nonce, + request.method.toString().toUpperCase, + request.uri, + host.getOrElse(""), + port.getOrElse(request.remotePort.toString), + ext(request).getOrElse(""), + "" + ).mkString("\n") + } + } + +} diff --git a/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala b/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala index 7937c7b..eb8924a 100644 --- a/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala +++ b/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala @@ -9,6 +9,7 @@ import scala.language.experimental.macros import cats.data._, Validated._ import cats.implicits._ import cats.instances.list._ +import com.twitter.finagle.Filter import com.twitter.finagle.http._ import com.twitter.finagle.http.Status._ import com.twitter.util.Future @@ -37,11 +38,15 @@ trait RequestTypes { self: Client => val url: URL val charset: Charset val headers: List[(String, String)] + val filters: Filter[Request, Response, Request, Response] def withHeader(name: String, value: String): Self = withHeaders((name, value)) def withHeaders(headers: (String, String)*): Self def withCharset(charset: Charset): Self def withUrl(url: URL): Self + def addFilter(filter: Filter[Request, Response, Request, Response]): Self + def resetFilters: Self + def setFilters(filter: Filter[Request, Response, Request, Response]): Self = resetFilters.addFilter(filter) def withQuery(query: String): Self = withUrl(new URL(url, url.getFile + "?" + query)) def withQueryParams(params: List[(String, String)]): Self = withQuery( @@ -80,7 +85,8 @@ trait RequestTypes { self: Client => out } - private def handleRequest(request: Request, numRedirects: Int = 0): Future[Response] = httpClient(request) flatMap { + private def handleRequest(request: Request, numRedirects: Int = 0): Future[Response] = + (filters andThen httpClient)(request) flatMap { rep => rep.status match { case Continue => Future.exception(InvalidResponse( @@ -198,7 +204,8 @@ trait RequestTypes { self: Client => case class GetRequest[Accept <: Coproduct]( url: URL, headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] ) extends RequestSyntax[Accept, GetRequest[Accept]] { def accept[AcceptTypes <: Coproduct]: GetRequest[AcceptTypes] = copy[AcceptTypes]() @@ -207,6 +214,9 @@ trait RequestTypes { self: Client => def withHeaders(addHeaders: (String, String)*): GetRequest[Accept] = copy(headers = headers ::: addHeaders.toList) def withCharset(charset: Charset): GetRequest[Accept] = copy(charset = charset) def withUrl(url: URL): GetRequest[Accept] = copy(url = url) + def addFilter(filter: Filter[Request, Response, Request, Response]): GetRequest[Accept] = + copy(filters = filter andThen filters) + def resetFilters: GetRequest[Accept] = copy(filters = Filter.identity[Request, Response]) def send[K]()(implicit canBuild: CanBuildRequest[GetRequest[Accept]], @@ -232,7 +242,8 @@ trait RequestTypes { self: Client => url: URL, content: Content, headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] ) extends RequestSyntax[Accept, PostRequest[Content, ContentType, Accept]] { def accept[AcceptTypes <: Coproduct]: PostRequest[Content, ContentType, AcceptTypes] = @@ -245,6 +256,9 @@ trait RequestTypes { self: Client => copy(charset = charset) def withUrl(url: URL): PostRequest[Content, ContentType, Accept] = copy(url = url) + def addFilter(filter: Filter[Request, Response, Request, Response]): PostRequest[Content, ContentType, Accept] = + copy(filters = filter andThen filters) + def resetFilters: PostRequest[Content, ContentType, Accept] = copy(filters = Filter.identity[Request, Response]) def withContent[T, Type <: String]( content: T, @@ -267,7 +281,9 @@ trait RequestTypes { self: Client => Right(NonEmptyList(firstElement, restElements)), multipart = false, headers, - charset) + charset, + filters + ) } def addParams( @@ -292,7 +308,8 @@ trait RequestTypes { self: Client => Right(NonEmptyList(element, Nil)), multipart = true, headers, - charset + charset, + filters ) } @@ -323,7 +340,8 @@ trait RequestTypes { self: Client => form: Elements = Left(None), multipart: Boolean = false, headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] ) extends RequestSyntax[Accept, FormPostRequest[Accept, Elements]] { def accept[AcceptTypes <: Coproduct]: FormPostRequest[AcceptTypes, Elements] = @@ -338,6 +356,9 @@ trait RequestTypes { self: Client => copy(url = url) def withMultipart(multipart: Boolean): FormPostRequest[Accept, Elements] = copy(multipart = multipart) + def addFilter(filter: Filter[Request, Response, Request, Response]): FormPostRequest[Accept, Elements] = + copy(filters = filter andThen filters) + def resetFilters: FormPostRequest[Accept, Elements] = copy(filters = Filter.identity[Request, Response]) private[request] def withParamsList(params: NonEmptyList[ValidatedNel[Throwable, FormElement]]) = copy[Accept, Right[Nothing, NonEmptyList[ValidatedNel[Throwable, FormElement]]]]( @@ -407,7 +428,8 @@ trait RequestTypes { self: Client => url: URL, content: Content, headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] ) extends RequestSyntax[Accept, PutRequest[Content, ContentType, Accept]] { def accept[AcceptTypes <: Coproduct]: PutRequest[Content, ContentType, AcceptTypes] = @@ -420,6 +442,9 @@ trait RequestTypes { self: Client => copy(charset = charset) def withUrl(url: URL): PutRequest[Content, ContentType, Accept] = copy(url = url) + def addFilter(filter: Filter[Request, Response, Request, Response]): PutRequest[Content, ContentType, Accept] = + copy(filters = filter andThen filters) + def resetFilters: PutRequest[Content, ContentType, Accept] = copy(filters = Filter.identity[Request, Response]) def withContent[T, Type <: String]( content: T, @@ -450,12 +475,16 @@ trait RequestTypes { self: Client => case class HeadRequest( url: URL, headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] ) extends RequestSyntax[Nothing, HeadRequest] { def withHeaders(addHeaders: (String, String)*): HeadRequest = copy(headers = headers ::: addHeaders.toList) def withCharset(charset: Charset): HeadRequest = copy(charset = charset) def withUrl(url: URL): HeadRequest = copy(url = url) + def addFilter(filter: Filter[Request, Response, Request, Response]): HeadRequest = + copy(filters = filter andThen filters) + def resetFilters: HeadRequest = copy(filters = Filter.identity[Request, Response]) def send()(implicit canBuild: CanBuildRequest[HeadRequest], @@ -466,7 +495,8 @@ trait RequestTypes { self: Client => case class DeleteRequest[Accept <: Coproduct]( url: URL, headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8 + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] ) extends RequestSyntax[Accept, DeleteRequest[Accept]] { def accept[AcceptTypes <: Coproduct]: DeleteRequest[AcceptTypes] = copy[AcceptTypes]() @@ -476,6 +506,9 @@ trait RequestTypes { self: Client => copy(headers = headers ::: addHeaders.toList) def withCharset(charset: Charset): DeleteRequest[Accept] = copy(charset = charset) def withUrl(url: URL): DeleteRequest[Accept] = copy(url = url) + def addFilter(filter: Filter[Request, Response, Request, Response]): DeleteRequest[Accept] = + copy(filters = filter andThen filters) + def resetFilters: DeleteRequest[Accept] = copy(filters = Filter.identity[Request, Response]) def send[K]()(implicit canBuild: CanBuildRequest[DeleteRequest[Accept]], From 7740d9d4f16631bd89314c701efa18400fbf4e51 Mon Sep 17 00:00:00 2001 From: Jeremy Smith Date: Tue, 4 Oct 2016 14:39:25 -0700 Subject: [PATCH 02/10] ScalaStyle nitpicks --- featherbed-core/src/main/scala/featherbed/auth/Authorizer.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/featherbed-core/src/main/scala/featherbed/auth/Authorizer.scala b/featherbed-core/src/main/scala/featherbed/auth/Authorizer.scala index a96c320..f68d0a9 100644 --- a/featherbed-core/src/main/scala/featherbed/auth/Authorizer.scala +++ b/featherbed-core/src/main/scala/featherbed/auth/Authorizer.scala @@ -3,4 +3,4 @@ package featherbed.auth import com.twitter.finagle.Filter import com.twitter.finagle.http.{Request, Response} -trait Authorizer extends Filter[Request, Response, Request, Response] \ No newline at end of file +trait Authorizer extends Filter[Request, Response, Request, Response] From 63421ed0178457aa5edbaa1cd5a71d8d51a82e53 Mon Sep 17 00:00:00 2001 From: Jeremy Smith Date: Tue, 31 Jan 2017 07:52:07 -0800 Subject: [PATCH 03/10] Refactoring types --- build.sbt | 9 + .../src/main/scala/featherbed/Client.scala | 52 +- .../main/scala/featherbed/content/Form.scala | 10 + .../featherbed/content/MimeContent.scala | 16 + .../scala/featherbed/content/package.scala | 14 +- .../featherbed/request/CanBuildRequest.scala | 236 ++-- .../featherbed/request/ClientRequest.scala | 233 ++++ .../featherbed/request/HTTPRequest.scala | 141 +++ .../featherbed/request/RequestSyntax.scala | 1034 +++++++++-------- .../scala/featherbed/request/package.scala | 9 + .../featherbed/support/AcceptHeader.scala | 7 +- .../scala/featherbed/support/package.scala | 8 +- .../scala/featherbed/fixture/package.scala | 35 - .../test/scala/featherbed/ClientSpec.scala | 0 .../scala/featherbed/ErrorHandlingSpec.scala | 9 +- .../scala/featherbed/circe/CirceSpec.scala | 8 +- .../src/test/scala/featherbed/package.scala | 33 + 17 files changed, 1186 insertions(+), 668 deletions(-) create mode 100644 featherbed-core/src/main/scala/featherbed/content/Form.scala create mode 100644 featherbed-core/src/main/scala/featherbed/content/MimeContent.scala create mode 100644 featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala create mode 100644 featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala create mode 100644 featherbed-core/src/main/scala/featherbed/request/package.scala rename {featherbed-core => featherbed-test}/src/test/scala/featherbed/ClientSpec.scala (100%) rename {featherbed-core => featherbed-test}/src/test/scala/featherbed/ErrorHandlingSpec.scala (97%) rename {featherbed-circe => featherbed-test}/src/test/scala/featherbed/circe/CirceSpec.scala (96%) create mode 100644 featherbed-test/src/test/scala/featherbed/package.scala diff --git a/build.sbt b/build.sbt index 6226d89..84ea157 100644 --- a/build.sbt +++ b/build.sbt @@ -78,6 +78,15 @@ lazy val `featherbed-circe` = project .settings(allSettings) .dependsOn(`featherbed-core`) +lazy val `featherbed-test` = project + .settings( + libraryDependencies ++= Seq( + "org.scalamock" %% "scalamock-scalatest-support" % "3.4.2" % "test", + "org.scalatest" %% "scalatest" % "3.0.0" % "test" + ), + buildSettings ++ noPublish + ).dependsOn(`featherbed-core`, `featherbed-circe`) + val scaladocVersionPath = settingKey[String]("Path to this version's ScalaDoc") val scaladocLatestPath = settingKey[String]("Path to latest ScalaDoc") val tutPath = settingKey[String]("Path to tutorials") diff --git a/featherbed-core/src/main/scala/featherbed/Client.scala b/featherbed-core/src/main/scala/featherbed/Client.scala index c584e09..0d69257 100644 --- a/featherbed-core/src/main/scala/featherbed/Client.scala +++ b/featherbed-core/src/main/scala/featherbed/Client.scala @@ -6,8 +6,10 @@ import java.nio.charset.{Charset, StandardCharsets} import com.twitter.finagle._ import com.twitter.finagle.builder.ClientBuilder import featherbed.auth.Authorizer +import featherbed.request.{ClientRequest, HTTPRequest} +import featherbed.request.ClientRequest._ import http.{Request, RequestBuilder, Response} -import shapeless.Coproduct +import shapeless.{CNil, Coproduct} /** * A REST client with a given base URL. @@ -15,8 +17,9 @@ import shapeless.Coproduct case class Client( baseUrl: URL, charset: Charset = StandardCharsets.UTF_8, - filters: Filter[Request, Response, Request, Response] = Filter.identity[Request, Response] -) extends request.RequestTypes with request.RequestBuilding { + filters: Filter[Request, Response, Request, Response] = Filter.identity[Request, Response], + maxFollows: Int = 5 +) { def addFilter(filter: Filter[Request, Response, Request, Response]): Client = copy(filters = filter andThen filters) @@ -31,11 +34,9 @@ case class Client( * @param relativePath The path to the resource, relative to the baseUrl * @return A [[GetRequest]] object, which can further specify and send the request */ - def get(relativePath: String): GetRequest[Coproduct.`"*/*"`.T] = - GetRequest[Coproduct.`"*/*"`.T]( + def get(relativePath: String): GetRequest[CNil] = + ClientRequest(this).get( baseUrl.toURI.resolve(relativePath).toURL, - List.empty, - charset, filters ) @@ -44,12 +45,9 @@ case class Client( * @param relativePath The path to the resource, relative to the baseUrl * @return A [[PostRequest]] object, which can further specify and send the request */ - def post(relativePath: String): PostRequest[None.type, Nothing, Coproduct.`"*/*"`.T] = - PostRequest[None.type, Nothing, Coproduct.`"*/*"`.T]( + def post(relativePath: String): PostRequest[CNil, None.type, None.type] = + ClientRequest(this).post( baseUrl.toURI.resolve(relativePath).toURL, - None, - List.empty, - charset, filters ) @@ -58,12 +56,20 @@ case class Client( * @param relativePath The path to the resource, relative to the baseUrl * @return A [[PutRequest]] object, which can further specify and send the request */ - def put(relativePath: String): PutRequest[None.type, Nothing, Coproduct.`"*/*"`.T] = - PutRequest[None.type, Nothing, Coproduct.`"*/*"`.T]( + def put(relativePath: String): PutRequest[CNil, None.type, None.type] = + ClientRequest(this).put( + baseUrl.toURI.resolve(relativePath).toURL, + filters + ) + + /** + * Specify a PATCH request to be performed against the given resource + * @param relativePath The path to the resource, relative to the baseUrl + * @return A [[PatchRequest]] object, which can further specify and send the request + */ + def patch(relativePath: String): PatchRequest[CNil, None.type, None.type] = + ClientRequest(this).patch( baseUrl.toURI.resolve(relativePath).toURL, - None, - List.empty, - charset, filters ) @@ -73,15 +79,21 @@ case class Client( * @return A [[HeadRequest]] object, which can further specify and send the request */ def head(relativePath: String): HeadRequest = - HeadRequest(baseUrl.toURI.resolve(relativePath).toURL, List.empty, charset, filters) + ClientRequest(this).head( + baseUrl.toURI.resolve(relativePath).toURL, + filters + ) /** * Specify a DELETE request to be performed against the given resource * @param relativePath The path to the resource, relative to the baseUrl * @return A [[DeleteRequest]] object, which can further specify and send the request */ - def delete(relativePath: String): DeleteRequest[Coproduct.`"*/*"`.T] = - DeleteRequest[Coproduct.`"*/*"`.T](baseUrl.toURI.resolve(relativePath).toURL, List.empty, charset, filters) + def delete(relativePath: String): DeleteRequest[CNil] = + ClientRequest(this).delete( + baseUrl.toURI.resolve(relativePath).toURL, + filters + ) /** * Close this client releasing allocated resources. diff --git a/featherbed-core/src/main/scala/featherbed/content/Form.scala b/featherbed-core/src/main/scala/featherbed/content/Form.scala new file mode 100644 index 0000000..c8f5812 --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/content/Form.scala @@ -0,0 +1,10 @@ +package featherbed.content + +import cats.data.{NonEmptyList, Validated} +import com.twitter.finagle.http.FormElement + +case class Form(params: NonEmptyList[Validated[Throwable, FormElement]]) { + def multipart: MultipartForm = MultipartForm(params) +} + +case class MultipartForm(params: NonEmptyList[Validated[Throwable, FormElement]]) diff --git a/featherbed-core/src/main/scala/featherbed/content/MimeContent.scala b/featherbed-core/src/main/scala/featherbed/content/MimeContent.scala new file mode 100644 index 0000000..2a42f9c --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/content/MimeContent.scala @@ -0,0 +1,16 @@ +package featherbed.content + +import shapeless.Witness + + +case class MimeContent[Content, ContentType](content: Content, contentType: ContentType) + +object MimeContent { + type WebForm = Witness.`"application/x-www-form-urlencoded"`.T + type Json = Witness.`"application/json"`.T + val NoContent: MimeContent[None.type, None.type] = MimeContent(None, None) + + def apply[Content, ContentType](content: Content)(implicit + witness: Witness.Aux[ContentType] + ): MimeContent[Content, ContentType] = MimeContent(content, witness.value) +} diff --git a/featherbed-core/src/main/scala/featherbed/content/package.scala b/featherbed-core/src/main/scala/featherbed/content/package.scala index e82dfa9..2c69d77 100644 --- a/featherbed-core/src/main/scala/featherbed/content/package.scala +++ b/featherbed-core/src/main/scala/featherbed/content/package.scala @@ -2,16 +2,16 @@ package featherbed import java.nio.CharBuffer import java.nio.charset.{Charset, CodingErrorAction} + import scala.util.Try import cats.data.{Validated, ValidatedNel} import com.twitter.finagle.http.Response import com.twitter.io.Buf -import shapeless.Witness +import shapeless.{CNil, Witness} import sun.nio.cs.ThreadLocalCoders package object content { - type ContentType = String trait Decoder[ContentType] { type Out @@ -22,7 +22,7 @@ package object content { object Decoder extends LowPriorityDecoders { type Aux[CT, A1] = Decoder[CT] { type Out = A1 } - def of[T <: ContentType, A1](t: T)(fn: Response => ValidatedNel[Throwable, A1]): Decoder.Aux[t.type, A1] = + def of[T <: String, A1](t: T)(fn: Response => ValidatedNel[Throwable, A1]): Decoder.Aux[t.type, A1] = new Decoder[t.type] { type Out = A1 val contentType = t @@ -49,8 +49,10 @@ package object content { response => Decoder.decodeString(response) } - implicit val anyResponseDecoder: Decoder.Aux[Witness.`"*/*"`.T, Response] = Decoder.of("*/*") { - response => Validated.Valid(response) + implicit val anyResponseDecoder: Decoder.Aux[Nothing, Response] = new Decoder[Nothing] { + type Out = Response + final val contentType: String = "*/*" + final def apply(rep: Response): ValidatedNel[Throwable, Response] = Validated.Valid(rep) } } @@ -59,7 +61,7 @@ package object content { } object Encoder extends LowPriorityEncoders { - def of[A, T <: ContentType](t: T)(fn: (A, Charset) => ValidatedNel[Throwable, Buf]): Encoder[A, t.type] = + def of[A, T <: String](t: T)(fn: (A, Charset) => ValidatedNel[Throwable, Buf]): Encoder[A, t.type] = new Encoder[A, t.type] { def apply(value: A, charset: Charset) = fn(value, charset) } diff --git a/featherbed-core/src/main/scala/featherbed/request/CanBuildRequest.scala b/featherbed-core/src/main/scala/featherbed/request/CanBuildRequest.scala index e4cdaaf..96b99e7 100644 --- a/featherbed-core/src/main/scala/featherbed/request/CanBuildRequest.scala +++ b/featherbed-core/src/main/scala/featherbed/request/CanBuildRequest.scala @@ -2,153 +2,165 @@ package featherbed.request import scala.annotation.implicitNotFound -import featherbed.Client -import featherbed.content -import featherbed.support.AcceptHeader - -import cats.data._, Validated._ +import cats.data._ +import cats.data.Validated._ +import cats.implicits._ import com.twitter.finagle.http.{FormElement, Request, RequestBuilder} import com.twitter.finagle.http.RequestConfig.Yes import com.twitter.io.Buf +import featherbed.Client +import featherbed.content +import featherbed.content.{Form, MultipartForm} +import featherbed.support.AcceptHeader import shapeless.{Coproduct, Witness} case class RequestBuildingError(errors: NonEmptyList[Throwable]) extends Throwable(s"Failed to build request: ${errors.toList.mkString(";")}") -trait RequestBuilding { - self: Client with RequestTypes => - - /** - * Represents evidence that the request can be built. For requests that include content, - * this requires that an implicit [[content.Encoder]] is available for the given Content-Type. - * - * @tparam T request type - */ - @implicitNotFound( - """The request of type ${T} cannot be built. This is most likely because either: - 1. If the request is a POST, PUT, or PATCH request: - a. The request requires a Content-Type to be defined, but one was not defined (using the withContent method) - b. An Encoder is required for the request's Content-Type, but one was not available in implicit scope. This is - usually a matter of importing the right module to obtain the Encoder instance. - 2. Something is missing from featherbed""") - sealed trait CanBuildRequest[T] { - def build(t: T): ValidatedNel[Throwable, Request] - } - object CanBuildRequest { +/** + * Represents evidence that the request can be built. For requests that include content, + * this requires that an implicit [[content.Encoder]] is available for the given Content-Type. + * + * @tparam T request type + */ +@implicitNotFound( + """The request of type ${T} cannot be built. This is most likely because either: + 1. If the request is a POST, PUT, or PATCH request: + a. The request requires a Content-Type to be defined, but one was not defined (using the withContent method) + b. An Encoder is required for the request's Content-Type, but one was not available in implicit scope. This is + usually a matter of importing the right module to obtain the Encoder instance. + 2. Something is missing from featherbed""") +sealed trait CanBuildRequest[T] { + def build(t: T): ValidatedNel[Throwable, Request] +} - private def baseBuilder[Accept <: Coproduct, Self <: RequestSyntax[Accept, Self]]( - request: RequestSyntax[Accept, Self] - )( - implicit - accept: AcceptHeader[Accept] - ): RequestBuilder[Yes, Nothing] = request.buildHeaders( - RequestBuilder().url(request.url).addHeader("Accept", accept.toString) - ) +object CanBuildRequest { + + + private def baseBuilder[Accept <: Coproduct]( + request: HTTPRequest[_, Accept, _, _] + )( + implicit accept: AcceptHeader[Accept] + ): RequestBuilder[Yes, Nothing] = { + val builder = RequestBuilder().url(request.buildUrl).addHeader("Accept", accept.toString) + request.headers.foldLeft(builder) { + (accum, next) => accum.setHeader(next._1, next._2) + } + } - implicit def canBuildGetRequest[Accept <: Coproduct]( - implicit - accept: AcceptHeader[Accept] - ): CanBuildRequest[GetRequest[Accept]] = new CanBuildRequest[GetRequest[Accept]] { - def build(getRequest: GetRequest[Accept]) = Valid( + implicit def canBuildGetRequest[Accept <: Coproduct](implicit + accept: AcceptHeader[Accept] + ): CanBuildRequest[HTTPRequest.GetRequest[Accept]] = + new CanBuildRequest[HTTPRequest.GetRequest[Accept]] { + def build(getRequest: HTTPRequest.GetRequest[Accept]) = Valid( baseBuilder(getRequest).buildGet() ) } - implicit def canBuildPostRequestWithContentBuffer[Accept <: Coproduct, CT <: content.ContentType]( - implicit - accept: AcceptHeader[Accept], - witness: Witness.Aux[CT] - ): CanBuildRequest[PostRequest[Buf, CT, Accept]] = - new CanBuildRequest[PostRequest[Buf, CT, Accept]] { - def build(postRequest: PostRequest[Buf, CT, Accept]) = Valid( - baseBuilder(postRequest) - .addHeader("Content-Type", s"${witness.value}; charset=${postRequest.charset.name}") - .buildPost(postRequest.content) - ) - } + implicit def canBuildPostRequestWithContentBuffer[Accept <: Coproduct, CT <: String](implicit + accept: AcceptHeader[Accept], + witness: Witness.Aux[CT] + ): CanBuildRequest[HTTPRequest.PostRequest[Accept, Buf, CT]] = + new CanBuildRequest[HTTPRequest.PostRequest[Accept, Buf, CT]] { + def build(postRequest: HTTPRequest.PostRequest[Accept, Buf, CT]) = Valid( + baseBuilder(postRequest) + .addHeader("Content-Type", s"${witness.value}; charset=${postRequest.charset.name}") + .buildPost(postRequest.content.content) + ) + } - implicit def canBuildFormPostRequest[Accept <: Coproduct]( - implicit - accept: AcceptHeader[Accept] - ): CanBuildRequest[FormPostRequest[Accept, Right[Nothing, NonEmptyList[ValidatedNel[Throwable, FormElement]]]]] = - new CanBuildRequest[FormPostRequest[Accept, Right[Nothing, NonEmptyList[ValidatedNel[Throwable, FormElement]]]]] { - def build( - formPostRequest: FormPostRequest[Accept, Right[Nothing, NonEmptyList[ValidatedNel[Throwable, FormElement]]]] - ) = { - formPostRequest.form match { - case Right(elems) => - val initial = elems.head.map(baseBuilder(formPostRequest).add) - val builder = elems.tail.foldLeft(initial) { - (builder, elem) => builder andThen { - b => elem.map { - e => b.add(e) - } - } - } - // Finagle takes care of Content-Type header - builder.map(_.buildFormPost(formPostRequest.multipart)) - } + implicit def canBuildFormPostRequest[Accept <: Coproduct](implicit + accept: AcceptHeader[Accept] + ): CanBuildRequest[HTTPRequest.FormPostRequest[Accept]] = + new CanBuildRequest[HTTPRequest.FormPostRequest[Accept]] { + def build( + formPostRequest: HTTPRequest.FormPostRequest[Accept] + ): Validated[NonEmptyList[Throwable], Request] = { + formPostRequest.content.content match { + case Form(elems) => + val validated = elems.traverseU(_.toValidatedNel) + + // Finagle takes care of Content-Type header + validated.map { + elems => baseBuilder(formPostRequest).add(elems.toList).buildFormPost(multipart = false) + } } } + } - implicit def canBuildPutRequestWithContentBuffer[Accept <: Coproduct, CT <: content.ContentType]( - implicit - accept: AcceptHeader[Accept], - witness: Witness.Aux[CT] - ): CanBuildRequest[PutRequest[Buf, CT, Accept]] = - new CanBuildRequest[PutRequest[Buf, CT, Accept]] { - def build(putRequest: PutRequest[Buf, CT, Accept]) = Valid( - baseBuilder(putRequest) - .addHeader("Content-Type", s"${witness.value}; charset=${putRequest.charset.name}") - .buildPut(putRequest.content) - ) + implicit def canBuildMultipartFormPostRequest[Accept <: Coproduct](implicit + accept: AcceptHeader[Accept] + ): CanBuildRequest[HTTPRequest.MultipartFormRequest[Accept]] = + new CanBuildRequest[HTTPRequest.MultipartFormRequest[Accept]] { + def build( + formPostRequest: HTTPRequest.MultipartFormRequest[Accept] + ): Validated[NonEmptyList[Throwable], Request] = { + formPostRequest.content.content match { + case MultipartForm(elems) => + val validated = elems.traverseU(_.toValidatedNel) + + // Finagle takes care of Content-Type header + validated.map { + elems => baseBuilder(formPostRequest).add(elems.toList).buildFormPost(multipart = false) + } + } } + } - implicit val canBuildHeadRequest = new CanBuildRequest[HeadRequest] { - def build(headRequest: HeadRequest) = Valid( - headRequest.buildHeaders( - RequestBuilder().url(headRequest.url) - ).buildHead() + implicit def canBuildPutRequestWithContentBuffer[Accept <: Coproduct, CT <: String](implicit + accept: AcceptHeader[Accept], + witness: Witness.Aux[CT] + ): CanBuildRequest[HTTPRequest.PutRequest[Accept, Buf, CT]] = + new CanBuildRequest[HTTPRequest.PutRequest[Accept, Buf, CT]] { + def build(putRequest: HTTPRequest.PutRequest[Accept, Buf, CT]) = Valid( + baseBuilder(putRequest) + .addHeader("Content-Type", s"${witness.value}; charset=${putRequest.charset.name}") + .buildPut(putRequest.content.content) ) } - implicit def canBuildDeleteRequest[Accept <: Coproduct]( - implicit - accept: AcceptHeader[Accept] - ): CanBuildRequest[DeleteRequest[Accept]] = new CanBuildRequest[DeleteRequest[Accept]] { - def build(deleteRequest: DeleteRequest[Accept]) = Valid( + implicit val canBuildHeadRequest = new CanBuildRequest[HTTPRequest.HeadRequest] { + def build(headRequest: HTTPRequest.HeadRequest) = Valid( + baseBuilder(headRequest).buildHead() + ) + } + + implicit def canBuildDeleteRequest[Accept <: Coproduct](implicit + accept: AcceptHeader[Accept] + ): CanBuildRequest[HTTPRequest.DeleteRequest[Accept]] = + new CanBuildRequest[HTTPRequest.DeleteRequest[Accept]] { + def build(deleteRequest: HTTPRequest.DeleteRequest[Accept]) = Valid( baseBuilder(deleteRequest).buildDelete() ) } - implicit def canBuildPostRequestWithEncoder[Accept <: Coproduct, A, CT <: content.ContentType]( - implicit - encoder: content.Encoder[A, CT], - witness: Witness.Aux[CT], - accept: AcceptHeader[Accept] - ): CanBuildRequest[PostRequest[A, CT, Accept]] = - new CanBuildRequest[PostRequest[A, CT, Accept]] { - def build(postRequest: PostRequest[A, CT, Accept]) = encoder(postRequest.content, postRequest.charset).map { + implicit def canBuildPostRequestWithEncoder[Accept <: Coproduct, Content, CT <: String](implicit + encoder: content.Encoder[Content, CT], + witness: Witness.Aux[CT], + accept: AcceptHeader[Accept] + ): CanBuildRequest[HTTPRequest.PostRequest[Accept, Content, CT]] = + new CanBuildRequest[HTTPRequest.PostRequest[Accept, Content, CT]] { + def build(postRequest: HTTPRequest.PostRequest[Accept, Content, CT]) = + encoder(postRequest.content.content, postRequest.charset).map { buf => baseBuilder(postRequest) .addHeader("Content-Type", s"${witness.value}; charset=${postRequest.charset.name}") .buildPost(buf) } - } + } - implicit def canBuildPutRequestWithEncoder[Accept <: Coproduct, A, CT <: content.ContentType]( - implicit - encoder: content.Encoder[A, CT], - witness: Witness.Aux[CT], - accept: AcceptHeader[Accept] - ): CanBuildRequest[PutRequest[A, CT, Accept]] = - new CanBuildRequest[PutRequest[A, CT, Accept]] { - def build(putRequest: PutRequest[A, CT, Accept]) = encoder(putRequest.content, putRequest.charset).map { + implicit def canBuildPutRequestWithEncoder[Accept <: Coproduct, Content, CT <: String](implicit + encoder: content.Encoder[Content, CT], + witness: Witness.Aux[CT], + accept: AcceptHeader[Accept] + ): CanBuildRequest[HTTPRequest.PutRequest[Accept, Content, CT]] = + new CanBuildRequest[HTTPRequest.PutRequest[Accept, Content, CT]] { + def build(putRequest: HTTPRequest.PutRequest[Accept, Content, CT]) = + encoder(putRequest.content.content, putRequest.charset).map { buf => baseBuilder(putRequest) .addHeader("Content-Type", s"${witness.value}; charset=${putRequest.charset.name}") .buildPut(buf) } - } - } - + } } + diff --git a/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala b/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala new file mode 100644 index 0000000..80d889f --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala @@ -0,0 +1,233 @@ +package featherbed.request + +import java.net.URL +import java.nio.charset.Charset + +import scala.language.experimental.macros + +import cats.data.Validated.{Invalid, Valid} +import cats.data.ValidatedNel +import cats.implicits._ +import com.twitter.finagle.{Filter, Service} +import com.twitter.finagle.http.{Method, Request, Response} +import com.twitter.finagle.http.Status._ +import com.twitter.util.Future +import featherbed.Client +import featherbed.content.{Form, MimeContent, MultipartForm} +import featherbed.littlemacros.CoproductMacros +import featherbed.support.{ContentType, DecodeAll, RuntimeContentType} +import shapeless.{CNil, Coproduct, HList, Witness} + +case class ClientRequest[Meth <: Method, Accept <: Coproduct, Content, ContentType]( + request: HTTPRequest[Meth, Accept, Content, ContentType], + client: Client +) { + + def accept[A <: Coproduct]: ClientRequest[Meth, A, Content, ContentType] = + copy[Meth, A, Content, ContentType](request = request.accept[A]) + + def accept[A <: Coproduct](types: String*): ClientRequest[Meth, A, Content, ContentType] = + macro CoproductMacros.callAcceptCoproduct + + def withCharset(charset: Charset): ClientRequest[Meth, Accept, Content, ContentType] = + copy(request = request.withCharset(charset)) + + def withHeader(name: String, value: String): ClientRequest[Meth, Accept, Content, ContentType] = + copy(request = request.withHeaders((name, value))) + + def withHeaders(headers: (String, String)*): ClientRequest[Meth, Accept, Content, ContentType] = + copy(request = request.withHeaders(headers: _*)) + + def withUrl(url: URL): ClientRequest[Meth, Accept, Content, ContentType] = + copy(request = request.withUrl(url)) + + def addFilter( + filter: Filter[Request, Response, Request, Response] + ): ClientRequest[Meth, Accept, Content, ContentType] = + copy(request = request.addFilter(filter)) + + def prependFilter( + filter: Filter[Request, Response, Request, Response] + ): ClientRequest[Meth, Accept, Content, ContentType] = + copy(request = request.prependFilter(filter)) + + def resetFilters(): ClientRequest[Meth, Accept, Content, ContentType] = + copy(request = request.resetFilters()) + + def withQuery(query: String): ClientRequest[Meth, Accept, Content, ContentType] = copy( + request = request.withQuery(query) + ) + + def withQueryParams( + params: List[(String, String)] + ): ClientRequest[Meth, Accept, Content, ContentType] = + copy(request = request.withQueryParams(params)) + + def addQueryParams( + params: List[(String, String)] + ): ClientRequest[Meth, Accept, Content, ContentType] = + copy(request = request.addQueryParams(params)) + + def withQueryParams( + params: (String, String)* + ): ClientRequest[Meth, Accept, Content, ContentType] = + copy(request = request.withQueryParams(params: _*)) + + def addQueryParams( + params: (String, String)* + ): ClientRequest[Meth, Accept, Content, ContentType] = + copy(request = request.addQueryParams(params: _*)) + + + def buildUrl: URL = request.buildUrl + + + def send[K]()(implicit + canBuildRequest: CanBuildRequest[HTTPRequest[Meth, Accept, Content, ContentType]], + decodeAll: DecodeAll[K, Accept] + ): Future[K] = sendValid().flatMap { + response => decodeResponse[K](response) + } + + def send[E, S](implicit + canBuildRequest: CanBuildRequest[HTTPRequest[Meth, Accept, Content, ContentType]], + decodeSuccess: DecodeAll[S, Accept], + decodeError: DecodeAll[E, Accept] + ): Future[Either[E, S]] = sendValid().flatMap { + rep => decodeResponse[S](rep).map(Either.right[E, S]) + }.rescue { + case ErrorResponse(_, rep) => decodeResponse[E](rep).map(Either.left[E, S]) + } + + def sendZip[E, S]()(implicit + canBuildRequest: CanBuildRequest[HTTPRequest[Meth, Accept, Content, ContentType]], + decodeSuccess: DecodeAll[S, Accept], + decodeError: DecodeAll[E, Accept] + ): Future[(Either[E, S], Response)] = sendValid().flatMap { + rep => decodeResponse[S](rep).map(Either.right[E, S]).map((_, rep)) + }.rescue { + case ErrorResponse(_, rep) => decodeResponse[E](rep).map(Either.left[E, S]).map((_, rep)) + } + + private def sendValid()(implicit + canBuildRequest: CanBuildRequest[HTTPRequest[Meth, Accept, Content, ContentType]] + ): Future[Response] = canBuildRequest.build(request).fold( + errs => Future.exception(errs.head), + req => handleRequest(() => req, request.filters, request.buildUrl, client.httpClient, client.maxFollows) + ) + + private def decodeResponse[K](rep: Response)(implicit decodeAll: DecodeAll[K, Accept]) = + rep.contentType flatMap ContentType.contentTypePieces match { + case None => Future.exception(InvalidResponse(rep, "Content-Type header is not present")) + case Some(RuntimeContentType(mediaType, _)) => decodeAll.instances.find(_.contentType == mediaType) match { + case Some(decoder) => + decoder(rep) match { + case Valid(decoded) => + Future(decoded) + case Invalid(errs) => + Future.exception(InvalidResponse(rep, errs.map(_.getMessage).toList.mkString("; "))) + } + case None => + Future.exception(InvalidResponse(rep, s"No decoder was found for $mediaType")) + } + } + + private def handleRequest( + request: () => Request, + filters: Filter[Request, Response, Request, Response], + url: URL, + httpClient: Service[Request, Response], + remainingRedirects: Int + ): Future[Response] = { + val req = request() + (filters andThen httpClient) (req) flatMap { + rep => + rep.status match { + case Continue => + Future.exception(InvalidResponse( + rep, + "Received unexpected 100/Continue, but request body was already sent." + )) + case SwitchingProtocols => Future.exception(InvalidResponse( + rep, + "Received unexpected 101/Switching Protocols, but no switch was requested." + )) + case s if s.code >= 200 && s.code < 300 => + Future(rep) + case MultipleChoices => + Future.exception(InvalidResponse(rep, "300/Multiple Choices is not yet supported in featherbed.")) + case MovedPermanently | Found | SeeOther | TemporaryRedirect => + val attempt = for { + tooMany <- if (remainingRedirects <= 0) + Left("Too many redirects; giving up") + else + Right(()) + location <- Either.fromOption( + rep.headerMap.get("Location"), + "Redirect required, but location header not present") + newUrl <- Either.catchNonFatal(url.toURI.resolve(location)) + .leftMap(_ => s"Could not resolve Location $location") + canHandle <- if (newUrl.getHost != url.getHost) + Either.left("Location points to another host; this isn't supported by featherbed") + else + Either.right(()) + } yield { + val newReq = request() + newReq.uri = List(Option(newUrl.getPath), Option(newUrl.getQuery).map("?" + _)).flatten.mkString + handleRequest(() => newReq, filters, url, httpClient, remainingRedirects - 1) + } + attempt.fold(err => Future.exception(InvalidResponse(rep, err)), identity) + case other => Future.exception(ErrorResponse(req, rep)) + } + } + } + +} + +object ClientRequest extends RequestTypes[ClientRequest] { + + class ClientRequestSyntax(client: Client) extends RequestSyntax[ClientRequest] with RequestTypes[ClientRequest] { + + def req[Meth <: Method, Accept <: Coproduct]( + method: Meth, url: URL, + filters: Filter[Request, Response, Request, Response] + ): ClientRequest[Meth, Accept, None.type, None.type] = + ClientRequest(HTTPRequest.req(method, url, filters), client) + } + + def apply(client: Client): ClientRequestSyntax = new ClientRequestSyntax(client) + + + implicit class PostRequestOps[Accept <: Coproduct, Content, ContentType]( + val req: PostRequest[Accept, Content, ContentType] + ) extends AnyVal { + def withContent[Content, ContentType <: String]( + content: Content, + contentType: ContentType)(implicit + witness: Witness.Aux[contentType.type] + ): PostRequest[Accept, Content, contentType.type] = req.copy( + request = req.request.withContent(content, contentType) + ) + + def withParams( + first: (String, String), + rest: (String, String)* + ): FormPostRequest[Accept] = req.copy( + request = req.request.withParams(first, rest: _*) + ) + + } + + implicit class PutRequestOps[Accept <: Coproduct, Content, ContentType]( + val req: PutRequest[Accept, Content, ContentType] + ) extends AnyVal { + def withContent[Content, ContentType <: String]( + content: Content, + contentType: ContentType)(implicit + witness: Witness.Aux[contentType.type] + ): PutRequest[Accept, Content, contentType.type] = req.copy( + request = req.request.withContent(content, contentType) + ) + } + +} \ No newline at end of file diff --git a/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala b/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala new file mode 100644 index 0000000..994cdf9 --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala @@ -0,0 +1,141 @@ +package featherbed +package request + +import java.net.{URL, URLEncoder} +import java.nio.charset.{Charset, StandardCharsets} + +import scala.language.experimental.macros + +import cats.syntax.either._ +import com.twitter.finagle.{Filter, Service, ServiceFactory} +import com.twitter.finagle.http.{Method, Request, Response, SimpleElement} +import com.twitter.finagle.http.Status.{NoContent => _, _} +import com.twitter.util.Future +import featherbed.content.{Form, MimeContent, MultipartForm} +import MimeContent.NoContent +import cats.data.{NonEmptyList, Validated} +import featherbed.littlemacros.CoproductMacros +import featherbed.support.DecodeAll +import shapeless._ + +case class HTTPRequest[ + Meth <: Method, + Accept <: Coproduct, + Content, + ContentType +]( + method: Meth, + url: URL, + content: MimeContent[Content, ContentType], + query: Option[String] = None, + headers: List[(String, String)] = List.empty, + charset: Charset = StandardCharsets.UTF_8, + filters: Filter[Request, Response, Request, Response] = Filter.identity +) { + + def accept[A <: Coproduct]: HTTPRequest[Meth, A, Content, ContentType] = + copy[Meth, A, Content, ContentType]() + + def accept[A <: Coproduct](types: String*): HTTPRequest[Meth, A, Content, ContentType] = + macro CoproductMacros.callAcceptCoproduct + + def withCharset(charset: Charset): HTTPRequest[Meth, Accept, Content, ContentType] = + copy(charset = charset) + def withHeader(name: String, value: String): HTTPRequest[Meth, Accept, Content, ContentType] = + withHeaders((name, value)) + def withHeaders(headers: (String, String)*): HTTPRequest[Meth, Accept, Content, ContentType] = + copy(headers = this.headers ++ headers) + + def withUrl(url: URL): HTTPRequest[Meth, Accept, Content, ContentType] = copy(url = url) + + def addFilter( + filter: Filter[Request, Response, Request, Response] + ): HTTPRequest[Meth, Accept, Content, ContentType] = copy(filters = filters andThen filter) + + def prependFilter( + filter: Filter[Request, Response, Request, Response] + ): HTTPRequest[Meth, Accept, Content, ContentType] = copy(filters = filter andThen filters) + + def resetFilters(): HTTPRequest[Meth, Accept, Content, ContentType] = + copy(filters = Filter.identity) + + def withQuery(query: String): HTTPRequest[Meth, Accept, Content, ContentType] = copy( + query = Some(query) + ) + + def withQueryParams( + params: List[(String, String)] + ): HTTPRequest[Meth, Accept, Content, ContentType] = withQuery( + params.map { + case (key, value) => URLEncoder.encode(key, charset.name) + "=" + URLEncoder.encode(value, charset.name) + }.mkString("&") + ) + + def addQueryParams( + params: List[(String, String)] + ): HTTPRequest[Meth, Accept, Content, ContentType] = withQuery( + query.map(_ + "&").getOrElse("") + params.map { + case (key, value) => URLEncoder.encode(key, charset.name) + "=" + URLEncoder.encode(value, charset.name) + }.mkString("&") + ) + + def withQueryParams( + params: (String, String)* + ): HTTPRequest[Meth, Accept, Content, ContentType] = withQueryParams(params.toList) + + def addQueryParams( + params: (String, String)* + ): HTTPRequest[Meth, Accept, Content, ContentType] = addQueryParams(params.toList) + + + def buildUrl: URL = query.map(q => new URL(url, "?" + q)).getOrElse(url) + +} + +object HTTPRequest extends RequestSyntax[HTTPRequest] with RequestTypes[HTTPRequest] { + def req[Meth <: Method, Accept <: Coproduct]( + method: Meth, url: URL, + filters: Filter[Request, Response, Request, Response] + ): HTTPRequest[Meth, Accept, None.type, None.type] = HTTPRequest(method, url, NoContent) + + implicit class PostRequestOps[Accept <: Coproduct, Content, ContentType]( + val req: PostRequest[Accept, Content, ContentType] + ) extends AnyVal { + def withContent[Content, ContentType <: String](content: Content, contentType: ContentType)(implicit + witness: Witness.Aux[contentType.type] + ): PostRequest[Accept, Content, contentType.type] = req.copy[Method.Post.type, Accept, Content, contentType.type]( + content = MimeContent[Content, contentType.type](content) + ) + + def withParams( + first: (String, String), + rest: (String, String)* + ): FormPostRequest[Accept] = req.copy[Method.Post.type, Accept, Form, MimeContent.WebForm]( + content = MimeContent[Form, MimeContent.WebForm]( + Form( + NonEmptyList(first, rest.toList) + .map((SimpleElement.apply _).tupled) + .map(Validated.valid) + ) + ) + ) + + def toService[In, Out](contentType: String)(client: Client)(implicit + canBuildRequest: CanBuildRequest[HTTPRequest[Method.Post.type, Accept, In, contentType.type]], + decodeAll: DecodeAll[Out, Accept], + witness: Witness.Aux[contentType.type] + ): Service[In, Out] = Service.mk[In, Out] { + in => ClientRequest(req, client).withContent[In, contentType.type](in, contentType).send[Out]() + } + } + + implicit class PutRequestOps[Accept <: Coproduct, Content, ContentType]( + val req: PutRequest[Accept, Content, ContentType] + ) extends AnyVal { + def withContent[Content, ContentType <: String](content: Content, contentType: ContentType)(implicit + witness: Witness.Aux[contentType.type] + ): PutRequest[Accept, Content, contentType.type] = req.copy[Method.Put.type, Accept, Content, contentType.type]( + content = MimeContent[Content, contentType.type](content) + ) + } +} \ No newline at end of file diff --git a/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala b/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala index eb8924a..455c6bd 100644 --- a/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala +++ b/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala @@ -4,18 +4,16 @@ package request import java.io.File import java.net.{URL, URLEncoder} import java.nio.charset.{Charset, StandardCharsets} + import scala.language.experimental.macros +import scala.language.higherKinds import cats.data._, Validated._ import cats.implicits._ import cats.instances.list._ import com.twitter.finagle.Filter -import com.twitter.finagle.http._ -import com.twitter.finagle.http.Status._ -import com.twitter.util.Future -import featherbed.content.{Decoder, Encoder} -import featherbed.littlemacros.CoproductMacros -import featherbed.support.{ContentType, DecodeAll, RuntimeContentType} +import com.twitter.finagle.http.{Method, Request, Response} +import featherbed.content.{Form, MimeContent, MultipartForm} import shapeless.{CNil, Coproduct, Witness} @@ -31,502 +29,570 @@ import shapeless.{CNil, Coproduct, Witness} case class InvalidResponse(response: Response, reason: String) extends Throwable(reason) case class ErrorResponse(request: Request, response: Response) extends Throwable("Error response received") -trait RequestTypes { self: Client => - - sealed trait RequestSyntax[Accept <: Coproduct, Self <: RequestSyntax[Accept, Self]] { self: Self => - - val url: URL - val charset: Charset - val headers: List[(String, String)] - val filters: Filter[Request, Response, Request, Response] - - def withHeader(name: String, value: String): Self = withHeaders((name, value)) - def withHeaders(headers: (String, String)*): Self - def withCharset(charset: Charset): Self - def withUrl(url: URL): Self - def addFilter(filter: Filter[Request, Response, Request, Response]): Self - def resetFilters: Self - def setFilters(filter: Filter[Request, Response, Request, Response]): Self = resetFilters.addFilter(filter) - - def withQuery(query: String): Self = withUrl(new URL(url, url.getFile + "?" + query)) - def withQueryParams(params: List[(String, String)]): Self = withQuery( - params.map { - case (key, value) => URLEncoder.encode(key, charset.name) + "=" + URLEncoder.encode(value, charset.name) - }.mkString("&") - ) - def addQueryParams(params: List[(String, String)]): Self = withQuery( - Option(url.getQuery).map(_ + "&").getOrElse("") + params.map { - case (key, value) => URLEncoder.encode(key, charset.name) + "=" + URLEncoder.encode(value, charset.name) - }.mkString("&") - ) - - def withQueryParams(params: (String, String)*): Self = withQueryParams(params.toList) - def addQueryParams(params: (String, String)*): Self = addQueryParams(params.toList) - - - protected[featherbed] def buildHeaders[HasUrl]( - rb: RequestBuilder[HasUrl, Nothing] - ): RequestBuilder[HasUrl, Nothing] = - headers.foldLeft(rb) { - case (builder, (key, value)) => builder.addHeader(key, value) - } - - protected[featherbed] def buildRequest(implicit - canBuild: CanBuildRequest[Self] - ): ValidatedNel[Throwable, Request] = canBuild.build(this: Self) - - private def cloneRequest(in: Request) = { - val out = Request() - out.uri = in.uri - out.content = in.content - in.headerMap.foreach { - case (k, v) => out.headerMap.put(k, v) - } - out - } - - private def handleRequest(request: Request, numRedirects: Int = 0): Future[Response] = - (filters andThen httpClient)(request) flatMap { - rep => rep.status match { - case Continue => - Future.exception(InvalidResponse( - rep, - "Received unexpected 100/Continue, but request body was already sent." - )) - case SwitchingProtocols => - Future.exception(InvalidResponse( - rep, - "Received unexpected 101/Switching Protocols, but no switch was requested." - )) - case s if s.code >= 200 && s.code < 300 => - Future(rep) - case MultipleChoices => - Future.exception(InvalidResponse(rep, "300/Multiple Choices is not yet supported in featherbed.")) - case MovedPermanently | Found | SeeOther | TemporaryRedirect => - val attempt = for { - tooMany <- if (numRedirects > 5) - Left("Too many redirects; giving up") - else - Right(()) - location <- Either.fromOption( - rep.headerMap.get("Location"), - "Redirect required, but location header not present") - newUrl <- Either.catchNonFatal(url.toURI.resolve(location)) - .leftMap(_ => s"Could not resolve Location $location") - canHandle <- if (newUrl.getHost != url.getHost) - Left("Location points to another host; this isn't supported by featherbed") - else - Right(()) - } yield { - val newReq = cloneRequest(request) - newReq.uri = List(Option(newUrl.getPath), Option(newUrl.getQuery).map("?" + _)).flatten.mkString - handleRequest(newReq, numRedirects + 1) - } - attempt.fold( - err => Future.exception(InvalidResponse(rep, err)), - identity - ) - case other => Future.exception(ErrorResponse(request, rep)) - } - } - - - protected def decodeResponse[T](rep: Response)(implicit decodeAll: DecodeAll[T, Accept]) = - rep.contentType flatMap ContentType.contentTypePieces match { - case None => Future.exception(InvalidResponse(rep, "Content-Type header is not present")) - case Some(RuntimeContentType(mediaType, _)) => decodeAll.instances.find(_.contentType == mediaType) match { - case Some(decoder) => - decoder(rep) match { - case Valid(decoded) => - Future(decoded) - case Invalid(errs) => - Future.exception(InvalidResponse(rep, errs.map(_.getMessage).toList.mkString("; "))) - } - case None => - Future.exception(InvalidResponse(rep, s"No decoder was found for $mediaType")) - } - } +trait RequestSyntax[Req[Meth <: Method, Accept <: Coproduct, Content, ContentType]] { self: RequestTypes[Req] => - /** - * Send the request, decoding the response as [[K]] - * - * @tparam K The type to which the response will be decoded - * @return A future which will contain a validated response - */ - protected def sendRequest[K](implicit - canBuild: CanBuildRequest[Self], - decodeAll: DecodeAll[K, Accept] - ): Future[K] = - buildRequest match { - case Valid(req) => handleRequest(req).flatMap { rep => - rep.contentType.getOrElse("*/*") match { - case ContentType(RuntimeContentType(mediaType, _)) => - decodeAll.findInstance(mediaType) match { - case Some(decoder) => - decoder(rep) - .leftMap(errs => InvalidResponse(rep, errs.map(_.getMessage).toList.mkString("; "))) - .fold( - Future.exception(_), - Future(_) - ) - case None => - Future.exception(InvalidResponse(rep, s"No decoder was found for $mediaType")) - } - case other => Future.exception(InvalidResponse(rep, s"Content-Type $other is not valid")) - } - } - case Invalid(errs) => Future.exception(RequestBuildingError(errs)) - } - - protected def sendZipRequest[Error, Success](implicit - canBuild: CanBuildRequest[Self], - decodeAllSuccess: DecodeAll[Success, Accept], - decodeAllError: DecodeAll[Error, Accept] - ): Future[(Either[Error, Success], Response)] = buildRequest match { - case Valid(req) => handleRequest(req) - .flatMap { - rep => decodeResponse[Success](rep).map(Right[Error, Success]).map((_, rep)) - }.rescue { - case ErrorResponse(_, rep) => decodeResponse[Error](rep).map(Left[Error, Success]).map((_, rep)) - } - case Invalid(errs) => Future.exception(RequestBuildingError(errs)) - } - - protected def sendRequest[Error, Success](implicit - canBuild: CanBuildRequest[Self], - decodeAllSuccess: DecodeAll[Success, Accept], - decodeAllError: DecodeAll[Error, Accept] - ): Future[Either[Error, Success]] = - sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError).map(_._1) - - } - - case class GetRequest[Accept <: Coproduct]( + def req[Meth <: Method, Accept <: Coproduct]( + method: Meth, url: URL, - headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8, filters: Filter[Request, Response, Request, Response] - ) extends RequestSyntax[Accept, GetRequest[Accept]] { - - def accept[AcceptTypes <: Coproduct]: GetRequest[AcceptTypes] = copy[AcceptTypes]() - def accept[AcceptTypes <: Coproduct](types: String*): GetRequest[AcceptTypes] = - macro CoproductMacros.callAcceptCoproduct - def withHeaders(addHeaders: (String, String)*): GetRequest[Accept] = copy(headers = headers ::: addHeaders.toList) - def withCharset(charset: Charset): GetRequest[Accept] = copy(charset = charset) - def withUrl(url: URL): GetRequest[Accept] = copy(url = url) - def addFilter(filter: Filter[Request, Response, Request, Response]): GetRequest[Accept] = - copy(filters = filter andThen filters) - def resetFilters: GetRequest[Accept] = copy(filters = Filter.identity[Request, Response]) - - def send[K]()(implicit - canBuild: CanBuildRequest[GetRequest[Accept]], - decodeAll: DecodeAll[K, Accept] - ): Future[K] = sendRequest[K](canBuild, decodeAll) - - def send[Error, Success]()(implicit - canBuild: CanBuildRequest[GetRequest[Accept]], - decodeAllError: DecodeAll[Error, Accept], - decodeAllSuccess: DecodeAll[Success, Accept] - ): Future[Either[Error, Success]] = - sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) + ): Req[Meth, Accept, None.type, None.type] - def sendZip[Error, Success]()(implicit - canBuild: CanBuildRequest[GetRequest[Accept]], - decodeAllError: DecodeAll[Error, Accept], - decodeAllSuccess: DecodeAll[Success, Accept] - ): Future[(Either[Error, Success], Response)] = - sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) - } - - case class PostRequest[Content, ContentType, Accept <: Coproduct] ( + def get( url: URL, - content: Content, - headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8, - filters: Filter[Request, Response, Request, Response] - ) extends RequestSyntax[Accept, PostRequest[Content, ContentType, Accept]] { - - def accept[AcceptTypes <: Coproduct]: PostRequest[Content, ContentType, AcceptTypes] = - copy[Content, ContentType, AcceptTypes]() - def accept[AcceptTypes <: Coproduct](types: String*): PostRequest[Content, ContentType, AcceptTypes] = - macro CoproductMacros.callAcceptCoproduct - def withHeaders(addHeaders: (String, String)*): PostRequest[Content, ContentType, Accept] = - copy(headers = headers ::: addHeaders.toList) - def withCharset(charset: Charset): PostRequest[Content, ContentType, Accept] = - copy(charset = charset) - def withUrl(url: URL): PostRequest[Content, ContentType, Accept] = - copy(url = url) - def addFilter(filter: Filter[Request, Response, Request, Response]): PostRequest[Content, ContentType, Accept] = - copy(filters = filter andThen filters) - def resetFilters: PostRequest[Content, ContentType, Accept] = copy(filters = Filter.identity[Request, Response]) - - def withContent[T, Type <: String]( - content: T, - typ: Type)(implicit - witness: Witness.Aux[typ.type] - ): PostRequest[T, typ.type, Accept] = - copy[T, typ.type, Accept](content = content) - - - def withParams( - first: (String, String), - rest: (String, String)* - ): FormPostRequest[Accept, Right[Nothing, NonEmptyList[ValidatedNel[Throwable, FormElement]]]] = { - val firstElement = Valid(SimpleElement(first._1, first._2)) - val restElements = rest.toList.map { - case (key, value) => Valid(SimpleElement(key, value)) - } - FormPostRequest( - url, - Right(NonEmptyList(firstElement, restElements)), - multipart = false, - headers, - charset, - filters - ) - } + filters: Filter[Request, Response, Request, Response] = Filter.identity + ): GetRequest[CNil] = req(Method.Get, url, filters = filters) - def addParams( - first: (String, String), - rest: (String, String)* - ): FormPostRequest[Accept, Right[Nothing, NonEmptyList[ValidatedNel[Throwable, FormElement]]]] = { - withParams(first, rest: _*) - } - - def addFile[T, ContentType <: String]( - name: String, - content: T, - contentType: ContentType, - filename: Option[String] = None)(implicit - encoder: Encoder[T, ContentType] - ): FormPostRequest[Accept, Right[Nothing, NonEmptyList[ValidatedNel[Throwable, FormElement]]]] = { - val element = encoder.apply(content, charset) map { - buf => FileElement(name, buf, Some(contentType), filename) - } - FormPostRequest( - url, - Right(NonEmptyList(element, Nil)), - multipart = true, - headers, - charset, - filters - ) - } - - def send[K]()(implicit - canBuild: CanBuildRequest[PostRequest[Content, ContentType, Accept]], - decodeAll: DecodeAll[K, Accept] - ): Future[K] = sendRequest[K](canBuild, decodeAll) - - def send[Error, Success]()(implicit - canBuild: CanBuildRequest[PostRequest[Content, ContentType, Accept]], - decodeAllError: DecodeAll[Error, Accept], - decodeAllSuccess: DecodeAll[Success, Accept] - ): Future[Either[Error, Success]] = sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) - - def sendZip[Error, Success]()(implicit - canBuild: CanBuildRequest[PostRequest[Content, ContentType, Accept]], - decodeAllError: DecodeAll[Error, Accept], - decodeAllSuccess: DecodeAll[Success, Accept] - ): Future[(Either[Error, Success], Response)] = - sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) - } - - case class FormPostRequest[ - Accept <: Coproduct, - Elements <: Either[None.type, NonEmptyList[ValidatedNel[Throwable, FormElement]]] - ] ( + def post( url: URL, - form: Elements = Left(None), - multipart: Boolean = false, - headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8, - filters: Filter[Request, Response, Request, Response] - ) extends RequestSyntax[Accept, FormPostRequest[Accept, Elements]] { - - def accept[AcceptTypes <: Coproduct]: FormPostRequest[AcceptTypes, Elements] = - copy[AcceptTypes, Elements]() - def accept[AcceptTypes <: Coproduct](types: String*): FormPostRequest[AcceptTypes, Elements] = - macro CoproductMacros.callAcceptCoproduct - def withHeaders(addHeaders: (String, String)*): FormPostRequest[Accept, Elements] = - copy(headers = headers ::: addHeaders.toList) - def withCharset(charset: Charset): FormPostRequest[Accept, Elements] = - copy(charset = charset) - def withUrl(url: URL): FormPostRequest[Accept, Elements] = - copy(url = url) - def withMultipart(multipart: Boolean): FormPostRequest[Accept, Elements] = - copy(multipart = multipart) - def addFilter(filter: Filter[Request, Response, Request, Response]): FormPostRequest[Accept, Elements] = - copy(filters = filter andThen filters) - def resetFilters: FormPostRequest[Accept, Elements] = copy(filters = Filter.identity[Request, Response]) - - private[request] def withParamsList(params: NonEmptyList[ValidatedNel[Throwable, FormElement]]) = - copy[Accept, Right[Nothing, NonEmptyList[ValidatedNel[Throwable, FormElement]]]]( - form = Right(params) - ) - - def withParams( - first: (String, String), - rest: (String, String)* - ): FormPostRequest[Accept, Right[Nothing, NonEmptyList[ValidatedNel[Throwable, FormElement]]]] = { - val firstElement = Valid(SimpleElement(first._1, first._2)) - val restElements = rest.toList.map { - case (key, value) => Valid(SimpleElement(key, value)) - } - withParamsList(NonEmptyList(firstElement, restElements)) - } - - def addParams( - first: (String, String), - rest: (String, String)* - ): FormPostRequest[Accept, Right[Nothing, NonEmptyList[ValidatedNel[Throwable, FormElement]]]] = { - val firstElement = Valid(SimpleElement(first._1, first._2)) - val restElements = rest.toList.map { - case (key, value) => Valid(SimpleElement(key, value)): ValidatedNel[Throwable, FormElement] - } - val newParams = NonEmptyList(firstElement, restElements) - withParamsList( - form match { - case Left(None) => newParams - case Right(currentParams) => newParams concat currentParams - }) - } - - def addFile[T, ContentType <: String]( - name: String, - content: T, - contentType: ContentType, - filename: Option[String] = None)(implicit - encoder: Encoder[T, ContentType] - ): FormPostRequest[Accept, Right[Nothing, NonEmptyList[ValidatedNel[Throwable, FormElement]]]] = { - val element = encoder.apply(content, charset) map { - buf => FileElement(name, buf, Some(contentType), filename) - } - withParamsList(NonEmptyList(element, form.fold(_ => List.empty, _.toList))) - } - - def send[K]()(implicit - canBuild: CanBuildRequest[FormPostRequest[Accept, Elements]], - decodeAll: DecodeAll[K, Accept] - ): Future[K] = sendRequest[K](canBuild, decodeAll) - - def send[Error, Success]()(implicit - canBuild: CanBuildRequest[FormPostRequest[Accept, Elements]], - decodeAllError: DecodeAll[Error, Accept], - decodeAllSuccess: DecodeAll[Success, Accept] - ): Future[Either[Error, Success]] = sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) + filters: Filter[Request, Response, Request, Response] = Filter.identity + ): PostRequest[CNil, None.type, None.type] = req(Method.Post, url, filters = filters) - def sendZip[Error, Success]()(implicit - canBuild: CanBuildRequest[FormPostRequest[Accept, Elements]], - decodeAllError: DecodeAll[Error, Accept], - decodeAllSuccess: DecodeAll[Success, Accept] - ): Future[(Either[Error, Success], Response)] = - sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) - } - - case class PutRequest[Content, ContentType, Accept <: Coproduct]( + def put( url: URL, - content: Content, - headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8, - filters: Filter[Request, Response, Request, Response] - ) extends RequestSyntax[Accept, PutRequest[Content, ContentType, Accept]] { - - def accept[AcceptTypes <: Coproduct]: PutRequest[Content, ContentType, AcceptTypes] = - copy[Content, ContentType, AcceptTypes]() - def accept[AcceptTypes <: Coproduct](types: String*): PutRequest[Content, ContentType, AcceptTypes] = - macro CoproductMacros.callAcceptCoproduct - def withHeaders(addHeaders: (String, String)*): PutRequest[Content, ContentType, Accept] = - copy(headers = headers ::: addHeaders.toList) - def withCharset(charset: Charset): PutRequest[Content, ContentType, Accept] = - copy(charset = charset) - def withUrl(url: URL): PutRequest[Content, ContentType, Accept] = - copy(url = url) - def addFilter(filter: Filter[Request, Response, Request, Response]): PutRequest[Content, ContentType, Accept] = - copy(filters = filter andThen filters) - def resetFilters: PutRequest[Content, ContentType, Accept] = copy(filters = Filter.identity[Request, Response]) - - def withContent[T, Type <: String]( - content: T, - typ: Type)(implicit - witness: Witness.Aux[typ.type] - ): PutRequest[T, typ.type, Accept] = - copy[T, typ.type, Accept](content = content) + filters: Filter[Request, Response, Request, Response] = Filter.identity + ): PutRequest[CNil, None.type, None.type] = req(Method.Put, url, filters = filters) - def send[K]()(implicit - canBuild: CanBuildRequest[PutRequest[Content, ContentType, Accept]], - decodeAll: DecodeAll[K, Accept] - ): Future[K] = sendRequest[K](canBuild, decodeAll) - - def send[Error, Success]()(implicit - canBuild: CanBuildRequest[PutRequest[Content, ContentType, Accept]], - decodeAllError: DecodeAll[Error, Accept], - decodeAllSuccess: DecodeAll[Success, Accept] - ): Future[Either[Error, Success]] = sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) - - def sendZip[Error, Success]()(implicit - canBuild: CanBuildRequest[PutRequest[Content, ContentType, Accept]], - decodeAllError: DecodeAll[Error, Accept], - decodeAllSuccess: DecodeAll[Success, Accept] - ): Future[(Either[Error, Success], Response)] = - sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) - } + def patch( + url: URL, + filters: Filter[Request, Response, Request, Response] = Filter.identity + ): PatchRequest[CNil, None.type, None.type] = req(Method.Patch, url, filters = filters) - case class HeadRequest( + def head( url: URL, - headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8, - filters: Filter[Request, Response, Request, Response] - ) extends RequestSyntax[Nothing, HeadRequest] { + filters: Filter[Request, Response, Request, Response] = Filter.identity + ): HeadRequest = req[Method.Head.type, CNil](Method.Head, url, filters = filters) - def withHeaders(addHeaders: (String, String)*): HeadRequest = copy(headers = headers ::: addHeaders.toList) - def withCharset(charset: Charset): HeadRequest = copy(charset = charset) - def withUrl(url: URL): HeadRequest = copy(url = url) - def addFilter(filter: Filter[Request, Response, Request, Response]): HeadRequest = - copy(filters = filter andThen filters) - def resetFilters: HeadRequest = copy(filters = Filter.identity[Request, Response]) + def delete( + url: URL, + filters: Filter[Request, Response, Request, Response] = Filter.identity + ): DeleteRequest[CNil] = req(Method.Delete, url, filters = filters) - def send()(implicit - canBuild: CanBuildRequest[HeadRequest], - decodeAll: DecodeAll[Response, Nothing] - ): Future[Response] = sendRequest[Response](canBuild, decodeAll) - } +} - case class DeleteRequest[Accept <: Coproduct]( - url: URL, - headers: List[(String, String)] = List.empty, - charset: Charset = StandardCharsets.UTF_8, - filters: Filter[Request, Response, Request, Response] - ) extends RequestSyntax[Accept, DeleteRequest[Accept]] { +trait RequestTypes[Req[Meth <: Method, Accept <: Coproduct, Content, ContentType]] { - def accept[AcceptTypes <: Coproduct]: DeleteRequest[AcceptTypes] = copy[AcceptTypes]() - def accept[AcceptTypes <: Coproduct](types: String*): DeleteRequest[AcceptTypes] = - macro CoproductMacros.callAcceptCoproduct - def withHeaders(addHeaders: (String, String)*): DeleteRequest[Accept] = - copy(headers = headers ::: addHeaders.toList) - def withCharset(charset: Charset): DeleteRequest[Accept] = copy(charset = charset) - def withUrl(url: URL): DeleteRequest[Accept] = copy(url = url) - def addFilter(filter: Filter[Request, Response, Request, Response]): DeleteRequest[Accept] = - copy(filters = filter andThen filters) - def resetFilters: DeleteRequest[Accept] = copy(filters = Filter.identity[Request, Response]) + type GetRequest[Accept <: Coproduct] = Req[Method.Get.type, Accept, None.type, None.type] + type PostRequest[Accept <: Coproduct, Content, ContentType] = + Req[Method.Post.type, Accept, Content, ContentType] - def send[K]()(implicit - canBuild: CanBuildRequest[DeleteRequest[Accept]], - decodeAll: DecodeAll[K, Accept] - ): Future[K] = sendRequest[K](canBuild, decodeAll) + type FormPostRequest[Accept <: Coproduct] = PostRequest[Accept, Form, MimeContent.WebForm] + type MultipartFormRequest[Accept <: Coproduct] = PostRequest[Accept, MultipartForm, Witness.`"multipart/form-data"`.T] - def send[Error, Success]()(implicit - canBuild: CanBuildRequest[DeleteRequest[Accept]], - decodeAllError: DecodeAll[Error, Accept], - decodeAllSuccess: DecodeAll[Success, Accept] - ): Future[Either[Error, Success]] = sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) + type PutRequest[Accept <: Coproduct, Content, ContentType] = + Req[Method.Put.type, Accept, Content, ContentType] - def sendZip[Error, Success]()(implicit - canBuild: CanBuildRequest[DeleteRequest[Accept]], - decodeAllError: DecodeAll[Error, Accept], - decodeAllSuccess: DecodeAll[Success, Accept] - ): Future[(Either[Error, Success], Response)] = - sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) - } + type HeadRequest = Req[Method.Head.type, CNil, None.type, None.type] + type DeleteRequest[Accept <: Coproduct] = Req[Method.Delete.type, Accept, None.type, None.type] + type PatchRequest[Accept <: Coproduct, Content, ContentType] = + Req[Method.Patch.type, Accept, Content, ContentType] } + +// +// +//sealed trait RequestSyntax[Accept <: Coproduct, Self <: RequestSyntax[Accept, Self]] { self: Self => +// +// val url: URL +// val charset: Charset +// val headers: List[(String, String)] +// val filters: Filter[Request, Response, Request, Response] +// +// def withHeader(name: String, value: String): Self = withHeaders((name, value)) +// def withHeaders(headers: (String, String)*): Self +// def withCharset(charset: Charset): Self +// def withUrl(url: URL): Self +// def addFilter(filter: Filter[Request, Response, Request, Response]): Self +// def resetFilters: Self +// def setFilters(filter: Filter[Request, Response, Request, Response]): Self = resetFilters.addFilter(filter) +// +// def withQuery(query: String): Self = withUrl(new URL(url, url.getFile + "?" + query)) +// def withQueryParams(params: List[(String, String)]): Self = withQuery( +// params.map { +// case (key, value) => URLEncoder.encode(key, charset.name) + "=" + URLEncoder.encode(value, charset.name) +// }.mkString("&") +// ) +// def addQueryParams(params: List[(String, String)]): Self = withQuery( +// Option(url.getQuery).map(_ + "&").getOrElse("") + params.map { +// case (key, value) => URLEncoder.encode(key, charset.name) + "=" + URLEncoder.encode(value, charset.name) +// }.mkString("&") +// ) +// +// def withQueryParams(params: (String, String)*): Self = withQueryParams(params.toList) +// def addQueryParams(params: (String, String)*): Self = addQueryParams(params.toList) +// +// +// protected[featherbed] def buildHeaders[HasUrl]( +// rb: RequestBuilder[HasUrl, Nothing] +// ): RequestBuilder[HasUrl, Nothing] = +// headers.foldLeft(rb) { +// case (builder, (key, value)) => builder.addHeader(key, value) +// } +// +// protected[featherbed] def buildRequest(implicit +// canBuild: CanBuildRequest[Self] +// ): ValidatedNel[Throwable, Request] = canBuild.build(this: Self) +// +// private def cloneRequest(in: Request) = { +// val out = Request() +// out.uri = in.uri +// out.content = in.content +// in.headerMap.foreach { +// case (k, v) => out.headerMap.put(k, v) +// } +// out +// } +// +// private def handleRequest( +// request: Request, +// httpClient: Service[Request, Response], +// numRedirects: Int = 0 +// ): Future[Response] = +// (filters andThen httpClient)(request) flatMap { +// rep => rep.status match { +// case Continue => +// Future.exception(InvalidResponse( +// rep, +// "Received unexpected 100/Continue, but request body was already sent." +// )) +// case SwitchingProtocols => +// Future.exception(InvalidResponse( +// rep, +// "Received unexpected 101/Switching Protocols, but no switch was requested." +// )) +// case s if s.code >= 200 && s.code < 300 => +// Future(rep) +// case MultipleChoices => +// Future.exception(InvalidResponse(rep, "300/Multiple Choices is not yet supported in featherbed.")) +// case MovedPermanently | Found | SeeOther | TemporaryRedirect => +// val attempt = for { +// tooMany <- if (numRedirects > 5) +// Either.left("Too many redirects; giving up") +// else +// Either.right(()) +// location <- Either.fromOption( +// rep.headerMap.get("Location"), +// "Redirect required, but location header not present") +// newUrl <- Either.catchNonFatal(url.toURI.resolve(location)) +// .leftMap(_ => s"Could not resolve Location $location") +// canHandle <- if (newUrl.getHost != url.getHost) +// Either.left("Location points to another host; this isn't supported by featherbed") +// else +// Either.right(()) +// } yield { +// val newReq = cloneRequest(request) +// newReq.uri = List(Option(newUrl.getPath), Option(newUrl.getQuery).map("?" + _)).flatten.mkString +// handleRequest(newReq, httpClient, numRedirects + 1) +// } +// attempt.fold( +// err => Future.exception(InvalidResponse(rep, err)), +// identity +// ) +// case other => Future.exception(ErrorResponse(request, rep)) +// } +// } +// +// +// protected def decodeResponse[T](rep: Response)(implicit decodeAll: DecodeAll[T, Accept]) = +// rep.contentType flatMap ContentType.contentTypePieces match { +// case None => Future.exception(InvalidResponse(rep, "Content-Type header is not present")) +// case Some(RuntimeContentType(mediaType, _)) => decodeAll.instances.find(_.contentType == mediaType) match { +// case Some(decoder) => +// decoder(rep) match { +// case Valid(decoded) => +// Future(decoded) +// case Invalid(errs) => +// Future.exception(InvalidResponse(rep, errs.map(_.getMessage).toList.mkString("; "))) +// } +// case None => +// Future.exception(InvalidResponse(rep, s"No decoder was found for $mediaType")) +// } +// } +// +// /** +// * Send the request, decoding the response as [[K]] +// * +// * @tparam K The type to which the response will be decoded +// * @return A future which will contain a validated response +// */ +// protected def sendRequest[K](implicit +// canBuild: CanBuildRequest[Self], +// decodeAll: DecodeAll[K, Accept], +// httpClient: Service[Request, Response] +// ): Future[K] = +// buildRequest match { +// case Valid(req) => handleRequest(req, httpClient).flatMap { rep => +// rep.contentType.getOrElse("*/*") match { +// case ContentType(RuntimeContentType(mediaType, _)) => +// decodeAll.findInstance(mediaType) match { +// case Some(decoder) => +// decoder(rep) +// .leftMap(errs => InvalidResponse(rep, errs.map(_.getMessage).toList.mkString("; "))) +// .fold( +// Future.exception(_), +// Future(_) +// ) +// case None => +// Future.exception(InvalidResponse(rep, s"No decoder was found for $mediaType")) +// } +// case other => Future.exception(InvalidResponse(rep, s"Content-Type $other is not valid")) +// } +// } +// case Invalid(errs) => Future.exception(RequestBuildingError(errs)) +// } +// +// protected def sendZipRequest[Error, Success](implicit +// canBuild: CanBuildRequest[Self], +// decodeAllSuccess: DecodeAll[Success, Accept], +// decodeAllError: DecodeAll[Error, Accept], +// httpClient: Service[Request, Response] +// ): Future[(Either[Error, Success], Response)] = buildRequest match { +// case Valid(req) => handleRequest(req, httpClient) +// .flatMap { +// rep => decodeResponse[Success](rep).map(Either.right[Error, Success]).map((_, rep)) +// }.rescue { +// case ErrorResponse(_, rep) => decodeResponse[Error](rep).map(Either.left[Error, Success]).map((_, rep)) +// } +// case Invalid(errs) => Future.exception(RequestBuildingError(errs)) +// } +// +// protected def sendRequest[Error, Success](implicit +// canBuild: CanBuildRequest[Self], +// decodeAllSuccess: DecodeAll[Success, Accept], +// decodeAllError: DecodeAll[Error, Accept], +// httpClient: Service[Request, Response] +// ): Future[Either[Error, Success]] = +// sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError, httpClient).map(_._1) +// +//} +// +//case class GetRequest[Accept <: Coproduct]( +// url: URL, +// headers: List[(String, String)] = List.empty, +// charset: Charset = StandardCharsets.UTF_8, +// filters: Filter[Request, Response, Request, Response], +// httpClient: Service[Request, Response] +//) extends RequestSyntax[Accept, GetRequest[Accept]] { +// +// def accept[AcceptTypes <: Coproduct]: GetRequest[AcceptTypes] = copy[AcceptTypes]() +// def accept[AcceptTypes <: Coproduct](types: String*): GetRequest[AcceptTypes] = +// macro CoproductMacros.callAcceptCoproduct +// def withHeaders(addHeaders: (String, String)*): GetRequest[Accept] = copy(headers = headers ::: addHeaders.toList) +// def withCharset(charset: Charset): GetRequest[Accept] = copy(charset = charset) +// def withUrl(url: URL): GetRequest[Accept] = copy(url = url) +// def addFilter(filter: Filter[Request, Response, Request, Response]): GetRequest[Accept] = +// copy(filters = filter andThen filters) +// def resetFilters: GetRequest[Accept] = copy(filters = Filter.identity[Request, Response]) +// +// def send[K]()(implicit +// canBuild: CanBuildRequest[GetRequest[Accept]], +// decodeAll: DecodeAll[K, Accept] +// ): Future[K] = sendRequest[K](canBuild, decodeAll, httpClient) +// +// def send[Error, Success]()(implicit +// canBuild: CanBuildRequest[GetRequest[Accept]], +// decodeAllError: DecodeAll[Error, Accept], +// decodeAllSuccess: DecodeAll[Success, Accept] +// ): Future[Either[Error, Success]] = +// sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError, httpClient) +// +// def sendZip[Error, Success]()(implicit +// canBuild: CanBuildRequest[GetRequest[Accept]], +// decodeAllError: DecodeAll[Error, Accept], +// decodeAllSuccess: DecodeAll[Success, Accept] +// ): Future[(Either[Error, Success], Response)] = +// sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError, httpClient) +//} +// +//case class PostRequest[Content, ContentType, Accept <: Coproduct] ( +// url: URL, +// content: Content, +// headers: List[(String, String)] = List.empty, +// charset: Charset = StandardCharsets.UTF_8, +// filters: Filter[Request, Response, Request, Response], +// httpClient: Service[Request, Response] +//) extends RequestSyntax[Accept, PostRequest[Content, ContentType, Accept]] { +// +// def accept[AcceptTypes <: Coproduct]: PostRequest[Content, ContentType, AcceptTypes] = +// copy[Content, ContentType, AcceptTypes]() +// def accept[AcceptTypes <: Coproduct](types: String*): PostRequest[Content, ContentType, AcceptTypes] = +// macro CoproductMacros.callAcceptCoproduct +// def withHeaders(addHeaders: (String, String)*): PostRequest[Content, ContentType, Accept] = +// copy(headers = headers ::: addHeaders.toList) +// def withCharset(charset: Charset): PostRequest[Content, ContentType, Accept] = +// copy(charset = charset) +// def withUrl(url: URL): PostRequest[Content, ContentType, Accept] = +// copy(url = url) +// def addFilter(filter: Filter[Request, Response, Request, Response]): PostRequest[Content, ContentType, Accept] = +// copy(filters = filter andThen filters) +// def resetFilters: PostRequest[Content, ContentType, Accept] = copy(filters = Filter.identity[Request, Response]) +// +// def withContent[T, Type <: String]( +// content: T, +// typ: Type)(implicit +// witness: Witness.Aux[typ.type] +// ): PostRequest[T, typ.type, Accept] = +// copy[T, typ.type, Accept](content = content) +// +// +// def withParams( +// first: (String, String), +// rest: (String, String)* +// ): FormPostRequest[Accept, FormRight] = { +// val firstElement = Valid(SimpleElement(first._1, first._2)) +// val restElements = rest.toList.map { +// case (key, value) => Valid(SimpleElement(key, value)) +// } +// FormPostRequest( +// url, +// Right(NonEmptyList(firstElement, restElements)), +// multipart = false, +// headers, +// charset, +// filters +// ) +// } +// +// def addParams( +// first: (String, String), +// rest: (String, String)* +// ): FormPostRequest[Accept, FormRight] = { +// withParams(first, rest: _*) +// } +// +// def addFile[T, ContentType <: String]( +// name: String, +// content: T, +// contentType: ContentType, +// filename: Option[String] = None)(implicit +// encoder: Encoder[T, ContentType] +// ): FormPostRequest[Accept, FormRight] = { +// val element = encoder.apply(content, charset) map { +// buf => FileElement(name, buf, Some(contentType), filename) +// } +// FormPostRequest( +// url, +// Right(NonEmptyList(element, Nil)), +// multipart = true, +// headers, +// charset, +// filters +// ) +// } +// +// def send[K]()(implicit +// canBuild: CanBuildRequest[PostRequest[Content, ContentType, Accept]], +// decodeAll: DecodeAll[K, Accept] +// ): Future[K] = sendRequest[K](canBuild, decodeAll, httpClient) +// +// def send[Error, Success]()(implicit +// canBuild: CanBuildRequest[PostRequest[Content, ContentType, Accept]], +// decodeAllError: DecodeAll[Error, Accept], +// decodeAllSuccess: DecodeAll[Success, Accept] +// ): Future[Either[Error, Success]] = sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) +// +// def sendZip[Error, Success]()(implicit +// canBuild: CanBuildRequest[PostRequest[Content, ContentType, Accept]], +// decodeAllError: DecodeAll[Error, Accept], +// decodeAllSuccess: DecodeAll[Success, Accept] +// ): Future[(Either[Error, Success], Response)] = +// sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) +//} +// +//case class FormPostRequest[ +// Accept <: Coproduct, +// Elements <: Either[None.type, NonEmptyList[ValidatedNel[Throwable, FormElement]]] +//] ( +// url: URL, +// form: Elements = Left(None), +// multipart: Boolean = false, +// headers: List[(String, String)] = List.empty, +// charset: Charset = StandardCharsets.UTF_8, +// filters: Filter[Request, Response, Request, Response] +//) extends RequestSyntax[Accept, FormPostRequest[Accept, Elements]] { +// +// def accept[AcceptTypes <: Coproduct]: FormPostRequest[AcceptTypes, Elements] = +// copy[AcceptTypes, Elements]() +// def accept[AcceptTypes <: Coproduct](types: String*): FormPostRequest[AcceptTypes, Elements] = +// macro CoproductMacros.callAcceptCoproduct +// def withHeaders(addHeaders: (String, String)*): FormPostRequest[Accept, Elements] = +// copy(headers = headers ::: addHeaders.toList) +// def withCharset(charset: Charset): FormPostRequest[Accept, Elements] = +// copy(charset = charset) +// def withUrl(url: URL): FormPostRequest[Accept, Elements] = +// copy(url = url) +// def withMultipart(multipart: Boolean): FormPostRequest[Accept, Elements] = +// copy(multipart = multipart) +// def addFilter(filter: Filter[Request, Response, Request, Response]): FormPostRequest[Accept, Elements] = +// copy(filters = filter andThen filters) +// def resetFilters: FormPostRequest[Accept, Elements] = copy(filters = Filter.identity[Request, Response]) +// +// private[request] def withParamsList(params: NonEmptyList[ValidatedNel[Throwable, FormElement]]) = +// copy[Accept, FormRight]( +// form = Right(params) +// ) +// +// def withParams( +// first: (String, String), +// rest: (String, String)* +// ): FormPostRequest[Accept, FormRight] = { +// val firstElement = Valid(SimpleElement(first._1, first._2)) +// val restElements = rest.toList.map { +// case (key, value) => Valid(SimpleElement(key, value)) +// } +// withParamsList(NonEmptyList(firstElement, restElements)) +// } +// +// def addParams( +// first: (String, String), +// rest: (String, String)* +// ): FormPostRequest[Accept, FormRight] = { +// val firstElement = Valid(SimpleElement(first._1, first._2)) +// val restElements = rest.toList.map { +// case (key, value) => Valid(SimpleElement(key, value)): ValidatedNel[Throwable, FormElement] +// } +// val newParams = NonEmptyList(firstElement, restElements) +// withParamsList( +// form match { +// case Left(None) => newParams +// case Right(currentParams) => newParams concat currentParams +// }) +// } +// +// def addFile[T, ContentType <: String]( +// name: String, +// content: T, +// contentType: ContentType, +// filename: Option[String] = None)(implicit +// encoder: Encoder[T, ContentType] +// ): FormPostRequest[Accept, FormRight] = { +// val element = encoder.apply(content, charset) map { +// buf => FileElement(name, buf, Some(contentType), filename) +// } +// withParamsList(NonEmptyList(element, form.fold(_ => List.empty, _.toList))) +// } +// +// def send[K]()(implicit +// canBuild: CanBuildRequest[FormPostRequest[Accept, Elements]], +// decodeAll: DecodeAll[K, Accept] +// ): Future[K] = sendRequest[K](canBuild, decodeAll) +// +// def send[Error, Success]()(implicit +// canBuild: CanBuildRequest[FormPostRequest[Accept, Elements]], +// decodeAllError: DecodeAll[Error, Accept], +// decodeAllSuccess: DecodeAll[Success, Accept] +// ): Future[Either[Error, Success]] = sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) +// +// def sendZip[Error, Success]()(implicit +// canBuild: CanBuildRequest[FormPostRequest[Accept, Elements]], +// decodeAllError: DecodeAll[Error, Accept], +// decodeAllSuccess: DecodeAll[Success, Accept] +// ): Future[(Either[Error, Success], Response)] = +// sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) +//} +// +//case class PutRequest[Content, ContentType, Accept <: Coproduct]( +// url: URL, +// content: Content, +// headers: List[(String, String)] = List.empty, +// charset: Charset = StandardCharsets.UTF_8, +// filters: Filter[Request, Response, Request, Response] +//) extends RequestSyntax[Accept, PutRequest[Content, ContentType, Accept]] { +// +// def accept[AcceptTypes <: Coproduct]: PutRequest[Content, ContentType, AcceptTypes] = +// copy[Content, ContentType, AcceptTypes]() +// def accept[AcceptTypes <: Coproduct](types: String*): PutRequest[Content, ContentType, AcceptTypes] = +// macro CoproductMacros.callAcceptCoproduct +// def withHeaders(addHeaders: (String, String)*): PutRequest[Content, ContentType, Accept] = +// copy(headers = headers ::: addHeaders.toList) +// def withCharset(charset: Charset): PutRequest[Content, ContentType, Accept] = +// copy(charset = charset) +// def withUrl(url: URL): PutRequest[Content, ContentType, Accept] = +// copy(url = url) +// def addFilter(filter: Filter[Request, Response, Request, Response]): PutRequest[Content, ContentType, Accept] = +// copy(filters = filter andThen filters) +// def resetFilters: PutRequest[Content, ContentType, Accept] = copy(filters = Filter.identity[Request, Response]) +// +// def withContent[T, Type <: String]( +// content: T, +// typ: Type)(implicit +// witness: Witness.Aux[typ.type] +// ): PutRequest[T, typ.type, Accept] = +// copy[T, typ.type, Accept](content = content) +// +// def send[K]()(implicit +// canBuild: CanBuildRequest[PutRequest[Content, ContentType, Accept]], +// decodeAll: DecodeAll[K, Accept] +// ): Future[K] = sendRequest[K](canBuild, decodeAll) +// +// def send[Error, Success]()(implicit +// canBuild: CanBuildRequest[PutRequest[Content, ContentType, Accept]], +// decodeAllError: DecodeAll[Error, Accept], +// decodeAllSuccess: DecodeAll[Success, Accept] +// ): Future[Either[Error, Success]] = sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) +// +// def sendZip[Error, Success]()(implicit +// canBuild: CanBuildRequest[PutRequest[Content, ContentType, Accept]], +// decodeAllError: DecodeAll[Error, Accept], +// decodeAllSuccess: DecodeAll[Success, Accept] +// ): Future[(Either[Error, Success], Response)] = +// sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) +//} +// +//case class HeadRequest( +// url: URL, +// headers: List[(String, String)] = List.empty, +// charset: Charset = StandardCharsets.UTF_8, +// filters: Filter[Request, Response, Request, Response] +//) extends RequestSyntax[Nothing, HeadRequest] { +// +// def withHeaders(addHeaders: (String, String)*): HeadRequest = copy(headers = headers ::: addHeaders.toList) +// def withCharset(charset: Charset): HeadRequest = copy(charset = charset) +// def withUrl(url: URL): HeadRequest = copy(url = url) +// def addFilter(filter: Filter[Request, Response, Request, Response]): HeadRequest = +// copy(filters = filter andThen filters) +// def resetFilters: HeadRequest = copy(filters = Filter.identity[Request, Response]) +// +// def send()(implicit +// canBuild: CanBuildRequest[HeadRequest], +// decodeAll: DecodeAll[Response, Nothing] +// ): Future[Response] = sendRequest[Response](canBuild, decodeAll) +//} +// +//case class DeleteRequest[Accept <: Coproduct]( +// url: URL, +// headers: List[(String, String)] = List.empty, +// charset: Charset = StandardCharsets.UTF_8, +// filters: Filter[Request, Response, Request, Response] +//) extends RequestSyntax[Accept, DeleteRequest[Accept]] { +// +// def accept[AcceptTypes <: Coproduct]: DeleteRequest[AcceptTypes] = copy[AcceptTypes]() +// def accept[AcceptTypes <: Coproduct](types: String*): DeleteRequest[AcceptTypes] = +// macro CoproductMacros.callAcceptCoproduct +// def withHeaders(addHeaders: (String, String)*): DeleteRequest[Accept] = +// copy(headers = headers ::: addHeaders.toList) +// def withCharset(charset: Charset): DeleteRequest[Accept] = copy(charset = charset) +// def withUrl(url: URL): DeleteRequest[Accept] = copy(url = url) +// def addFilter(filter: Filter[Request, Response, Request, Response]): DeleteRequest[Accept] = +// copy(filters = filter andThen filters) +// def resetFilters: DeleteRequest[Accept] = copy(filters = Filter.identity[Request, Response]) +// +// def send[K]()(implicit +// canBuild: CanBuildRequest[DeleteRequest[Accept]], +// decodeAll: DecodeAll[K, Accept] +// ): Future[K] = sendRequest[K](canBuild, decodeAll) +// +// def send[Error, Success]()(implicit +// canBuild: CanBuildRequest[DeleteRequest[Accept]], +// decodeAllError: DecodeAll[Error, Accept], +// decodeAllSuccess: DecodeAll[Success, Accept] +// ): Future[Either[Error, Success]] = sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) +// +// def sendZip[Error, Success]()(implicit +// canBuild: CanBuildRequest[DeleteRequest[Accept]], +// decodeAllError: DecodeAll[Error, Accept], +// decodeAllSuccess: DecodeAll[Success, Accept] +// ): Future[(Either[Error, Success], Response)] = +// sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) +//} +// +// diff --git a/featherbed-core/src/main/scala/featherbed/request/package.scala b/featherbed-core/src/main/scala/featherbed/request/package.scala new file mode 100644 index 0000000..8239ae9 --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/request/package.scala @@ -0,0 +1,9 @@ +package featherbed + +import cats.data.{NonEmptyList, ValidatedNel} +import com.twitter.finagle.http.FormElement + +package object request { + + type FormRight = Right[None.type, NonEmptyList[ValidatedNel[Throwable, FormElement]]] +} diff --git a/featherbed-core/src/main/scala/featherbed/support/AcceptHeader.scala b/featherbed-core/src/main/scala/featherbed/support/AcceptHeader.scala index 1b40d23..9114f38 100644 --- a/featherbed-core/src/main/scala/featherbed/support/AcceptHeader.scala +++ b/featherbed-core/src/main/scala/featherbed/support/AcceptHeader.scala @@ -4,19 +4,20 @@ import shapeless.{:+:, CNil, Coproduct, Witness} sealed trait AcceptHeader[Accept <: Coproduct] { def contentTypes: List[String] - override def toString = contentTypes.mkString(", ") + ", */*; q=0" + override def toString: String = contentTypes.mkString(", ") + ", */*; q=0" } object AcceptHeader { implicit val cnil = new AcceptHeader[CNil] { - val contentTypes = List() + final val contentTypes = List() + final override def toString: String = "*/*" } implicit def ccons[H <: String, T <: Coproduct](implicit witness: Witness.Aux[H], tailHeader: AcceptHeader[T] ): AcceptHeader[H :+: T] = new AcceptHeader[H :+: T] { - val contentTypes = witness.value :: tailHeader.contentTypes + final val contentTypes: List[String] = witness.value :: tailHeader.contentTypes } } diff --git a/featherbed-core/src/main/scala/featherbed/support/package.scala b/featherbed-core/src/main/scala/featherbed/support/package.scala index 985c57c..66b6895 100644 --- a/featherbed-core/src/main/scala/featherbed/support/package.scala +++ b/featherbed-core/src/main/scala/featherbed/support/package.scala @@ -24,18 +24,18 @@ or you may be missing Decoder instances for some content types. implicit def one[H, A](implicit headInstance: content.Decoder.Aux[H, A] ): DecodeAll[A, H :+: CNil] = new DecodeAll[A, H :+: CNil] { - val instances = headInstance :: Nil + final val instances: List[content.Decoder.Aux[_, A]] = headInstance :: Nil } implicit def ccons[H, A, T <: Coproduct](implicit headInstance: content.Decoder.Aux[H, A], tailInstances: DecodeAll[A, T]): DecodeAll[A, H :+: T] = new DecodeAll[A, H :+: T] { - val instances = headInstance :: tailInstances.instances + final val instances: List[content.Decoder.Aux[_, A]] = headInstance :: tailInstances.instances } - implicit val decodeResponse = new DecodeAll[Response, Nothing] { - val instances = new content.Decoder[Response] { + implicit val decodeResponse: DecodeAll[Response, CNil] = new DecodeAll[Response, CNil] { + final val instances: List[content.Decoder.Aux[CNil, Response]] = new content.Decoder[CNil] { type Out = Response val contentType = "*/*" def apply(response: Response) = Valid(response) diff --git a/featherbed-core/src/test/scala/featherbed/fixture/package.scala b/featherbed-core/src/test/scala/featherbed/fixture/package.scala index 9e51eec..e69de29 100644 --- a/featherbed-core/src/test/scala/featherbed/fixture/package.scala +++ b/featherbed-core/src/test/scala/featherbed/fixture/package.scala @@ -1,35 +0,0 @@ -package featherbed - -import java.net.URL - -import com.twitter.finagle.{Filter, Http} -import com.twitter.finagle.http.{Request, Response} -import org.scalamock.matchers.Matcher -import org.scalamock.scalatest.MockFactory - -package object fixture { - private[fixture] class MockClient ( - baseUrl: URL, - filter: Filter[Request, Response, Request, Response] - ) extends Client(baseUrl) { - override def clientTransform(c: Http.Client) = c.filtered(filter) - } - - trait ClientTest { self: MockFactory => - class TransportRequestMatcher(f: Request => Unit) extends Matcher[Any] { - override def canEqual(x: Any) = x match { - case x: Request => true - case _ => false - } - override def safeEquals(that: Any): Boolean = that match { - case x: Request => f(x); true - case _ => false - } - } - - def request(f: Request => Unit): TransportRequestMatcher = new TransportRequestMatcher(f) - - def mockClient(url: String, filter: Filter[Request, Response, Request, Response]): Client = - new MockClient(new URL(url), filter) - } -} diff --git a/featherbed-core/src/test/scala/featherbed/ClientSpec.scala b/featherbed-test/src/test/scala/featherbed/ClientSpec.scala similarity index 100% rename from featherbed-core/src/test/scala/featherbed/ClientSpec.scala rename to featherbed-test/src/test/scala/featherbed/ClientSpec.scala diff --git a/featherbed-core/src/test/scala/featherbed/ErrorHandlingSpec.scala b/featherbed-test/src/test/scala/featherbed/ErrorHandlingSpec.scala similarity index 97% rename from featherbed-core/src/test/scala/featherbed/ErrorHandlingSpec.scala rename to featherbed-test/src/test/scala/featherbed/ErrorHandlingSpec.scala index d484479..7cbef18 100644 --- a/featherbed-core/src/test/scala/featherbed/ErrorHandlingSpec.scala +++ b/featherbed-test/src/test/scala/featherbed/ErrorHandlingSpec.scala @@ -1,14 +1,21 @@ package featherbed +import java.nio.charset.Charset + +import featherbed.circe._ +import featherbed.content.Encoder import cats.data.{NonEmptyList, Validated} import cats.data.Validated.{Invalid, Valid} import com.twitter.finagle.{Service, SimpleFilter} -import com.twitter.finagle.http.{Request, Response} +import com.twitter.finagle.http.{Method, Request, Response} import com.twitter.io.Buf import com.twitter.util.{Await, Future} import featherbed.content.{Decoder, Encoder} import featherbed.fixture.ClientTest import featherbed.request._ +import featherbed.request._ +import io.circe.generic.auto._ +import io.circe.syntax._ import org.scalamock.scalatest.MockFactory import org.scalatest.FreeSpec import shapeless.Witness diff --git a/featherbed-circe/src/test/scala/featherbed/circe/CirceSpec.scala b/featherbed-test/src/test/scala/featherbed/circe/CirceSpec.scala similarity index 96% rename from featherbed-circe/src/test/scala/featherbed/circe/CirceSpec.scala rename to featherbed-test/src/test/scala/featherbed/circe/CirceSpec.scala index a0ddfb8..3346941 100644 --- a/featherbed-circe/src/test/scala/featherbed/circe/CirceSpec.scala +++ b/featherbed-test/src/test/scala/featherbed/circe/CirceSpec.scala @@ -1,5 +1,8 @@ package featherbed.circe +import cats.implicits._ +import io.circe.parser.parse +import io.circe.syntax._ import cats.implicits._ import com.twitter.util.Future import io.circe._ @@ -7,8 +10,7 @@ import io.circe.generic.auto._ import io.circe.parser.parse import io.circe.syntax._ import org.scalatest.FlatSpec -import shapeless.{Coproduct, Witness} -import shapeless.union.Union +import shapeless.Coproduct case class Foo(someText: String, someInt: Int) @@ -52,9 +54,9 @@ class CirceSpec extends FlatSpec { } "API example" should "compile" in { - import shapeless.Coproduct import java.net.URL import com.twitter.util.Await + import shapeless.Coproduct case class Post(userId: Int, id: Int, title: String, body: String) case class Comment(postId: Int, id: Int, name: String, email: String, body: String) diff --git a/featherbed-test/src/test/scala/featherbed/package.scala b/featherbed-test/src/test/scala/featherbed/package.scala new file mode 100644 index 0000000..f0aece4 --- /dev/null +++ b/featherbed-test/src/test/scala/featherbed/package.scala @@ -0,0 +1,33 @@ +import java.net.URL + +import com.twitter.finagle.{Filter, Http} +import com.twitter.finagle.http.{Request, Response} +import org.scalamock.matchers.Matcher +import org.scalamock.scalatest.MockFactory + +package object featherbed { + private[featherbed] class MockClient ( + baseUrl: URL, + filter: Filter[Request, Response, Request, Response] + ) extends Client(baseUrl) { + override def clientTransform(c: Http.Client) = c.filtered(filter) + } + + trait ClientTest { self: MockFactory => + class TransportRequestMatcher(f: Request => Unit) extends Matcher[Any] { + override def canEqual(x: Any) = x match { + case x: Request => true + case _ => false + } + override def safeEquals(that: Any): Boolean = that match { + case x: Request => f(x); true + case _ => false + } + } + + def request(f: Request => Unit): TransportRequestMatcher = new TransportRequestMatcher(f) + + def mockClient(url: String, filter: Filter[Request, Response, Request, Response]): Client = + new MockClient(new URL(url), filter) + } +} From 7298ccc888ea100daa4c774f7ff07eab4977e541 Mon Sep 17 00:00:00 2001 From: Jeremy Smith Date: Tue, 9 May 2017 09:02:01 -0700 Subject: [PATCH 04/10] API Changes This commit makes the following changes: * Enable a new preferred API, `toService`. This API allows for creating a service from a request. Rather than specifying content up-front, you can create a function `Content => Future[Result]` using the `toService` API, which gives a reusable function from a request specification. * Major refactoring of types. All request types have been unified to a single `HTTPRequest` type, which is not path-dependent. A `ClientRequest` type also wraps this with a provided client. --- build.sbt | 14 +- docs/src/main/tut/02-basic-usage.md | 4 +- featherbed-circe/build.sbt | 9 - .../scala/featherbed/content/Decoder.scala | 66 ++++ .../scala/featherbed/content/Encoder.scala | 37 ++ .../main/scala/featherbed/content/Form.scala | 8 +- .../featherbed/content/MimeContent.scala | 3 +- .../featherbed/content/ToFormParams.scala | 90 +++++ .../featherbed/content/ToQueryParams.scala | 69 ++++ .../scala/featherbed/content/package.scala | 83 ----- .../littlemacros/CoproductMacros.scala | 4 +- .../featherbed/request/CanBuildRequest.scala | 64 ++-- .../featherbed/request/ClientRequest.scala | 248 ++++++++----- .../featherbed/request/CodecFilter.scala | 109 ++++++ .../featherbed/request/HTTPRequest.scala | 50 ++- .../featherbed/request/QueryFilter.scala | 17 + .../featherbed/request/RequestSyntax.scala | 4 +- .../scala/featherbed/support/DecodeAll.scala | 44 +++ .../scala/featherbed/support/package.scala | 45 --- .../test/scala/featherbed/ClientSpec.scala | 335 +++++++++++++----- .../scala/featherbed/ErrorHandlingSpec.scala | 3 +- .../scala/featherbed/circe/CirceSpec.scala | 1 + 22 files changed, 931 insertions(+), 376 deletions(-) create mode 100644 featherbed-core/src/main/scala/featherbed/content/Decoder.scala create mode 100644 featherbed-core/src/main/scala/featherbed/content/Encoder.scala create mode 100644 featherbed-core/src/main/scala/featherbed/content/ToFormParams.scala create mode 100644 featherbed-core/src/main/scala/featherbed/content/ToQueryParams.scala delete mode 100644 featherbed-core/src/main/scala/featherbed/content/package.scala create mode 100644 featherbed-core/src/main/scala/featherbed/request/CodecFilter.scala create mode 100644 featherbed-core/src/main/scala/featherbed/request/QueryFilter.scala create mode 100644 featherbed-core/src/main/scala/featherbed/support/DecodeAll.scala delete mode 100644 featherbed-core/src/main/scala/featherbed/support/package.scala diff --git a/build.sbt b/build.sbt index 84ea157..6a83fc5 100644 --- a/build.sbt +++ b/build.sbt @@ -28,9 +28,7 @@ lazy val baseSettings = docSettings ++ Seq( "org.scalamock" %% "scalamock-scalatest-support" % "3.6.0" % "test", "org.scalatest" %% "scalatest" % "3.0.3" % "test" ), - resolvers += Resolver.sonatypeRepo("snapshots"), - dependencyUpdatesFailBuild := false, - dependencyUpdatesExclusions := moduleFilter("org.scala-lang") + resolvers += Resolver.sonatypeRepo("snapshots") ) lazy val publishSettings = Seq( @@ -75,8 +73,14 @@ lazy val `featherbed-core` = project .settings(allSettings) lazy val `featherbed-circe` = project - .settings(allSettings) - .dependsOn(`featherbed-core`) + .settings( + libraryDependencies ++= Seq( + "io.circe" %% "circe-core" % circeVersion, + "io.circe" %% "circe-parser" % circeVersion, + "io.circe" %% "circe-generic" % circeVersion + ), + allSettings + ).dependsOn(`featherbed-core`) lazy val `featherbed-test` = project .settings( diff --git a/docs/src/main/tut/02-basic-usage.md b/docs/src/main/tut/02-basic-usage.md index a51af7e..80e42e9 100644 --- a/docs/src/main/tut/02-basic-usage.md +++ b/docs/src/main/tut/02-basic-usage.md @@ -42,8 +42,8 @@ Now you can make some requests: import com.twitter.util.Await Await.result { - val request = client.get("test/resource").send[Response]() - request map { + val request = client.get("test/resource").toService[Response] + request() map { response => response.contentString } } diff --git a/featherbed-circe/build.sbt b/featherbed-circe/build.sbt index a425177..e69de29 100644 --- a/featherbed-circe/build.sbt +++ b/featherbed-circe/build.sbt @@ -1,9 +0,0 @@ -name := "featherbed-circe" - -val circeVersion = "0.9.0" - -libraryDependencies ++= Seq( - "io.circe" %% "circe-core" % circeVersion, - "io.circe" %% "circe-generic" % circeVersion, - "io.circe" %% "circe-parser" % circeVersion -) diff --git a/featherbed-core/src/main/scala/featherbed/content/Decoder.scala b/featherbed-core/src/main/scala/featherbed/content/Decoder.scala new file mode 100644 index 0000000..21a7525 --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/content/Decoder.scala @@ -0,0 +1,66 @@ +package featherbed.content + +import java.nio.charset.{Charset, CodingErrorAction} + +import scala.util.Try + +import cats.data.{Validated, ValidatedNel} +import com.twitter.finagle.http.Response +import com.twitter.io.Buf +import shapeless.Witness +import sun.nio.cs.ThreadLocalCoders + + +trait Decoder[ContentType] { + type Out + val contentType: String //widened version of ContentType + def apply(buf: Response): ValidatedNel[Throwable, Out] +} + +object Decoder extends LowPriorityDecoders { + type Aux[CT, A1] = Decoder[CT] { type Out = A1 } + + def of[T <: String, A1](t: T)(fn: Response => ValidatedNel[Throwable, A1]): Decoder.Aux[t.type, A1] = + new Decoder[t.type] { + type Out = A1 + val contentType = t + def apply(response: Response) = fn(response) + } + + def decodeString(response: Response): ValidatedNel[Throwable, String] = { + Validated.fromTry(Try { + response.charset.map(Charset.forName).getOrElse(Charset.defaultCharset) + }).andThen { charset: Charset => + val decoder = ThreadLocalCoders.decoderFor(charset) + Validated.fromTry( + Try( + decoder + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(Buf.ByteBuffer.Owned.extract(response.content).asReadOnlyBuffer()))).map[String](_.toString) + }.toValidatedNel + } + + implicit def decodeEither[Error, Success, ContentType <: String](implicit + decodeError: Aux[ContentType, Error], + decodeSuccess: Aux[ContentType, Success], + witness: Witness.Aux[ContentType] + ): Aux[ContentType, Either[Error, Success]] = new Decoder[ContentType] { + type Out = Either[Error, Success] + val contentType: String = witness.value + def apply(buf: Response): ValidatedNel[Throwable, Either[Error, Success]] = + decodeSuccess(buf).map(Right(_)) orElse decodeError(buf).map(Left(_)) + } +} + +private[featherbed] trait LowPriorityDecoders { + implicit val plainTextDecoder: Decoder.Aux[Witness.`"text/plain"`.T, String] = Decoder.of("text/plain") { + response => Decoder.decodeString(response) + } + + implicit val anyResponseDecoder: Decoder.Aux[Nothing, Response] = new Decoder[Nothing] { + type Out = Response + final val contentType: String = "*/*" + final def apply(rep: Response): ValidatedNel[Throwable, Response] = Validated.Valid(rep) + } +} diff --git a/featherbed-core/src/main/scala/featherbed/content/Encoder.scala b/featherbed-core/src/main/scala/featherbed/content/Encoder.scala new file mode 100644 index 0000000..013f8ab --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/content/Encoder.scala @@ -0,0 +1,37 @@ +package featherbed.content + +import java.nio.CharBuffer +import java.nio.charset.{Charset, CodingErrorAction} + +import scala.util.Try + +import cats.data.{Validated, ValidatedNel} +import com.twitter.io.Buf +import shapeless.Witness +import sun.nio.cs.ThreadLocalCoders + + +trait Encoder[A, ForContentType] { + def apply(value: A, charset: Charset): ValidatedNel[Throwable, Buf] +} + +object Encoder extends LowPriorityEncoders { + def of[A, T <: String](t: T)(fn: (A, Charset) => ValidatedNel[Throwable, Buf]): Encoder[A, t.type] = + new Encoder[A, t.type] { + def apply(value: A, charset: Charset) = fn(value, charset) + } + + def encodeString(value: String, charset: Charset): ValidatedNel[Throwable, Buf] = { + val encoder = ThreadLocalCoders.encoderFor(charset) + Validated.fromTry(Try(encoder + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .encode(CharBuffer.wrap(value)))).toValidatedNel.map[Buf](Buf.ByteBuffer.Owned(_)) + } +} + +private[featherbed] trait LowPriorityEncoders { + implicit val plainTextEncoder: Encoder[String, Witness.`"text/plain"`.T] = Encoder.of("text/plain") { + case (value, charset) => Encoder.encodeString(value, charset) + } +} diff --git a/featherbed-core/src/main/scala/featherbed/content/Form.scala b/featherbed-core/src/main/scala/featherbed/content/Form.scala index c8f5812..152d01a 100644 --- a/featherbed-core/src/main/scala/featherbed/content/Form.scala +++ b/featherbed-core/src/main/scala/featherbed/content/Form.scala @@ -1,10 +1,10 @@ package featherbed.content -import cats.data.{NonEmptyList, Validated} +import cats.data.{NonEmptyList, Validated, ValidatedNel} import com.twitter.finagle.http.FormElement -case class Form(params: NonEmptyList[Validated[Throwable, FormElement]]) { - def multipart: MultipartForm = MultipartForm(params) +case class Form(params: NonEmptyList[FormElement]) { + def multipart: MultipartForm = MultipartForm(Validated.valid(params)) } -case class MultipartForm(params: NonEmptyList[Validated[Throwable, FormElement]]) +case class MultipartForm(params: ValidatedNel[Throwable, NonEmptyList[FormElement]]) diff --git a/featherbed-core/src/main/scala/featherbed/content/MimeContent.scala b/featherbed-core/src/main/scala/featherbed/content/MimeContent.scala index 2a42f9c..b29fb1e 100644 --- a/featherbed-core/src/main/scala/featherbed/content/MimeContent.scala +++ b/featherbed-core/src/main/scala/featherbed/content/MimeContent.scala @@ -7,7 +7,8 @@ case class MimeContent[Content, ContentType](content: Content, contentType: Cont object MimeContent { type WebForm = Witness.`"application/x-www-form-urlencoded"`.T - type Json = Witness.`"application/json"`.T + type MultipartForm = Witness.`"multipart/form-data"`.T + type Json = Witness.`"application/json"`.T val NoContent: MimeContent[None.type, None.type] = MimeContent(None, None) def apply[Content, ContentType](content: Content)(implicit diff --git a/featherbed-core/src/main/scala/featherbed/content/ToFormParams.scala b/featherbed-core/src/main/scala/featherbed/content/ToFormParams.scala new file mode 100644 index 0000000..4b8618c --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/content/ToFormParams.scala @@ -0,0 +1,90 @@ +package featherbed.content + +import java.io.{File, FileInputStream} +import java.nio.channels.FileChannel +import scala.util.control.NonFatal + +import cats.Show +import cats.data.{Validated, ValidatedNel} +import cats.data.Validated.{Invalid, Valid} +import cats.instances.list._ +import cats.syntax.traverse._ +import com.twitter.finagle.http.{FileElement, FormElement, SimpleElement} +import com.twitter.io.Buf +import shapeless._ +import shapeless.labelled.FieldType + + +abstract class ToFormParams[T] { + def apply(t: T): ValidatedNel[Throwable, List[FormElement]] +} + +object ToFormParams { + + final case class Instance[T](fn: T => ValidatedNel[Throwable, List[FormElement]]) extends ToFormParams[T] { + def apply(t: T): ValidatedNel[Throwable, List[FormElement]] = fn(t) + } + + implicit val hnil: ToFormParams[HNil] = Instance(hnil => Valid(Nil)) + + implicit def hcons[K <: Symbol, H, T <: HList](implicit + name: Witness.Aux[K], + toFormParamH: ToFormParam[H], + toFormParamsT: ToFormParams[T] + ): ToFormParams[FieldType[K, H] :: T] = Instance { + t => toFormParamsT(t.tail) andThen { + tail => toFormParamH(name.value.name, t.head).map(head => head :: tail) + } + } + + implicit def generic[P <: Product, L <: HList](implicit + gen: LabelledGeneric.Aux[P, L], + toFormParamsL: ToFormParams[L] + ): ToFormParams[P] = Instance(p => toFormParamsL(gen.to(p))) + + implicit val form: ToFormParams[Form] = Instance(form => Validated.valid(form.params.toList)) + implicit val multipartForm: ToFormParams[MultipartForm] = Instance(form => form.params.map(_.toList)) + +} + +abstract class ToFormParam[T] { + def apply(name: String, t: T): ValidatedNel[Throwable, FormElement] +} + +object ToFormParam extends ToFormParam0 { + + final case class Instance[T](fn: (String, T) => ValidatedNel[Throwable, FormElement]) extends ToFormParam[T] { + def apply(name: String, t: T): ValidatedNel[Throwable, FormElement] = fn(name, t) + } + + implicit val file: ToFormParam[File] = Instance { + (name, f) => + try { + val stream = new FileInputStream(f) + val channel = stream.getChannel + val buf = channel.map(FileChannel.MapMode.READ_ONLY, 0, f.length()) + channel.close() + Valid(FileElement(name, Buf.ByteBuffer.Owned(buf), filename = Some(f.getName))) + } catch { + case NonFatal(err) => Invalid(err).toValidatedNel + } + } + + + implicit val string: ToFormParam[String] = Instance { + (name, str) => Valid(SimpleElement(name, str)) + } + +} + +trait ToFormParam0 extends ToFormParam1 { + implicit def fromShow[T: Show]: ToFormParam[T] = ToFormParam.Instance { + (name, t) => Valid(SimpleElement(name, Show[T].show(t))) + } +} + +trait ToFormParam1 { + implicit def default[T](implicit lowPriority: LowPriority): ToFormParam[T] = ToFormParam.Instance { + (name, t) => Valid(SimpleElement(name, t.toString)) + } +} diff --git a/featherbed-core/src/main/scala/featherbed/content/ToQueryParams.scala b/featherbed-core/src/main/scala/featherbed/content/ToQueryParams.scala new file mode 100644 index 0000000..38173b9 --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/content/ToQueryParams.scala @@ -0,0 +1,69 @@ +package featherbed.content + + +import scala.collection.generic.CanBuildFrom + +import cats.syntax.either._ +import cats.Show +import shapeless.{:: => /::, _} +import shapeless.labelled.FieldType + +abstract class ToQueryParams[L] { + def apply(l: L): List[(String, String)] +} + +object ToQueryParams extends ToQueryParams0 { + + implicit val empty: ToQueryParams[HNil] = new ToQueryParams[HNil] { + def apply(l: HNil) = Nil + } + + implicit def consSeq[K <: Symbol, F[_], V, T <: HList](implicit + label: Witness.Aux[K], + toQueryParam: ToQueryParam[V], + canBuildFrom: CanBuildFrom[F[V], V, List[V]], + toQueryParamsT: ToQueryParams[T] + ): ToQueryParams[FieldType[K, F[V]] /:: T] = new ToQueryParams[FieldType[K, F[V]] /:: T] { + final def apply(l: FieldType[K, F[V]] /:: T): List[(String, String)] = { + canBuildFrom.apply(l.head: F[V]).result.map { + v => (label.value.name, toQueryParam(v)) + } ::: toQueryParamsT(l.tail) + } + } + +} + +trait ToQueryParams0 { + implicit def cons[K <: Symbol, H, T <: HList](implicit + label: Witness.Aux[K], + toQueryParam: ToQueryParam[H], + toQueryParamsT: ToQueryParams[T] + ): ToQueryParams[FieldType[K, H] /:: T] = new ToQueryParams[FieldType[K, H] /:: T] { + final def apply(l: FieldType[K, H] /:: T): List[(String, String)] = { + (label.value.name, toQueryParam(l.head)) :: toQueryParamsT(l.tail) + } + } + + implicit def generic[P <: Product, L <: HList](implicit + gen: LabelledGeneric.Aux[P, L], + toQueryParamsL: ToQueryParams[L] + ): ToQueryParams[P] = new ToQueryParams[P] { + final def apply(p: P): List[(String, String)] = toQueryParamsL(gen.to(p)) + } +} + +abstract class ToQueryParam[T] { + def apply(t: T): String +} + +object ToQueryParam extends ToQueryParam0 { + implicit def fromShow[T: Show]: ToQueryParam[T] = new ToQueryParam[T] { + def apply(t: T): String = Show[T].show(t) + } +} + +trait ToQueryParam0 { + implicit def default[T](implicit lowPriority: LowPriority): ToQueryParam[T] = new ToQueryParam[T] { + def apply(t: T): String = t.toString + } +} \ No newline at end of file diff --git a/featherbed-core/src/main/scala/featherbed/content/package.scala b/featherbed-core/src/main/scala/featherbed/content/package.scala deleted file mode 100644 index 2c69d77..0000000 --- a/featherbed-core/src/main/scala/featherbed/content/package.scala +++ /dev/null @@ -1,83 +0,0 @@ -package featherbed - -import java.nio.CharBuffer -import java.nio.charset.{Charset, CodingErrorAction} - -import scala.util.Try - -import cats.data.{Validated, ValidatedNel} -import com.twitter.finagle.http.Response -import com.twitter.io.Buf -import shapeless.{CNil, Witness} -import sun.nio.cs.ThreadLocalCoders - -package object content { - - trait Decoder[ContentType] { - type Out - val contentType: String //widened version of ContentType - def apply(buf: Response): ValidatedNel[Throwable, Out] - } - - object Decoder extends LowPriorityDecoders { - type Aux[CT, A1] = Decoder[CT] { type Out = A1 } - - def of[T <: String, A1](t: T)(fn: Response => ValidatedNel[Throwable, A1]): Decoder.Aux[t.type, A1] = - new Decoder[t.type] { - type Out = A1 - val contentType = t - def apply(response: Response) = fn(response) - } - - def decodeString(response: Response): ValidatedNel[Throwable, String] = { - Validated.fromTry(Try { - response.charset.map(Charset.forName).getOrElse(Charset.defaultCharset) - }).andThen { charset: Charset => - val decoder = ThreadLocalCoders.decoderFor(charset) - Validated.fromTry( - Try( - decoder - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(Buf.ByteBuffer.Owned.extract(response.content).asReadOnlyBuffer()))).map[String](_.toString) - }.toValidatedNel - } - } - - private[featherbed] trait LowPriorityDecoders { - implicit val plainTextDecoder: Decoder.Aux[Witness.`"text/plain"`.T, String] = Decoder.of("text/plain") { - response => Decoder.decodeString(response) - } - - implicit val anyResponseDecoder: Decoder.Aux[Nothing, Response] = new Decoder[Nothing] { - type Out = Response - final val contentType: String = "*/*" - final def apply(rep: Response): ValidatedNel[Throwable, Response] = Validated.Valid(rep) - } - } - - trait Encoder[A, ForContentType] { - def apply(value: A, charset: Charset): ValidatedNel[Throwable, Buf] - } - - object Encoder extends LowPriorityEncoders { - def of[A, T <: String](t: T)(fn: (A, Charset) => ValidatedNel[Throwable, Buf]): Encoder[A, t.type] = - new Encoder[A, t.type] { - def apply(value: A, charset: Charset) = fn(value, charset) - } - - def encodeString(value: String, charset: Charset): ValidatedNel[Throwable, Buf] = { - val encoder = ThreadLocalCoders.encoderFor(charset) - Validated.fromTry(Try(encoder - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .encode(CharBuffer.wrap(value)))).toValidatedNel.map[Buf](Buf.ByteBuffer.Owned(_)) - } - } - - private[featherbed] trait LowPriorityEncoders { - implicit val plainTextEncoder: Encoder[String, Witness.`"text/plain"`.T] = Encoder.of("text/plain") { - case (value, charset) => Encoder.encodeString(value, charset) - } - } -} diff --git a/featherbed-core/src/main/scala/featherbed/littlemacros/CoproductMacros.scala b/featherbed-core/src/main/scala/featherbed/littlemacros/CoproductMacros.scala index 9a2afde..0adcca7 100644 --- a/featherbed-core/src/main/scala/featherbed/littlemacros/CoproductMacros.scala +++ b/featherbed-core/src/main/scala/featherbed/littlemacros/CoproductMacros.scala @@ -2,7 +2,7 @@ package featherbed.littlemacros import scala.reflect.macros.whitebox -import shapeless.{:+:, CNil} +import shapeless.{:+:, CNil, Coproduct} class CoproductMacros(val c: whitebox.Context) { import c.universe._ @@ -12,7 +12,7 @@ class CoproductMacros(val c: whitebox.Context) { * @param types Content types * @return Passes through to accept[ContentTypes] */ - def callAcceptCoproduct(types: Tree*): Tree = { + def callAcceptCoproduct[A <: Coproduct: WeakTypeTag](types: Tree*): Tree = { val lhs = c.prefix.tree val accepts = types map { case Literal(const @ Constant(str: String)) => c.internal.constantType(const) diff --git a/featherbed-core/src/main/scala/featherbed/request/CanBuildRequest.scala b/featherbed-core/src/main/scala/featherbed/request/CanBuildRequest.scala index 96b99e7..c5670cf 100644 --- a/featherbed-core/src/main/scala/featherbed/request/CanBuildRequest.scala +++ b/featherbed-core/src/main/scala/featherbed/request/CanBuildRequest.scala @@ -1,16 +1,15 @@ package featherbed.request import scala.annotation.implicitNotFound - import cats.data._ import cats.data.Validated._ import cats.implicits._ -import com.twitter.finagle.http.{FormElement, Request, RequestBuilder} +import com.twitter.finagle.http.{FormElement, Method, Request, RequestBuilder} import com.twitter.finagle.http.RequestConfig.Yes import com.twitter.io.Buf import featherbed.Client import featherbed.content -import featherbed.content.{Form, MultipartForm} +import featherbed.content.{Form, MultipartForm, ToFormParams} import featherbed.support.AcceptHeader import shapeless.{Coproduct, Witness} @@ -39,9 +38,8 @@ object CanBuildRequest { private def baseBuilder[Accept <: Coproduct]( - request: HTTPRequest[_, Accept, _, _] - )( - implicit accept: AcceptHeader[Accept] + request: HTTPRequest[_, Accept, _, _])(implicit + accept: AcceptHeader[Accept] ): RequestBuilder[Yes, Nothing] = { val builder = RequestBuilder().url(request.buildUrl).addHeader("Accept", accept.toString) request.headers.foldLeft(builder) { @@ -70,41 +68,27 @@ object CanBuildRequest { ) } - implicit def canBuildFormPostRequest[Accept <: Coproduct](implicit - accept: AcceptHeader[Accept] - ): CanBuildRequest[HTTPRequest.FormPostRequest[Accept]] = - new CanBuildRequest[HTTPRequest.FormPostRequest[Accept]] { + implicit def canBuildFormPostRequest[Accept <: Coproduct, Content](implicit + accept: AcceptHeader[Accept], + toFormParams: ToFormParams[Content] + ): CanBuildRequest[HTTPRequest.FormPostRequest[Accept, Content]] = + new CanBuildRequest[HTTPRequest.FormPostRequest[Accept, Content]] { def build( - formPostRequest: HTTPRequest.FormPostRequest[Accept] - ): Validated[NonEmptyList[Throwable], Request] = { - formPostRequest.content.content match { - case Form(elems) => - val validated = elems.traverseU(_.toValidatedNel) - - // Finagle takes care of Content-Type header - validated.map { - elems => baseBuilder(formPostRequest).add(elems.toList).buildFormPost(multipart = false) - } - } + formPostRequest: HTTPRequest.FormPostRequest[Accept, Content] + ): Validated[NonEmptyList[Throwable], Request] = toFormParams(formPostRequest.content.content).map { + elems => baseBuilder(formPostRequest).add(elems).buildFormPost(multipart = false) } } - implicit def canBuildMultipartFormPostRequest[Accept <: Coproduct](implicit - accept: AcceptHeader[Accept] - ): CanBuildRequest[HTTPRequest.MultipartFormRequest[Accept]] = - new CanBuildRequest[HTTPRequest.MultipartFormRequest[Accept]] { + implicit def canBuildMultipartFormPostRequest[Accept <: Coproduct, Content](implicit + accept: AcceptHeader[Accept], + toFormParams: ToFormParams[Content] + ): CanBuildRequest[HTTPRequest.MultipartFormRequest[Accept, Content]] = + new CanBuildRequest[HTTPRequest.MultipartFormRequest[Accept, Content]] { def build( - formPostRequest: HTTPRequest.MultipartFormRequest[Accept] - ): Validated[NonEmptyList[Throwable], Request] = { - formPostRequest.content.content match { - case MultipartForm(elems) => - val validated = elems.traverseU(_.toValidatedNel) - - // Finagle takes care of Content-Type header - validated.map { - elems => baseBuilder(formPostRequest).add(elems.toList).buildFormPost(multipart = false) - } - } + formPostRequest: HTTPRequest.MultipartFormRequest[Accept, Content] + ): Validated[NonEmptyList[Throwable], Request] = toFormParams(formPostRequest.content.content).map { + elems => baseBuilder(formPostRequest).add(elems).buildFormPost(multipart = true) } } @@ -162,5 +146,13 @@ object CanBuildRequest { .buildPut(buf) } } + + implicit def canBuildDefinedRequest[ + Meth <: Method, + Accept <: Coproduct + ]: CanBuildRequest[HTTPRequest[Meth, Accept, Request, None.type]] = + new CanBuildRequest[HTTPRequest[Meth, Accept, Request, None.type]] { + def build(req: HTTPRequest[Meth, Accept, Request, None.type]) = Validated.valid(req.content.content) + } } diff --git a/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala b/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala index 80d889f..b53c4a5 100644 --- a/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala +++ b/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala @@ -4,20 +4,22 @@ import java.net.URL import java.nio.charset.Charset import scala.language.experimental.macros - -import cats.data.Validated.{Invalid, Valid} -import cats.data.ValidatedNel -import cats.implicits._ +import cats.syntax.either._ import com.twitter.finagle.{Filter, Service} import com.twitter.finagle.http.{Method, Request, Response} -import com.twitter.finagle.http.Status._ import com.twitter.util.Future import featherbed.Client -import featherbed.content.{Form, MimeContent, MultipartForm} -import featherbed.littlemacros.CoproductMacros -import featherbed.support.{ContentType, DecodeAll, RuntimeContentType} -import shapeless.{CNil, Coproduct, HList, Witness} +import featherbed.content.{Form, MimeContent, ToQueryParams} +import featherbed.littlemacros._ +import featherbed.support.DecodeAll +import shapeless._ +import shapeless.labelled.FieldType +import shapeless.ops.hlist.SelectAll + +/** + * An [[HTTPRequest]] which is already paired with a [[Client]] + */ case class ClientRequest[Meth <: Method, Accept <: Coproduct, Content, ContentType]( request: HTTPRequest[Meth, Accept, Content, ContentType], client: Client @@ -26,8 +28,21 @@ case class ClientRequest[Meth <: Method, Accept <: Coproduct, Content, ContentTy def accept[A <: Coproduct]: ClientRequest[Meth, A, Content, ContentType] = copy[Meth, A, Content, ContentType](request = request.accept[A]) - def accept[A <: Coproduct](types: String*): ClientRequest[Meth, A, Content, ContentType] = - macro CoproductMacros.callAcceptCoproduct + def accept(mimeType: Witness.Lt[String]): ClientRequest[Meth, mimeType.T :+: CNil, Content, ContentType] = + accept[mimeType.T :+: CNil] + + def accept( + mimeTypeA: Witness.Lt[String], + mimeTypeB: Witness.Lt[String] + ): ClientRequest[Meth, mimeTypeA.T :+: mimeTypeB.T :+: CNil, Content, ContentType] = + accept[mimeTypeA.T :+: mimeTypeB.T :+: CNil] + + def accept( + mimeTypeA: Witness.Lt[String], + mimeTypeB: Witness.Lt[String], + mimeTypeC: Witness.Lt[String] + ): ClientRequest[Meth, mimeTypeA.T :+: mimeTypeB.T :+: mimeTypeC.T :+: CNil, Content, ContentType] = + accept[mimeTypeA.T :+: mimeTypeB.T :+: mimeTypeC.T :+: CNil] def withCharset(charset: Charset): ClientRequest[Meth, Accept, Content, ContentType] = copy(request = request.withCharset(charset)) @@ -86,17 +101,17 @@ case class ClientRequest[Meth <: Method, Accept <: Coproduct, Content, ContentTy canBuildRequest: CanBuildRequest[HTTPRequest[Meth, Accept, Content, ContentType]], decodeAll: DecodeAll[K, Accept] ): Future[K] = sendValid().flatMap { - response => decodeResponse[K](response) + response => CodecFilter.decodeResponse[K, Accept](response) } - def send[E, S](implicit + def send[E, S]()(implicit canBuildRequest: CanBuildRequest[HTTPRequest[Meth, Accept, Content, ContentType]], decodeSuccess: DecodeAll[S, Accept], decodeError: DecodeAll[E, Accept] ): Future[Either[E, S]] = sendValid().flatMap { - rep => decodeResponse[S](rep).map(Either.right[E, S]) + rep => CodecFilter.decodeResponse[S, Accept](rep).map(Either.right[E, S]) }.rescue { - case ErrorResponse(_, rep) => decodeResponse[E](rep).map(Either.left[E, S]) + case ErrorResponse(_, rep) => CodecFilter.decodeResponse[E, Accept](rep).map(Either.left[E, S]) } def sendZip[E, S]()(implicit @@ -104,88 +119,37 @@ case class ClientRequest[Meth <: Method, Accept <: Coproduct, Content, ContentTy decodeSuccess: DecodeAll[S, Accept], decodeError: DecodeAll[E, Accept] ): Future[(Either[E, S], Response)] = sendValid().flatMap { - rep => decodeResponse[S](rep).map(Either.right[E, S]).map((_, rep)) + rep => CodecFilter.decodeResponse[S, Accept](rep).map(Either.right[E, S]).map((_, rep)) }.rescue { - case ErrorResponse(_, rep) => decodeResponse[E](rep).map(Either.left[E, S]).map((_, rep)) + case ErrorResponse(_, rep) => CodecFilter.decodeResponse[E, Accept](rep).map(Either.left[E, S]).map((_, rep)) } private def sendValid()(implicit canBuildRequest: CanBuildRequest[HTTPRequest[Meth, Accept, Content, ContentType]] ): Future[Response] = canBuildRequest.build(request).fold( - errs => Future.exception(errs.head), - req => handleRequest(() => req, request.filters, request.buildUrl, client.httpClient, client.maxFollows) + errs => Future.exception(RequestBuildingError(errs)), + req => CodecFilter.handleRequest(() => req, request.filters, request.buildUrl, client.httpClient, client.maxFollows) ) - private def decodeResponse[K](rep: Response)(implicit decodeAll: DecodeAll[K, Accept]) = - rep.contentType flatMap ContentType.contentTypePieces match { - case None => Future.exception(InvalidResponse(rep, "Content-Type header is not present")) - case Some(RuntimeContentType(mediaType, _)) => decodeAll.instances.find(_.contentType == mediaType) match { - case Some(decoder) => - decoder(rep) match { - case Valid(decoded) => - Future(decoded) - case Invalid(errs) => - Future.exception(InvalidResponse(rep, errs.map(_.getMessage).toList.mkString("; "))) - } - case None => - Future.exception(InvalidResponse(rep, s"No decoder was found for $mediaType")) - } - } - - private def handleRequest( - request: () => Request, - filters: Filter[Request, Response, Request, Response], - url: URL, - httpClient: Service[Request, Response], - remainingRedirects: Int - ): Future[Response] = { - val req = request() - (filters andThen httpClient) (req) flatMap { - rep => - rep.status match { - case Continue => - Future.exception(InvalidResponse( - rep, - "Received unexpected 100/Continue, but request body was already sent." - )) - case SwitchingProtocols => Future.exception(InvalidResponse( - rep, - "Received unexpected 101/Switching Protocols, but no switch was requested." - )) - case s if s.code >= 200 && s.code < 300 => - Future(rep) - case MultipleChoices => - Future.exception(InvalidResponse(rep, "300/Multiple Choices is not yet supported in featherbed.")) - case MovedPermanently | Found | SeeOther | TemporaryRedirect => - val attempt = for { - tooMany <- if (remainingRedirects <= 0) - Left("Too many redirects; giving up") - else - Right(()) - location <- Either.fromOption( - rep.headerMap.get("Location"), - "Redirect required, but location header not present") - newUrl <- Either.catchNonFatal(url.toURI.resolve(location)) - .leftMap(_ => s"Could not resolve Location $location") - canHandle <- if (newUrl.getHost != url.getHost) - Either.left("Location points to another host; this isn't supported by featherbed") - else - Either.right(()) - } yield { - val newReq = request() - newReq.uri = List(Option(newUrl.getPath), Option(newUrl.getQuery).map("?" + _)).flatten.mkString - handleRequest(() => newReq, filters, url, httpClient, remainingRedirects - 1) - } - attempt.fold(err => Future.exception(InvalidResponse(rep, err)), identity) - case other => Future.exception(ErrorResponse(req, rep)) - } - } - } } object ClientRequest extends RequestTypes[ClientRequest] { + case class ContentToService[Meth <: Method, Accept <: Coproduct, Content, Result]( + req: ClientRequest[Meth, Accept, None.type, None.type] + ) extends AnyVal { + def apply[ContentType <: String](contentType: Witness.Lt[ContentType])(implicit + canBuildRequest: CanBuildRequest[HTTPRequest[Meth, Accept, Content, ContentType]], + decodeAll: DecodeAll[Result, Accept] + ): Service[Content, Result] = { + new CodecFilter[Meth, Accept, Content, ContentType, Result]( + req.request.copy[Meth, Accept, None.type, ContentType](content = MimeContent(None, contentType.value)), + req.client.maxFollows + ) andThen req.client.httpClient + } + } + class ClientRequestSyntax(client: Client) extends RequestSyntax[ClientRequest] with RequestTypes[ClientRequest] { def req[Meth <: Method, Accept <: Coproduct]( @@ -212,7 +176,7 @@ object ClientRequest extends RequestTypes[ClientRequest] { def withParams( first: (String, String), rest: (String, String)* - ): FormPostRequest[Accept] = req.copy( + ): FormPostRequest[Accept, Form] = req.copy( request = req.request.withParams(first, rest: _*) ) @@ -230,4 +194,118 @@ object ClientRequest extends RequestTypes[ClientRequest] { ) } + implicit class PutToServiceOps[Accept <: Coproduct]( + val req: PutRequest[Accept, None.type, None.type] + ) extends AnyVal { + def toService[In, Out]: ContentToService[Method.Put.type, Accept, In, Out] = ContentToService(req) + } + + case class PostMultipartFormToService[Accept <: Coproduct]( + req: PostRequest[Accept, None.type, None.type] + ) extends AnyVal { + def toService[In, Out](implicit + canBuildRequest: CanBuildRequest[ + HTTPRequest[Method.Post.type, Accept, In, MimeContent.MultipartForm] + ], + decodeAll: DecodeAll[Out, Accept] + ): Service[In, Out] = + ContentToService[Method.Post.type, Accept, In, Out](req) + .apply("multipart/form-data") + } + + + case class PostFormToService[Accept <: Coproduct]( + req: PostRequest[Accept, None.type, None.type] + ) extends AnyVal { + def toService[In, Out](implicit + canBuildRequest: CanBuildRequest[ + HTTPRequest[Method.Post.type, Accept, In, MimeContent.WebForm] + ], + decodeAll: DecodeAll[Out, Accept] + ): Service[In, Out] = + ContentToService[Method.Post.type, Accept, In, Out](req) + .apply("application/x-www-form-urlencoded") + + def multipart: PostMultipartFormToService[Accept] = PostMultipartFormToService(req) + } + + implicit class PostToServiceOps[Accept <: Coproduct]( + val req: PostRequest[Accept, None.type, None.type] + ) extends AnyVal { + def toService[In, Out]: ContentToService[Method.Post.type, Accept, In, Out] = ContentToService(req) + def form: PostFormToService[Accept] = PostFormToService(req) + } + + implicit class GetToServiceOps[Accept <: Coproduct]( + val req: GetRequest[Accept] + ) extends AnyVal { + def toService[Result](implicit + canBuildRequest: CanBuildRequest[HTTPRequest[Method.Get.type, Accept, None.type, None.type]], + decodeAll: DecodeAll[Result, Accept] + ): () => Future[Result] = new Function0[Future[Result]] { + private val service = new CodecFilter[Method.Get.type, Accept, Request, None.type, Result]( + req.request, req.client.maxFollows + ) andThen req.client.httpClient + + private val builtRequest = canBuildRequest.build(req.request).fold( + errs => Future.exception(errs.head), + req => Future.value(req) + ) + + def apply(): Future[Result] = for { + request <- builtRequest + result <- service(request) + } yield result + } + + def toService[Params, Result](implicit + canBuildRequest: CanBuildRequest[HTTPRequest[Method.Get.type, Accept, None.type, None.type]], + decodeAll: DecodeAll[Result, Accept], + toQueryParams: ToQueryParams[Params] + ): Service[Params, Result] = { + new QueryFilter[Params, Request, Result]( + params => { + val withParams = req.addQueryParams(params) + canBuildRequest.build(withParams.request).fold( + errs => Future.exception(errs.head), + req => Future.value(req) + ) + } + ) andThen new CodecFilter[Method.Get.type, Accept, Request, None.type, Result]( + req.request, req.client.maxFollows + ) andThen req.client.httpClient + } + } + + implicit class HeadToServiceOps(val req: HeadRequest) extends AnyVal { + def toService(implicit + canBuildRequest: CanBuildRequest[HTTPRequest[Method.Head.type, CNil, None.type, None.type]] + ): Service[Unit, Response] = + new UnitToNone[Response] andThen + new CodecFilter[Method.Head.type, CNil, None.type, None.type, Response]( + req.request, req.client.maxFollows + ) andThen req.client.httpClient + } + + implicit class DeleteToServiceOps[Accept <: Coproduct](val req: DeleteRequest[Accept]) extends AnyVal { + def toService[Result](implicit + canBuildRequest: CanBuildRequest[HTTPRequest[Method.Delete.type, Accept, None.type, None.type]], + decodeAll: DecodeAll[Result, Accept] + ): Service[Unit, Result] = new UnitToNone[Result] andThen + new CodecFilter[Method.Delete.type, Accept, None.type, None.type, Result]( + req.request, req.client.maxFollows + ) andThen req.client.httpClient + } + + implicit class PatchToServiceOps[Accept <: Coproduct]( + val req: PatchRequest[Accept, None.type, None.type] + ) extends AnyVal { + def toService[In, Out]: ContentToService[Method.Patch.type, Accept, In, Out] = ContentToService(req) + } + + private class UnitToNone[Rep] extends Filter[Unit, Rep, None.type, Rep] { + def apply(request: Unit, service: Service[None.type, Rep]): Future[Rep] = service(None) + } + + } \ No newline at end of file diff --git a/featherbed-core/src/main/scala/featherbed/request/CodecFilter.scala b/featherbed-core/src/main/scala/featherbed/request/CodecFilter.scala new file mode 100644 index 0000000..d572d77 --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/request/CodecFilter.scala @@ -0,0 +1,109 @@ +package featherbed.request + +import java.net.URL + +import cats.data.Validated.{Invalid, Valid} +import cats.syntax.either._ +import com.twitter.finagle.{Filter, Service} +import com.twitter.finagle.http.{Method, Request, Response} +import com.twitter.finagle.http.Status._ +import com.twitter.util.Future +import featherbed.content.MimeContent +import featherbed.support.{ContentType, DecodeAll, RuntimeContentType} +import shapeless.{Coproduct, Witness} + +class CodecFilter[Meth <: Method, Accept <: Coproduct, Content, ContentType, Result]( + request: HTTPRequest[Meth, Accept, None.type, ContentType], + maxFollows: Int)(implicit + canBuildRequest: CanBuildRequest[HTTPRequest[Meth, Accept, Content, ContentType]], + decodeAll: DecodeAll[Result, Accept] +) extends Filter[Content, Result, Request, Response] { + + def apply(req: Content, service: Service[Request, Response]): Future[Result] = { + canBuildRequest + .build(request.copy[Meth, Accept, Content, ContentType](content = request.content.copy(content = req))) + .fold( + errs => Future.exception(RequestBuildingError(errs)), + req => CodecFilter.handleRequest(() => req, request.filters, request.buildUrl, service, maxFollows)) + .flatMap { + rep => CodecFilter.decodeResponse[Result, Accept](rep) + } + } + +} + +object CodecFilter { + + private[featherbed] def handleRequest( + request: () => Request, + filters: Filter[Request, Response, Request, Response], + url: URL, + httpClient: Service[Request, Response], + remainingRedirects: Int + ): Future[Response] = { + val req = request() + (filters andThen httpClient) (req) flatMap { + rep => + rep.status match { + case Continue => + Future.exception(InvalidResponse( + rep, + "Received unexpected 100/Continue, but request body was already sent." + )) + case SwitchingProtocols => Future.exception(InvalidResponse( + rep, + "Received unexpected 101/Switching Protocols, but no switch was requested." + )) + case s if s.code >= 200 && s.code < 300 => + Future(rep) + case MultipleChoices => + Future.exception(InvalidResponse(rep, "300/Multiple Choices is not yet supported in featherbed.")) + case MovedPermanently | Found | SeeOther | TemporaryRedirect => + val attempt = for { + tooMany <- if (remainingRedirects <= 0) + Left("Too many redirects; giving up") + else + Right(()) + location <- Either.fromOption( + rep.headerMap.get("Location"), + "Redirect required, but location header not present") + newUrl <- Either.catchNonFatal(url.toURI.resolve(location)) + .leftMap(_ => s"Could not resolve Location $location") + canHandle <- if (newUrl.getHost != url.getHost) + Either.left("Location points to another host; this isn't supported by featherbed") + else + Either.right(()) + } yield { + val newReq = request() + newReq.uri = List(Option(newUrl.getPath), Option(newUrl.getQuery).map("?" + _)).flatten.mkString + handleRequest(() => newReq, filters, url, httpClient, remainingRedirects - 1) + } + attempt.fold(err => Future.exception(InvalidResponse(rep, err)), identity) + case other => Future.exception(ErrorResponse(req, rep)) + } + } + } + + + private[featherbed] def decodeResponse[K, Accept <: Coproduct](rep: Response)(implicit + decodeAll: DecodeAll[K, Accept] + ): Future[K] = { + val RuntimeContentType(mediaType, _) = rep.contentType + .flatMap(ContentType.contentTypePieces) + .getOrElse(RuntimeContentType("*/*", Map.empty)) + + decodeAll.findInstance(mediaType) match { + case Some(decoder) => + decoder(rep) match { + case Valid(decoded) => + Future(decoded) + case Invalid(errs) => + Future.exception(InvalidResponse(rep, errs.map(_.getMessage).toList.mkString("; "))) + } + case None => + Future.exception(InvalidResponse(rep, s"No decoder was found for $mediaType")) + } + } + + +} \ No newline at end of file diff --git a/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala b/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala index 994cdf9..0381dab 100644 --- a/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala +++ b/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala @@ -1,17 +1,19 @@ package featherbed package request -import java.net.{URL, URLEncoder} +import java.io.File +import java.net.{URI, URL, URLEncoder} import java.nio.charset.{Charset, StandardCharsets} import scala.language.experimental.macros - import cats.syntax.either._ +import cats.syntax.traverse._ +import cats.instances.list._ import com.twitter.finagle.{Filter, Service, ServiceFactory} -import com.twitter.finagle.http.{Method, Request, Response, SimpleElement} +import com.twitter.finagle.http._ import com.twitter.finagle.http.Status.{NoContent => _, _} import com.twitter.util.Future -import featherbed.content.{Form, MimeContent, MultipartForm} +import featherbed.content.{Form, MimeContent, MultipartForm, ToFormParam} import MimeContent.NoContent import cats.data.{NonEmptyList, Validated} import featherbed.littlemacros.CoproductMacros @@ -37,7 +39,7 @@ case class HTTPRequest[ copy[Meth, A, Content, ContentType]() def accept[A <: Coproduct](types: String*): HTTPRequest[Meth, A, Content, ContentType] = - macro CoproductMacros.callAcceptCoproduct + macro CoproductMacros.callAcceptCoproduct[A] def withCharset(charset: Charset): HTTPRequest[Meth, Accept, Content, ContentType] = copy(charset = charset) @@ -88,7 +90,12 @@ case class HTTPRequest[ ): HTTPRequest[Meth, Accept, Content, ContentType] = addQueryParams(params.toList) - def buildUrl: URL = query.map(q => new URL(url, "?" + q)).getOrElse(url) + def buildUrl: URL = { + val uri = url.toURI + query.map { + q => new URI(uri.getScheme, uri.getUserInfo, uri.getHost, uri.getPort, uri.getPath, q, uri.getFragment).toURL + }.getOrElse(url) + } } @@ -110,16 +117,43 @@ object HTTPRequest extends RequestSyntax[HTTPRequest] with RequestTypes[HTTPRequ def withParams( first: (String, String), rest: (String, String)* - ): FormPostRequest[Accept] = req.copy[Method.Post.type, Accept, Form, MimeContent.WebForm]( + ): FormPostRequest[Accept, Form] = req.copy[Method.Post.type, Accept, Form, MimeContent.WebForm]( content = MimeContent[Form, MimeContent.WebForm]( Form( NonEmptyList(first, rest.toList) .map((SimpleElement.apply _).tupled) - .map(Validated.valid) ) ) ) + def withFiles( + first: (String, File), + rest: (String, File)* + ): MultipartFormRequest[Accept, MultipartForm] = { + val newContent = req.content match { + case MimeContent(Form(existing), _) => MimeContent[MultipartForm, MimeContent.MultipartForm]( + MultipartForm( + NonEmptyList(first, rest.toList).map((ToFormParam.file.apply _).tupled).sequenceU map { + validFiles => validFiles ++ existing.toList + } + ) + ) + case MimeContent(MultipartForm(existing), _) => MimeContent[MultipartForm, MimeContent.MultipartForm]( + MultipartForm( + NonEmptyList(first, rest.toList).map((ToFormParam.file.apply _).tupled).sequenceU andThen { + validFiles => existing map (validFiles ++ _.toList) + } + ) + ) + case _ => MimeContent[MultipartForm, MimeContent.MultipartForm](MultipartForm( + NonEmptyList(first, rest.toList).map((ToFormParam.file.apply _).tupled).sequenceU + )) + } + req.copy[Method.Post.type, Accept, MultipartForm, MimeContent.MultipartForm]( + content = newContent + ) + } + def toService[In, Out](contentType: String)(client: Client)(implicit canBuildRequest: CanBuildRequest[HTTPRequest[Method.Post.type, Accept, In, contentType.type]], decodeAll: DecodeAll[Out, Accept], diff --git a/featherbed-core/src/main/scala/featherbed/request/QueryFilter.scala b/featherbed-core/src/main/scala/featherbed/request/QueryFilter.scala new file mode 100644 index 0000000..e92ae0b --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/request/QueryFilter.scala @@ -0,0 +1,17 @@ +package featherbed.request + +import cats.data.ValidatedNel +import com.twitter.finagle.{Filter, Service} +import com.twitter.util.Future +import featherbed.content.ToQueryParams +import shapeless.Coproduct + +class QueryFilter[Params, Req, Result]( + toReq: List[(String, String)] => Future[Req])(implicit + toQueryParams: ToQueryParams[Params] +) extends Filter[Params, Result, Req, Result] { + def apply(req: Params, service: Service[Req, Result]): Future[Result] = for { + request <- toReq(toQueryParams(req)) + response <- service(request) + } yield response +} diff --git a/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala b/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala index 455c6bd..3029c5c 100644 --- a/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala +++ b/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala @@ -75,8 +75,8 @@ trait RequestTypes[Req[Meth <: Method, Accept <: Coproduct, Content, ContentType type PostRequest[Accept <: Coproduct, Content, ContentType] = Req[Method.Post.type, Accept, Content, ContentType] - type FormPostRequest[Accept <: Coproduct] = PostRequest[Accept, Form, MimeContent.WebForm] - type MultipartFormRequest[Accept <: Coproduct] = PostRequest[Accept, MultipartForm, Witness.`"multipart/form-data"`.T] + type FormPostRequest[Accept <: Coproduct, Content] = PostRequest[Accept, Content, MimeContent.WebForm] + type MultipartFormRequest[Accept <: Coproduct, Content] = PostRequest[Accept, Content, MimeContent.MultipartForm] type PutRequest[Accept <: Coproduct, Content, ContentType] = Req[Method.Put.type, Accept, Content, ContentType] diff --git a/featherbed-core/src/main/scala/featherbed/support/DecodeAll.scala b/featherbed-core/src/main/scala/featherbed/support/DecodeAll.scala new file mode 100644 index 0000000..228bb69 --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/support/DecodeAll.scala @@ -0,0 +1,44 @@ +package featherbed.support + +import scala.annotation.implicitNotFound + +import cats.data.Validated.Valid +import com.twitter.finagle.http.Response +import featherbed.content +import shapeless.{:+:, CNil, Coproduct} + + +@implicitNotFound("In order to decode a request to ${A}, it must be known that a decoder exists to ${A} from " + +"all the content types that you Accept, which is currently ${ContentTypes}. " + +"You may have forgotten to specify Accept types with the `accept(..)` method, " + +"or you may be missing Decoder instances for some content types.") +sealed trait DecodeAll[A, ContentTypes <: Coproduct] { + val instances: List[content.Decoder.Aux[_, A]] + def findInstance(ct: String): Option[content.Decoder.Aux[_, A]] = + instances.find(_.contentType == ct) orElse instances.find(_.contentType == "*/*") +} + +object DecodeAll { + + implicit def one[H, A](implicit + headInstance: content.Decoder.Aux[H, A] + ): DecodeAll[A, H :+: CNil] = new DecodeAll[A, H :+: CNil] { + final val instances: List[content.Decoder.Aux[_, A]] = headInstance :: Nil + } + + implicit def ccons[H, A, T <: Coproduct](implicit + headInstance: content.Decoder.Aux[H, A], + tailInstances: DecodeAll[A, T]): DecodeAll[A, H :+: T] = new DecodeAll[A, H :+: T] { + + final val instances: List[content.Decoder.Aux[_, A]] = headInstance :: tailInstances.instances + } + + implicit val decodeResponse: DecodeAll[Response, CNil] = new DecodeAll[Response, CNil] { + final val instances: List[content.Decoder.Aux[CNil, Response]] = new content.Decoder[CNil] { + type Out = Response + val contentType = "*/*" + def apply(response: Response) = Valid(response) + } :: Nil + } + +} diff --git a/featherbed-core/src/main/scala/featherbed/support/package.scala b/featherbed-core/src/main/scala/featherbed/support/package.scala deleted file mode 100644 index 66b6895..0000000 --- a/featherbed-core/src/main/scala/featherbed/support/package.scala +++ /dev/null @@ -1,45 +0,0 @@ -package featherbed - -import scala.annotation.implicitNotFound -import scala.language.higherKinds - -import cats.data.Validated.Valid -import com.twitter.finagle.http.Response -import shapeless._ - -package object support { - - @implicitNotFound("""In order to decode a request to ${A}, it must be known that a decoder exists to ${A} from -all the content types that you Accept, which is currently ${ContentTypes}. -You may have forgotten to specify Accept types with the `accept(..)` method, -or you may be missing Decoder instances for some content types. -""") - sealed trait DecodeAll[A, ContentTypes <: Coproduct] { - val instances: List[content.Decoder.Aux[_, A]] - def findInstance(ct: String): Option[content.Decoder.Aux[_, A]] = - instances.find(_.contentType == ct) orElse instances.find(_.contentType == "*/*") - } - - object DecodeAll { - implicit def one[H, A](implicit - headInstance: content.Decoder.Aux[H, A] - ): DecodeAll[A, H :+: CNil] = new DecodeAll[A, H :+: CNil] { - final val instances: List[content.Decoder.Aux[_, A]] = headInstance :: Nil - } - - implicit def ccons[H, A, T <: Coproduct](implicit - headInstance: content.Decoder.Aux[H, A], - tailInstances: DecodeAll[A, T]): DecodeAll[A, H :+: T] = new DecodeAll[A, H :+: T] { - - final val instances: List[content.Decoder.Aux[_, A]] = headInstance :: tailInstances.instances - } - - implicit val decodeResponse: DecodeAll[Response, CNil] = new DecodeAll[Response, CNil] { - final val instances: List[content.Decoder.Aux[CNil, Response]] = new content.Decoder[CNil] { - type Out = Response - val contentType = "*/*" - def apply(response: Response) = Valid(response) - } :: Nil - } - } -} diff --git a/featherbed-test/src/test/scala/featherbed/ClientSpec.scala b/featherbed-test/src/test/scala/featherbed/ClientSpec.scala index a39c455..4b36d9e 100644 --- a/featherbed-test/src/test/scala/featherbed/ClientSpec.scala +++ b/featherbed-test/src/test/scala/featherbed/ClientSpec.scala @@ -5,13 +5,11 @@ import java.nio.charset.Charset import com.twitter.finagle.{Service, SimpleFilter} import com.twitter.finagle.http.{Method, Request, Response} import com.twitter.util.{Await, Future} -import featherbed.fixture.ClientTest -import featherbed.support.DecodeAll +import featherbed.content.ToFormParams import org.scalamock.scalatest.MockFactory -import org.scalatest.{BeforeAndAfterEach, FlatSpec} -import shapeless.{CNil, Coproduct, Witness} +import org.scalatest.FreeSpec -class ClientSpec extends FlatSpec with MockFactory with ClientTest { +class ClientSpec extends FreeSpec with MockFactory with ClientTest { val receiver = stubFunction[Request, Unit]("receiveRequest") @@ -24,118 +22,269 @@ class ClientSpec extends FlatSpec with MockFactory with ClientTest { val client = mockClient("http://example.com/api/v1/", InterceptRequest) - "Client" should "get" in { - - val req = client.get("foo/bar").accept("text/plain") + "GET requests" - { + + "Plain" - { + "send" in { + val req = client.get("foo/bar").accept("text/plain") + + intercept[Throwable]( + Await.result( + for { + rep <- req.send[String]() + } yield ())) + + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar") + assert(req.method == Method.Get) + assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) + } + } + + "toService" in { + val service = client.get("foo/bar").accept("text/plain").toService[String] + + intercept[Throwable]( + Await.result( + for { + rep <- service() + } yield ())) + + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar") + assert(req.method == Method.Get) + assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) + } + } + } - intercept[Throwable](Await.result(for { - rep <- req.send[String]() - } yield ())) + "With query params" - { + + "send" in { + val req = client + .get("foo/bar") + .withQueryParams("param" -> "value") + .accept("text/plain") + + intercept[Throwable]( + Await.result( + for { + rep <- req.send[String]() + } yield ())) + + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar?param=value") + assert(req.method == Method.Get) + assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) + } + } + } - receiver verify request { req => - assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Get) - assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) + "toService" in { + case class Params( + first: Int, + second: String + ) + val service = client + .get("foo/bar") + .accept("text/plain") + .toService[Params, String] + + intercept[Throwable]( + Await.result( + for { + rep <- service(Params(10, "foo")) + } yield ())) + + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar?first=10&second=foo") + assert(req.method == Method.Get) + assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) + } } + } - it should "get with query params" in { - val req = client - .get("foo/bar") - .withQueryParams("param" -> "value") - .accept("text/plain") - - intercept[Throwable](Await.result(for { - rep <- req.send[String]() - } yield ())) + "POST requests" - { + "Plain" - { + "send" in { + val req = client + .post("foo/bar") + .withContent("Hello world", "text/plain") + .accept("text/plain") + + intercept[Throwable]( + Await.result( + for { + rep <- req.send[String]() + } yield ())) + + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar") + assert(req.method == Method.Post) + assert(req.headerMap("Content-Type") == s"text/plain; charset=${Charset.defaultCharset.name}") + assert(req.contentString == "Hello world") + assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) + } + } + + "toService" in { + val service = client.post("foo/bar").accept("text/plain").toService[String, String]("text/plain") + intercept[Throwable] { + Await.result { + for { + rep <- service("Hello world") + } yield () + } + } + + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar") + assert(req.method == Method.Post) + assert(req.headerMap("Content-Type") == s"text/plain; charset=${Charset.defaultCharset.name}") + assert(req.contentString == "Hello world") + assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) + } + } + } - receiver verify request { req => - assert(req.uri == "/api/v1/foo/bar?param=value") - assert(req.method == Method.Get) - assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) + "Form" - { + "send" in { + val req = client + .post("foo/bar") + .withParams("foo" -> "bar", "bar" -> "baz") + .accept("text/plain") + + intercept[Throwable](Await.result(req.send[String]())) + + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar") + assert(req.method == Method.Post) + assert(req.headerMap("Content-Type") == s"application/x-www-form-urlencoded") + assert(req.contentString == "foo=bar&bar=baz") + assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) + } + } + + "toService" - { + "string args" in { + case class Params(foo: String, bar: String) + val service = client.post("foo/bar").accept("text/plain").form.toService[Params, String] + intercept[Throwable] { + Await.result { + for { + result <- service(Params(foo = "bar", bar = "baz")) + } yield () + } + } + + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar") + assert(req.method == Method.Post) + assert(req.headerMap("Content-Type") == s"application/x-www-form-urlencoded") + assert(req.contentString == "foo=bar&bar=baz") + assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) + } + } + } } } - it should "post" in { - val req = client - .post("foo/bar") - .withContent("Hello world", "text/plain") - .accept("text/plain") + "HEAD" - { + "send" in { + val req = client + .head("foo/bar") - intercept[Throwable](Await.result(for { - rep <- req.send[String]() - } yield ())) + Await.result(req.send[Response]()) - receiver verify request { req => - assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Post) - assert(req.headerMap("Content-Type") == s"text/plain; charset=${Charset.defaultCharset.name}") - assert(req.contentString == "Hello world") - assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar") + assert(req.method == Method.Head) + } } - } - it should "post a form" in { - val req = client - .post("foo/bar") - .withParams("foo" -> "bar", "bar" -> "baz") - .accept("text/plain") + "toService" in { + val service = client.head("foo/bar").toService + Await.result(service()) - intercept[Throwable](Await.result(req.send[String]())) - - receiver verify request { req => - assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Post) - assert(req.headerMap("Content-Type") == s"application/x-www-form-urlencoded") - assert(req.contentString == "foo=bar&bar=baz") - assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar") + assert(req.method == Method.Head) + } } } - it should "make a head request" in { - val req = client - .head("foo/bar") + "DELETE" - { + "send" in { + val req = client + .delete("foo/bar") + .accept("text/plain") - Await.result(req.send()) + intercept[Throwable](Await.result(req.send[String]())) - receiver verify request { req => - assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Head) + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar") + assert(req.method == Method.Delete) + } } - } - - it should "delete" in { - val req = client - .delete("foo/bar") - .accept("text/plain") - intercept[Throwable](Await.result(req.send[String]())) + "toService" in { + val service = client.delete("foo/bar").accept("text/plain").toService[String] + intercept[Throwable](Await.result(service())) - receiver verify request { req => - assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Delete) + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar") + assert(req.method == Method.Delete) + } } } - it should "put" in { - val req = client - .put("foo/bar") - .withContent("Hello world", "text/plain") - .accept("text/plain") - - intercept[Throwable](Await.result(req.send[String]())) + "PUT" - { + "send" in { + val req = client + .put("foo/bar") + .withContent("Hello world", "text/plain") + .accept("text/plain") + + intercept[Throwable](Await.result(req.send[String]())) + + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar") + assert(req.method == Method.Put) + assert(req.contentType.contains(s"text/plain; charset=${Charset.defaultCharset.name}")) + assert(req.contentString == "Hello world") + } + } - receiver verify request { req => - assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Put) - assert(req.contentType.contains(s"text/plain; charset=${Charset.defaultCharset.name}")) - assert(req.contentString == "Hello world") + "toService" in { + val service = client.put("foo/bar").accept("text/plain").toService[String, String]("text/plain") + intercept[Throwable](Await.result(service("Hello world"))) + receiver verify request { + req => + assert(req.uri == "/api/v1/foo/bar") + assert(req.method == Method.Put) + assert(req.contentType.contains(s"text/plain; charset=${Charset.defaultCharset.name}")) + assert(req.contentString == "Hello world") + } } } ///--- - it should "sendZip: get" in { + "sendZip" in { val req = client.get("foo/bar").accept("text/plain") @@ -150,7 +299,7 @@ class ClientSpec extends FlatSpec with MockFactory with ClientTest { } } - it should "sendZip: get with query params" in { + "sendZip: get with query params" in { val req = client .get("foo/bar") @@ -168,7 +317,7 @@ class ClientSpec extends FlatSpec with MockFactory with ClientTest { } } - it should "sendZip: post" in { + "sendZip: post" in { val req = client .post("foo/bar") .withContent("Hello world", "text/plain") @@ -187,7 +336,7 @@ class ClientSpec extends FlatSpec with MockFactory with ClientTest { } } - it should "sendZip: post a form" in { + "sendZip: post a form" in { val req = client .post("foo/bar") .withParams("foo" -> "bar", "bar" -> "baz") @@ -204,7 +353,7 @@ class ClientSpec extends FlatSpec with MockFactory with ClientTest { } } - it should "sendZip: delete" in { + "sendZip: delete" in { val req = client .delete("foo/bar") .accept("text/plain") @@ -217,7 +366,7 @@ class ClientSpec extends FlatSpec with MockFactory with ClientTest { } } - it should "sendZip: put" in { + "sendZip: put" in { val req = client .put("foo/bar") .withContent("Hello world", "text/plain") @@ -233,14 +382,14 @@ class ClientSpec extends FlatSpec with MockFactory with ClientTest { } } - "Compile" should "fail when no encoder is available for the request" in { + "Compile fails when no encoder is available for the request" in { assertDoesNotCompile( """ client.put("foo/bar").withContent("Hello world", "no/encoder").accept("*/*").send[Response]() """) } - it should "fail when no decoder is available for the response" in { + "Compile fails when no decoder is available for the response" in { assertDoesNotCompile( """ client.get("foo/bar").accept("pie/pumpkin").send[String]() diff --git a/featherbed-test/src/test/scala/featherbed/ErrorHandlingSpec.scala b/featherbed-test/src/test/scala/featherbed/ErrorHandlingSpec.scala index 7cbef18..d60fd4f 100644 --- a/featherbed-test/src/test/scala/featherbed/ErrorHandlingSpec.scala +++ b/featherbed-test/src/test/scala/featherbed/ErrorHandlingSpec.scala @@ -14,11 +14,12 @@ import featherbed.content.{Decoder, Encoder} import featherbed.fixture.ClientTest import featherbed.request._ import featherbed.request._ +import featherbed.support.DecodeAll import io.circe.generic.auto._ import io.circe.syntax._ import org.scalamock.scalatest.MockFactory import org.scalatest.FreeSpec -import shapeless.Witness +import shapeless.{:+:, CNil, Witness} class ErrorHandlingSpec extends FreeSpec with MockFactory with ClientTest { diff --git a/featherbed-test/src/test/scala/featherbed/circe/CirceSpec.scala b/featherbed-test/src/test/scala/featherbed/circe/CirceSpec.scala index 3346941..d1e088d 100644 --- a/featherbed-test/src/test/scala/featherbed/circe/CirceSpec.scala +++ b/featherbed-test/src/test/scala/featherbed/circe/CirceSpec.scala @@ -1,6 +1,7 @@ package featherbed.circe import cats.implicits._ +import io.circe.generic.auto._ import io.circe.parser.parse import io.circe.syntax._ import cats.implicits._ From 13cd29ef88c26e378838281fb66d6e6a6c4485ca Mon Sep 17 00:00:00 2001 From: Jeremy Smith Date: Tue, 9 May 2017 11:24:18 -0700 Subject: [PATCH 05/10] Support Finagle 6.44.0 * Use strings for method singleton types (workaround breaking finagle change) * Move code around & remove json dependency from core tests * Update build.sbt based on master --- build.sbt | 19 +- .../scala/featherbed/circe/CirceSpec.scala | 0 .../src/main/scala/featherbed/Method.scala | 21 + .../featherbed/request/CanBuildRequest.scala | 8 +- .../featherbed/request/ClientRequest.scala | 43 +- .../featherbed/request/CodecFilter.scala | 7 +- .../featherbed/request/HTTPRequest.scala | 20 +- .../featherbed/request/RequestSyntax.scala | 531 +----------------- .../test/scala/featherbed/ClientSpec.scala | 44 +- .../scala/featherbed/ErrorHandlingSpec.scala | 18 +- .../scala/featherbed/fixture/package.scala | 35 ++ .../src/test/scala/featherbed/package.scala | 33 -- project/plugins.sbt | 1 + 13 files changed, 138 insertions(+), 642 deletions(-) rename {featherbed-test => featherbed-circe}/src/test/scala/featherbed/circe/CirceSpec.scala (100%) create mode 100644 featherbed-core/src/main/scala/featherbed/Method.scala rename {featherbed-test => featherbed-core}/src/test/scala/featherbed/ClientSpec.scala (90%) rename {featherbed-test => featherbed-core}/src/test/scala/featherbed/ErrorHandlingSpec.scala (95%) delete mode 100644 featherbed-test/src/test/scala/featherbed/package.scala diff --git a/build.sbt b/build.sbt index 6a83fc5..46e6ebb 100644 --- a/build.sbt +++ b/build.sbt @@ -73,23 +73,8 @@ lazy val `featherbed-core` = project .settings(allSettings) lazy val `featherbed-circe` = project - .settings( - libraryDependencies ++= Seq( - "io.circe" %% "circe-core" % circeVersion, - "io.circe" %% "circe-parser" % circeVersion, - "io.circe" %% "circe-generic" % circeVersion - ), - allSettings - ).dependsOn(`featherbed-core`) - -lazy val `featherbed-test` = project - .settings( - libraryDependencies ++= Seq( - "org.scalamock" %% "scalamock-scalatest-support" % "3.4.2" % "test", - "org.scalatest" %% "scalatest" % "3.0.0" % "test" - ), - buildSettings ++ noPublish - ).dependsOn(`featherbed-core`, `featherbed-circe`) + .settings(allSettings) + .dependsOn(`featherbed-core`) val scaladocVersionPath = settingKey[String]("Path to this version's ScalaDoc") val scaladocLatestPath = settingKey[String]("Path to latest ScalaDoc") diff --git a/featherbed-test/src/test/scala/featherbed/circe/CirceSpec.scala b/featherbed-circe/src/test/scala/featherbed/circe/CirceSpec.scala similarity index 100% rename from featherbed-test/src/test/scala/featherbed/circe/CirceSpec.scala rename to featherbed-circe/src/test/scala/featherbed/circe/CirceSpec.scala diff --git a/featherbed-core/src/main/scala/featherbed/Method.scala b/featherbed-core/src/main/scala/featherbed/Method.scala new file mode 100644 index 0000000..28c967f --- /dev/null +++ b/featherbed-core/src/main/scala/featherbed/Method.scala @@ -0,0 +1,21 @@ +package featherbed + +import shapeless.Witness + +// Finagle 6.43.0 made their Methods vals instead of case objects, eliminating their singleton types +sealed trait Method +object Method { + final val Post: Post = implicitly[Witness.Aux[Post]].value + final val Get: Get = implicitly[Witness.Aux[Get]].value + final val Put: Put = implicitly[Witness.Aux[Put]].value + final val Patch: Patch = implicitly[Witness.Aux[Patch]].value + final val Delete: Delete = implicitly[Witness.Aux[Delete]].value + final val Head: Head = implicitly[Witness.Aux[Head]].value + + type Post = Witness.`"POST"`.T + type Get = Witness.`"GET"`.T + type Put = Witness.`"PUT"`.T + type Patch = Witness.`"PATCH"`.T + type Delete = Witness.`"DELETE"`.T + type Head = Witness.`"HEAD"`.T +} diff --git a/featherbed-core/src/main/scala/featherbed/request/CanBuildRequest.scala b/featherbed-core/src/main/scala/featherbed/request/CanBuildRequest.scala index c5670cf..f6a25fd 100644 --- a/featherbed-core/src/main/scala/featherbed/request/CanBuildRequest.scala +++ b/featherbed-core/src/main/scala/featherbed/request/CanBuildRequest.scala @@ -1,10 +1,12 @@ -package featherbed.request +package featherbed +package request import scala.annotation.implicitNotFound + import cats.data._ import cats.data.Validated._ import cats.implicits._ -import com.twitter.finagle.http.{FormElement, Method, Request, RequestBuilder} +import com.twitter.finagle.http.{FormElement, Request, RequestBuilder} import com.twitter.finagle.http.RequestConfig.Yes import com.twitter.io.Buf import featherbed.Client @@ -148,7 +150,7 @@ object CanBuildRequest { } implicit def canBuildDefinedRequest[ - Meth <: Method, + Meth <: String, Accept <: Coproduct ]: CanBuildRequest[HTTPRequest[Meth, Accept, Request, None.type]] = new CanBuildRequest[HTTPRequest[Meth, Accept, Request, None.type]] { diff --git a/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala b/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala index b53c4a5..73cb7db 100644 --- a/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala +++ b/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala @@ -1,12 +1,13 @@ -package featherbed.request +package featherbed +package request import java.net.URL import java.nio.charset.Charset - import scala.language.experimental.macros + import cats.syntax.either._ import com.twitter.finagle.{Filter, Service} -import com.twitter.finagle.http.{Method, Request, Response} +import com.twitter.finagle.http.{Request, Response} import com.twitter.util.Future import featherbed.Client import featherbed.content.{Form, MimeContent, ToQueryParams} @@ -20,7 +21,7 @@ import shapeless.ops.hlist.SelectAll /** * An [[HTTPRequest]] which is already paired with a [[Client]] */ -case class ClientRequest[Meth <: Method, Accept <: Coproduct, Content, ContentType]( +case class ClientRequest[Meth <: String, Accept <: Coproduct, Content, ContentType]( request: HTTPRequest[Meth, Accept, Content, ContentType], client: Client ) { @@ -136,7 +137,7 @@ case class ClientRequest[Meth <: Method, Accept <: Coproduct, Content, ContentTy object ClientRequest extends RequestTypes[ClientRequest] { - case class ContentToService[Meth <: Method, Accept <: Coproduct, Content, Result]( + case class ContentToService[Meth <: String, Accept <: Coproduct, Content, Result]( req: ClientRequest[Meth, Accept, None.type, None.type] ) extends AnyVal { def apply[ContentType <: String](contentType: Witness.Lt[ContentType])(implicit @@ -152,7 +153,7 @@ object ClientRequest extends RequestTypes[ClientRequest] { class ClientRequestSyntax(client: Client) extends RequestSyntax[ClientRequest] with RequestTypes[ClientRequest] { - def req[Meth <: Method, Accept <: Coproduct]( + def req[Meth <: String, Accept <: Coproduct]( method: Meth, url: URL, filters: Filter[Request, Response, Request, Response] ): ClientRequest[Meth, Accept, None.type, None.type] = @@ -197,7 +198,7 @@ object ClientRequest extends RequestTypes[ClientRequest] { implicit class PutToServiceOps[Accept <: Coproduct]( val req: PutRequest[Accept, None.type, None.type] ) extends AnyVal { - def toService[In, Out]: ContentToService[Method.Put.type, Accept, In, Out] = ContentToService(req) + def toService[In, Out]: ContentToService[Method.Put, Accept, In, Out] = ContentToService(req) } case class PostMultipartFormToService[Accept <: Coproduct]( @@ -205,11 +206,11 @@ object ClientRequest extends RequestTypes[ClientRequest] { ) extends AnyVal { def toService[In, Out](implicit canBuildRequest: CanBuildRequest[ - HTTPRequest[Method.Post.type, Accept, In, MimeContent.MultipartForm] + HTTPRequest[Method.Post, Accept, In, MimeContent.MultipartForm] ], decodeAll: DecodeAll[Out, Accept] ): Service[In, Out] = - ContentToService[Method.Post.type, Accept, In, Out](req) + ContentToService[Method.Post, Accept, In, Out](req) .apply("multipart/form-data") } @@ -219,11 +220,11 @@ object ClientRequest extends RequestTypes[ClientRequest] { ) extends AnyVal { def toService[In, Out](implicit canBuildRequest: CanBuildRequest[ - HTTPRequest[Method.Post.type, Accept, In, MimeContent.WebForm] + HTTPRequest[Method.Post, Accept, In, MimeContent.WebForm] ], decodeAll: DecodeAll[Out, Accept] ): Service[In, Out] = - ContentToService[Method.Post.type, Accept, In, Out](req) + ContentToService[Method.Post, Accept, In, Out](req) .apply("application/x-www-form-urlencoded") def multipart: PostMultipartFormToService[Accept] = PostMultipartFormToService(req) @@ -232,7 +233,7 @@ object ClientRequest extends RequestTypes[ClientRequest] { implicit class PostToServiceOps[Accept <: Coproduct]( val req: PostRequest[Accept, None.type, None.type] ) extends AnyVal { - def toService[In, Out]: ContentToService[Method.Post.type, Accept, In, Out] = ContentToService(req) + def toService[In, Out]: ContentToService[Method.Post, Accept, In, Out] = ContentToService(req) def form: PostFormToService[Accept] = PostFormToService(req) } @@ -240,10 +241,10 @@ object ClientRequest extends RequestTypes[ClientRequest] { val req: GetRequest[Accept] ) extends AnyVal { def toService[Result](implicit - canBuildRequest: CanBuildRequest[HTTPRequest[Method.Get.type, Accept, None.type, None.type]], + canBuildRequest: CanBuildRequest[HTTPRequest[Method.Get, Accept, None.type, None.type]], decodeAll: DecodeAll[Result, Accept] ): () => Future[Result] = new Function0[Future[Result]] { - private val service = new CodecFilter[Method.Get.type, Accept, Request, None.type, Result]( + private val service = new CodecFilter[Method.Get, Accept, Request, None.type, Result]( req.request, req.client.maxFollows ) andThen req.client.httpClient @@ -259,7 +260,7 @@ object ClientRequest extends RequestTypes[ClientRequest] { } def toService[Params, Result](implicit - canBuildRequest: CanBuildRequest[HTTPRequest[Method.Get.type, Accept, None.type, None.type]], + canBuildRequest: CanBuildRequest[HTTPRequest[Method.Get, Accept, None.type, None.type]], decodeAll: DecodeAll[Result, Accept], toQueryParams: ToQueryParams[Params] ): Service[Params, Result] = { @@ -271,7 +272,7 @@ object ClientRequest extends RequestTypes[ClientRequest] { req => Future.value(req) ) } - ) andThen new CodecFilter[Method.Get.type, Accept, Request, None.type, Result]( + ) andThen new CodecFilter[Method.Get, Accept, Request, None.type, Result]( req.request, req.client.maxFollows ) andThen req.client.httpClient } @@ -279,20 +280,20 @@ object ClientRequest extends RequestTypes[ClientRequest] { implicit class HeadToServiceOps(val req: HeadRequest) extends AnyVal { def toService(implicit - canBuildRequest: CanBuildRequest[HTTPRequest[Method.Head.type, CNil, None.type, None.type]] + canBuildRequest: CanBuildRequest[HTTPRequest[Method.Head, CNil, None.type, None.type]] ): Service[Unit, Response] = new UnitToNone[Response] andThen - new CodecFilter[Method.Head.type, CNil, None.type, None.type, Response]( + new CodecFilter[Method.Head, CNil, None.type, None.type, Response]( req.request, req.client.maxFollows ) andThen req.client.httpClient } implicit class DeleteToServiceOps[Accept <: Coproduct](val req: DeleteRequest[Accept]) extends AnyVal { def toService[Result](implicit - canBuildRequest: CanBuildRequest[HTTPRequest[Method.Delete.type, Accept, None.type, None.type]], + canBuildRequest: CanBuildRequest[HTTPRequest[Method.Delete, Accept, None.type, None.type]], decodeAll: DecodeAll[Result, Accept] ): Service[Unit, Result] = new UnitToNone[Result] andThen - new CodecFilter[Method.Delete.type, Accept, None.type, None.type, Result]( + new CodecFilter[Method.Delete, Accept, None.type, None.type, Result]( req.request, req.client.maxFollows ) andThen req.client.httpClient } @@ -300,7 +301,7 @@ object ClientRequest extends RequestTypes[ClientRequest] { implicit class PatchToServiceOps[Accept <: Coproduct]( val req: PatchRequest[Accept, None.type, None.type] ) extends AnyVal { - def toService[In, Out]: ContentToService[Method.Patch.type, Accept, In, Out] = ContentToService(req) + def toService[In, Out]: ContentToService[Method.Patch, Accept, In, Out] = ContentToService(req) } private class UnitToNone[Rep] extends Filter[Unit, Rep, None.type, Rep] { diff --git a/featherbed-core/src/main/scala/featherbed/request/CodecFilter.scala b/featherbed-core/src/main/scala/featherbed/request/CodecFilter.scala index d572d77..22dffaa 100644 --- a/featherbed-core/src/main/scala/featherbed/request/CodecFilter.scala +++ b/featherbed-core/src/main/scala/featherbed/request/CodecFilter.scala @@ -1,18 +1,19 @@ -package featherbed.request +package featherbed +package request import java.net.URL import cats.data.Validated.{Invalid, Valid} import cats.syntax.either._ import com.twitter.finagle.{Filter, Service} -import com.twitter.finagle.http.{Method, Request, Response} +import com.twitter.finagle.http.{Request, Response} import com.twitter.finagle.http.Status._ import com.twitter.util.Future import featherbed.content.MimeContent import featherbed.support.{ContentType, DecodeAll, RuntimeContentType} import shapeless.{Coproduct, Witness} -class CodecFilter[Meth <: Method, Accept <: Coproduct, Content, ContentType, Result]( +class CodecFilter[Meth <: String, Accept <: Coproduct, Content, ContentType, Result]( request: HTTPRequest[Meth, Accept, None.type, ContentType], maxFollows: Int)(implicit canBuildRequest: CanBuildRequest[HTTPRequest[Meth, Accept, Content, ContentType]], diff --git a/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala b/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala index 0381dab..b649806 100644 --- a/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala +++ b/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala @@ -4,13 +4,13 @@ package request import java.io.File import java.net.{URI, URL, URLEncoder} import java.nio.charset.{Charset, StandardCharsets} - import scala.language.experimental.macros + +import cats.instances.list._ import cats.syntax.either._ import cats.syntax.traverse._ -import cats.instances.list._ import com.twitter.finagle.{Filter, Service, ServiceFactory} -import com.twitter.finagle.http._ +import com.twitter.finagle.http.{Method => _, _} import com.twitter.finagle.http.Status.{NoContent => _, _} import com.twitter.util.Future import featherbed.content.{Form, MimeContent, MultipartForm, ToFormParam} @@ -21,7 +21,7 @@ import featherbed.support.DecodeAll import shapeless._ case class HTTPRequest[ - Meth <: Method, + Meth <: String, Accept <: Coproduct, Content, ContentType @@ -100,7 +100,7 @@ case class HTTPRequest[ } object HTTPRequest extends RequestSyntax[HTTPRequest] with RequestTypes[HTTPRequest] { - def req[Meth <: Method, Accept <: Coproduct]( + def req[Meth <: String, Accept <: Coproduct]( method: Meth, url: URL, filters: Filter[Request, Response, Request, Response] ): HTTPRequest[Meth, Accept, None.type, None.type] = HTTPRequest(method, url, NoContent) @@ -110,14 +110,14 @@ object HTTPRequest extends RequestSyntax[HTTPRequest] with RequestTypes[HTTPRequ ) extends AnyVal { def withContent[Content, ContentType <: String](content: Content, contentType: ContentType)(implicit witness: Witness.Aux[contentType.type] - ): PostRequest[Accept, Content, contentType.type] = req.copy[Method.Post.type, Accept, Content, contentType.type]( + ): PostRequest[Accept, Content, contentType.type] = req.copy[Method.Post, Accept, Content, contentType.type]( content = MimeContent[Content, contentType.type](content) ) def withParams( first: (String, String), rest: (String, String)* - ): FormPostRequest[Accept, Form] = req.copy[Method.Post.type, Accept, Form, MimeContent.WebForm]( + ): FormPostRequest[Accept, Form] = req.copy[Method.Post, Accept, Form, MimeContent.WebForm]( content = MimeContent[Form, MimeContent.WebForm]( Form( NonEmptyList(first, rest.toList) @@ -149,13 +149,13 @@ object HTTPRequest extends RequestSyntax[HTTPRequest] with RequestTypes[HTTPRequ NonEmptyList(first, rest.toList).map((ToFormParam.file.apply _).tupled).sequenceU )) } - req.copy[Method.Post.type, Accept, MultipartForm, MimeContent.MultipartForm]( + req.copy[Method.Post, Accept, MultipartForm, MimeContent.MultipartForm]( content = newContent ) } def toService[In, Out](contentType: String)(client: Client)(implicit - canBuildRequest: CanBuildRequest[HTTPRequest[Method.Post.type, Accept, In, contentType.type]], + canBuildRequest: CanBuildRequest[HTTPRequest[Method.Post, Accept, In, contentType.type]], decodeAll: DecodeAll[Out, Accept], witness: Witness.Aux[contentType.type] ): Service[In, Out] = Service.mk[In, Out] { @@ -168,7 +168,7 @@ object HTTPRequest extends RequestSyntax[HTTPRequest] with RequestTypes[HTTPRequ ) extends AnyVal { def withContent[Content, ContentType <: String](content: Content, contentType: ContentType)(implicit witness: Witness.Aux[contentType.type] - ): PutRequest[Accept, Content, contentType.type] = req.copy[Method.Put.type, Accept, Content, contentType.type]( + ): PutRequest[Accept, Content, contentType.type] = req.copy[Method.Put, Accept, Content, contentType.type]( content = MimeContent[Content, contentType.type](content) ) } diff --git a/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala b/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala index 3029c5c..1b6037f 100644 --- a/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala +++ b/featherbed-core/src/main/scala/featherbed/request/RequestSyntax.scala @@ -4,7 +4,6 @@ package request import java.io.File import java.net.{URL, URLEncoder} import java.nio.charset.{Charset, StandardCharsets} - import scala.language.experimental.macros import scala.language.higherKinds @@ -12,7 +11,7 @@ import cats.data._, Validated._ import cats.implicits._ import cats.instances.list._ import com.twitter.finagle.Filter -import com.twitter.finagle.http.{Method, Request, Response} +import com.twitter.finagle.http.{Request, Response} import featherbed.content.{Form, MimeContent, MultipartForm} import shapeless.{CNil, Coproduct, Witness} @@ -29,9 +28,9 @@ import shapeless.{CNil, Coproduct, Witness} case class InvalidResponse(response: Response, reason: String) extends Throwable(reason) case class ErrorResponse(request: Request, response: Response) extends Throwable("Error response received") -trait RequestSyntax[Req[Meth <: Method, Accept <: Coproduct, Content, ContentType]] { self: RequestTypes[Req] => +trait RequestSyntax[Req[Meth <: String, Accept <: Coproduct, Content, ContentType]] { self: RequestTypes[Req] => - def req[Meth <: Method, Accept <: Coproduct]( + def req[Meth <: String, Accept <: Coproduct]( method: Meth, url: URL, filters: Filter[Request, Response, Request, Response] @@ -60,7 +59,7 @@ trait RequestSyntax[Req[Meth <: Method, Accept <: Coproduct, Content, ContentTyp def head( url: URL, filters: Filter[Request, Response, Request, Response] = Filter.identity - ): HeadRequest = req[Method.Head.type, CNil](Method.Head, url, filters = filters) + ): HeadRequest = req[Method.Head, CNil](Method.Head, url, filters = filters) def delete( url: URL, @@ -69,530 +68,22 @@ trait RequestSyntax[Req[Meth <: Method, Accept <: Coproduct, Content, ContentTyp } -trait RequestTypes[Req[Meth <: Method, Accept <: Coproduct, Content, ContentType]] { +trait RequestTypes[Req[Meth <: String, Accept <: Coproduct, Content, ContentType]] { - type GetRequest[Accept <: Coproduct] = Req[Method.Get.type, Accept, None.type, None.type] + type GetRequest[Accept <: Coproduct] = Req[Method.Get, Accept, None.type, None.type] type PostRequest[Accept <: Coproduct, Content, ContentType] = - Req[Method.Post.type, Accept, Content, ContentType] + Req[Method.Post, Accept, Content, ContentType] type FormPostRequest[Accept <: Coproduct, Content] = PostRequest[Accept, Content, MimeContent.WebForm] type MultipartFormRequest[Accept <: Coproduct, Content] = PostRequest[Accept, Content, MimeContent.MultipartForm] type PutRequest[Accept <: Coproduct, Content, ContentType] = - Req[Method.Put.type, Accept, Content, ContentType] + Req[Method.Put, Accept, Content, ContentType] - type HeadRequest = Req[Method.Head.type, CNil, None.type, None.type] - type DeleteRequest[Accept <: Coproduct] = Req[Method.Delete.type, Accept, None.type, None.type] + type HeadRequest = Req[Method.Head, CNil, None.type, None.type] + type DeleteRequest[Accept <: Coproduct] = Req[Method.Delete, Accept, None.type, None.type] type PatchRequest[Accept <: Coproduct, Content, ContentType] = - Req[Method.Patch.type, Accept, Content, ContentType] + Req[Method.Patch, Accept, Content, ContentType] } -// -// -//sealed trait RequestSyntax[Accept <: Coproduct, Self <: RequestSyntax[Accept, Self]] { self: Self => -// -// val url: URL -// val charset: Charset -// val headers: List[(String, String)] -// val filters: Filter[Request, Response, Request, Response] -// -// def withHeader(name: String, value: String): Self = withHeaders((name, value)) -// def withHeaders(headers: (String, String)*): Self -// def withCharset(charset: Charset): Self -// def withUrl(url: URL): Self -// def addFilter(filter: Filter[Request, Response, Request, Response]): Self -// def resetFilters: Self -// def setFilters(filter: Filter[Request, Response, Request, Response]): Self = resetFilters.addFilter(filter) -// -// def withQuery(query: String): Self = withUrl(new URL(url, url.getFile + "?" + query)) -// def withQueryParams(params: List[(String, String)]): Self = withQuery( -// params.map { -// case (key, value) => URLEncoder.encode(key, charset.name) + "=" + URLEncoder.encode(value, charset.name) -// }.mkString("&") -// ) -// def addQueryParams(params: List[(String, String)]): Self = withQuery( -// Option(url.getQuery).map(_ + "&").getOrElse("") + params.map { -// case (key, value) => URLEncoder.encode(key, charset.name) + "=" + URLEncoder.encode(value, charset.name) -// }.mkString("&") -// ) -// -// def withQueryParams(params: (String, String)*): Self = withQueryParams(params.toList) -// def addQueryParams(params: (String, String)*): Self = addQueryParams(params.toList) -// -// -// protected[featherbed] def buildHeaders[HasUrl]( -// rb: RequestBuilder[HasUrl, Nothing] -// ): RequestBuilder[HasUrl, Nothing] = -// headers.foldLeft(rb) { -// case (builder, (key, value)) => builder.addHeader(key, value) -// } -// -// protected[featherbed] def buildRequest(implicit -// canBuild: CanBuildRequest[Self] -// ): ValidatedNel[Throwable, Request] = canBuild.build(this: Self) -// -// private def cloneRequest(in: Request) = { -// val out = Request() -// out.uri = in.uri -// out.content = in.content -// in.headerMap.foreach { -// case (k, v) => out.headerMap.put(k, v) -// } -// out -// } -// -// private def handleRequest( -// request: Request, -// httpClient: Service[Request, Response], -// numRedirects: Int = 0 -// ): Future[Response] = -// (filters andThen httpClient)(request) flatMap { -// rep => rep.status match { -// case Continue => -// Future.exception(InvalidResponse( -// rep, -// "Received unexpected 100/Continue, but request body was already sent." -// )) -// case SwitchingProtocols => -// Future.exception(InvalidResponse( -// rep, -// "Received unexpected 101/Switching Protocols, but no switch was requested." -// )) -// case s if s.code >= 200 && s.code < 300 => -// Future(rep) -// case MultipleChoices => -// Future.exception(InvalidResponse(rep, "300/Multiple Choices is not yet supported in featherbed.")) -// case MovedPermanently | Found | SeeOther | TemporaryRedirect => -// val attempt = for { -// tooMany <- if (numRedirects > 5) -// Either.left("Too many redirects; giving up") -// else -// Either.right(()) -// location <- Either.fromOption( -// rep.headerMap.get("Location"), -// "Redirect required, but location header not present") -// newUrl <- Either.catchNonFatal(url.toURI.resolve(location)) -// .leftMap(_ => s"Could not resolve Location $location") -// canHandle <- if (newUrl.getHost != url.getHost) -// Either.left("Location points to another host; this isn't supported by featherbed") -// else -// Either.right(()) -// } yield { -// val newReq = cloneRequest(request) -// newReq.uri = List(Option(newUrl.getPath), Option(newUrl.getQuery).map("?" + _)).flatten.mkString -// handleRequest(newReq, httpClient, numRedirects + 1) -// } -// attempt.fold( -// err => Future.exception(InvalidResponse(rep, err)), -// identity -// ) -// case other => Future.exception(ErrorResponse(request, rep)) -// } -// } -// -// -// protected def decodeResponse[T](rep: Response)(implicit decodeAll: DecodeAll[T, Accept]) = -// rep.contentType flatMap ContentType.contentTypePieces match { -// case None => Future.exception(InvalidResponse(rep, "Content-Type header is not present")) -// case Some(RuntimeContentType(mediaType, _)) => decodeAll.instances.find(_.contentType == mediaType) match { -// case Some(decoder) => -// decoder(rep) match { -// case Valid(decoded) => -// Future(decoded) -// case Invalid(errs) => -// Future.exception(InvalidResponse(rep, errs.map(_.getMessage).toList.mkString("; "))) -// } -// case None => -// Future.exception(InvalidResponse(rep, s"No decoder was found for $mediaType")) -// } -// } -// -// /** -// * Send the request, decoding the response as [[K]] -// * -// * @tparam K The type to which the response will be decoded -// * @return A future which will contain a validated response -// */ -// protected def sendRequest[K](implicit -// canBuild: CanBuildRequest[Self], -// decodeAll: DecodeAll[K, Accept], -// httpClient: Service[Request, Response] -// ): Future[K] = -// buildRequest match { -// case Valid(req) => handleRequest(req, httpClient).flatMap { rep => -// rep.contentType.getOrElse("*/*") match { -// case ContentType(RuntimeContentType(mediaType, _)) => -// decodeAll.findInstance(mediaType) match { -// case Some(decoder) => -// decoder(rep) -// .leftMap(errs => InvalidResponse(rep, errs.map(_.getMessage).toList.mkString("; "))) -// .fold( -// Future.exception(_), -// Future(_) -// ) -// case None => -// Future.exception(InvalidResponse(rep, s"No decoder was found for $mediaType")) -// } -// case other => Future.exception(InvalidResponse(rep, s"Content-Type $other is not valid")) -// } -// } -// case Invalid(errs) => Future.exception(RequestBuildingError(errs)) -// } -// -// protected def sendZipRequest[Error, Success](implicit -// canBuild: CanBuildRequest[Self], -// decodeAllSuccess: DecodeAll[Success, Accept], -// decodeAllError: DecodeAll[Error, Accept], -// httpClient: Service[Request, Response] -// ): Future[(Either[Error, Success], Response)] = buildRequest match { -// case Valid(req) => handleRequest(req, httpClient) -// .flatMap { -// rep => decodeResponse[Success](rep).map(Either.right[Error, Success]).map((_, rep)) -// }.rescue { -// case ErrorResponse(_, rep) => decodeResponse[Error](rep).map(Either.left[Error, Success]).map((_, rep)) -// } -// case Invalid(errs) => Future.exception(RequestBuildingError(errs)) -// } -// -// protected def sendRequest[Error, Success](implicit -// canBuild: CanBuildRequest[Self], -// decodeAllSuccess: DecodeAll[Success, Accept], -// decodeAllError: DecodeAll[Error, Accept], -// httpClient: Service[Request, Response] -// ): Future[Either[Error, Success]] = -// sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError, httpClient).map(_._1) -// -//} -// -//case class GetRequest[Accept <: Coproduct]( -// url: URL, -// headers: List[(String, String)] = List.empty, -// charset: Charset = StandardCharsets.UTF_8, -// filters: Filter[Request, Response, Request, Response], -// httpClient: Service[Request, Response] -//) extends RequestSyntax[Accept, GetRequest[Accept]] { -// -// def accept[AcceptTypes <: Coproduct]: GetRequest[AcceptTypes] = copy[AcceptTypes]() -// def accept[AcceptTypes <: Coproduct](types: String*): GetRequest[AcceptTypes] = -// macro CoproductMacros.callAcceptCoproduct -// def withHeaders(addHeaders: (String, String)*): GetRequest[Accept] = copy(headers = headers ::: addHeaders.toList) -// def withCharset(charset: Charset): GetRequest[Accept] = copy(charset = charset) -// def withUrl(url: URL): GetRequest[Accept] = copy(url = url) -// def addFilter(filter: Filter[Request, Response, Request, Response]): GetRequest[Accept] = -// copy(filters = filter andThen filters) -// def resetFilters: GetRequest[Accept] = copy(filters = Filter.identity[Request, Response]) -// -// def send[K]()(implicit -// canBuild: CanBuildRequest[GetRequest[Accept]], -// decodeAll: DecodeAll[K, Accept] -// ): Future[K] = sendRequest[K](canBuild, decodeAll, httpClient) -// -// def send[Error, Success]()(implicit -// canBuild: CanBuildRequest[GetRequest[Accept]], -// decodeAllError: DecodeAll[Error, Accept], -// decodeAllSuccess: DecodeAll[Success, Accept] -// ): Future[Either[Error, Success]] = -// sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError, httpClient) -// -// def sendZip[Error, Success]()(implicit -// canBuild: CanBuildRequest[GetRequest[Accept]], -// decodeAllError: DecodeAll[Error, Accept], -// decodeAllSuccess: DecodeAll[Success, Accept] -// ): Future[(Either[Error, Success], Response)] = -// sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError, httpClient) -//} -// -//case class PostRequest[Content, ContentType, Accept <: Coproduct] ( -// url: URL, -// content: Content, -// headers: List[(String, String)] = List.empty, -// charset: Charset = StandardCharsets.UTF_8, -// filters: Filter[Request, Response, Request, Response], -// httpClient: Service[Request, Response] -//) extends RequestSyntax[Accept, PostRequest[Content, ContentType, Accept]] { -// -// def accept[AcceptTypes <: Coproduct]: PostRequest[Content, ContentType, AcceptTypes] = -// copy[Content, ContentType, AcceptTypes]() -// def accept[AcceptTypes <: Coproduct](types: String*): PostRequest[Content, ContentType, AcceptTypes] = -// macro CoproductMacros.callAcceptCoproduct -// def withHeaders(addHeaders: (String, String)*): PostRequest[Content, ContentType, Accept] = -// copy(headers = headers ::: addHeaders.toList) -// def withCharset(charset: Charset): PostRequest[Content, ContentType, Accept] = -// copy(charset = charset) -// def withUrl(url: URL): PostRequest[Content, ContentType, Accept] = -// copy(url = url) -// def addFilter(filter: Filter[Request, Response, Request, Response]): PostRequest[Content, ContentType, Accept] = -// copy(filters = filter andThen filters) -// def resetFilters: PostRequest[Content, ContentType, Accept] = copy(filters = Filter.identity[Request, Response]) -// -// def withContent[T, Type <: String]( -// content: T, -// typ: Type)(implicit -// witness: Witness.Aux[typ.type] -// ): PostRequest[T, typ.type, Accept] = -// copy[T, typ.type, Accept](content = content) -// -// -// def withParams( -// first: (String, String), -// rest: (String, String)* -// ): FormPostRequest[Accept, FormRight] = { -// val firstElement = Valid(SimpleElement(first._1, first._2)) -// val restElements = rest.toList.map { -// case (key, value) => Valid(SimpleElement(key, value)) -// } -// FormPostRequest( -// url, -// Right(NonEmptyList(firstElement, restElements)), -// multipart = false, -// headers, -// charset, -// filters -// ) -// } -// -// def addParams( -// first: (String, String), -// rest: (String, String)* -// ): FormPostRequest[Accept, FormRight] = { -// withParams(first, rest: _*) -// } -// -// def addFile[T, ContentType <: String]( -// name: String, -// content: T, -// contentType: ContentType, -// filename: Option[String] = None)(implicit -// encoder: Encoder[T, ContentType] -// ): FormPostRequest[Accept, FormRight] = { -// val element = encoder.apply(content, charset) map { -// buf => FileElement(name, buf, Some(contentType), filename) -// } -// FormPostRequest( -// url, -// Right(NonEmptyList(element, Nil)), -// multipart = true, -// headers, -// charset, -// filters -// ) -// } -// -// def send[K]()(implicit -// canBuild: CanBuildRequest[PostRequest[Content, ContentType, Accept]], -// decodeAll: DecodeAll[K, Accept] -// ): Future[K] = sendRequest[K](canBuild, decodeAll, httpClient) -// -// def send[Error, Success]()(implicit -// canBuild: CanBuildRequest[PostRequest[Content, ContentType, Accept]], -// decodeAllError: DecodeAll[Error, Accept], -// decodeAllSuccess: DecodeAll[Success, Accept] -// ): Future[Either[Error, Success]] = sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) -// -// def sendZip[Error, Success]()(implicit -// canBuild: CanBuildRequest[PostRequest[Content, ContentType, Accept]], -// decodeAllError: DecodeAll[Error, Accept], -// decodeAllSuccess: DecodeAll[Success, Accept] -// ): Future[(Either[Error, Success], Response)] = -// sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) -//} -// -//case class FormPostRequest[ -// Accept <: Coproduct, -// Elements <: Either[None.type, NonEmptyList[ValidatedNel[Throwable, FormElement]]] -//] ( -// url: URL, -// form: Elements = Left(None), -// multipart: Boolean = false, -// headers: List[(String, String)] = List.empty, -// charset: Charset = StandardCharsets.UTF_8, -// filters: Filter[Request, Response, Request, Response] -//) extends RequestSyntax[Accept, FormPostRequest[Accept, Elements]] { -// -// def accept[AcceptTypes <: Coproduct]: FormPostRequest[AcceptTypes, Elements] = -// copy[AcceptTypes, Elements]() -// def accept[AcceptTypes <: Coproduct](types: String*): FormPostRequest[AcceptTypes, Elements] = -// macro CoproductMacros.callAcceptCoproduct -// def withHeaders(addHeaders: (String, String)*): FormPostRequest[Accept, Elements] = -// copy(headers = headers ::: addHeaders.toList) -// def withCharset(charset: Charset): FormPostRequest[Accept, Elements] = -// copy(charset = charset) -// def withUrl(url: URL): FormPostRequest[Accept, Elements] = -// copy(url = url) -// def withMultipart(multipart: Boolean): FormPostRequest[Accept, Elements] = -// copy(multipart = multipart) -// def addFilter(filter: Filter[Request, Response, Request, Response]): FormPostRequest[Accept, Elements] = -// copy(filters = filter andThen filters) -// def resetFilters: FormPostRequest[Accept, Elements] = copy(filters = Filter.identity[Request, Response]) -// -// private[request] def withParamsList(params: NonEmptyList[ValidatedNel[Throwable, FormElement]]) = -// copy[Accept, FormRight]( -// form = Right(params) -// ) -// -// def withParams( -// first: (String, String), -// rest: (String, String)* -// ): FormPostRequest[Accept, FormRight] = { -// val firstElement = Valid(SimpleElement(first._1, first._2)) -// val restElements = rest.toList.map { -// case (key, value) => Valid(SimpleElement(key, value)) -// } -// withParamsList(NonEmptyList(firstElement, restElements)) -// } -// -// def addParams( -// first: (String, String), -// rest: (String, String)* -// ): FormPostRequest[Accept, FormRight] = { -// val firstElement = Valid(SimpleElement(first._1, first._2)) -// val restElements = rest.toList.map { -// case (key, value) => Valid(SimpleElement(key, value)): ValidatedNel[Throwable, FormElement] -// } -// val newParams = NonEmptyList(firstElement, restElements) -// withParamsList( -// form match { -// case Left(None) => newParams -// case Right(currentParams) => newParams concat currentParams -// }) -// } -// -// def addFile[T, ContentType <: String]( -// name: String, -// content: T, -// contentType: ContentType, -// filename: Option[String] = None)(implicit -// encoder: Encoder[T, ContentType] -// ): FormPostRequest[Accept, FormRight] = { -// val element = encoder.apply(content, charset) map { -// buf => FileElement(name, buf, Some(contentType), filename) -// } -// withParamsList(NonEmptyList(element, form.fold(_ => List.empty, _.toList))) -// } -// -// def send[K]()(implicit -// canBuild: CanBuildRequest[FormPostRequest[Accept, Elements]], -// decodeAll: DecodeAll[K, Accept] -// ): Future[K] = sendRequest[K](canBuild, decodeAll) -// -// def send[Error, Success]()(implicit -// canBuild: CanBuildRequest[FormPostRequest[Accept, Elements]], -// decodeAllError: DecodeAll[Error, Accept], -// decodeAllSuccess: DecodeAll[Success, Accept] -// ): Future[Either[Error, Success]] = sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) -// -// def sendZip[Error, Success]()(implicit -// canBuild: CanBuildRequest[FormPostRequest[Accept, Elements]], -// decodeAllError: DecodeAll[Error, Accept], -// decodeAllSuccess: DecodeAll[Success, Accept] -// ): Future[(Either[Error, Success], Response)] = -// sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) -//} -// -//case class PutRequest[Content, ContentType, Accept <: Coproduct]( -// url: URL, -// content: Content, -// headers: List[(String, String)] = List.empty, -// charset: Charset = StandardCharsets.UTF_8, -// filters: Filter[Request, Response, Request, Response] -//) extends RequestSyntax[Accept, PutRequest[Content, ContentType, Accept]] { -// -// def accept[AcceptTypes <: Coproduct]: PutRequest[Content, ContentType, AcceptTypes] = -// copy[Content, ContentType, AcceptTypes]() -// def accept[AcceptTypes <: Coproduct](types: String*): PutRequest[Content, ContentType, AcceptTypes] = -// macro CoproductMacros.callAcceptCoproduct -// def withHeaders(addHeaders: (String, String)*): PutRequest[Content, ContentType, Accept] = -// copy(headers = headers ::: addHeaders.toList) -// def withCharset(charset: Charset): PutRequest[Content, ContentType, Accept] = -// copy(charset = charset) -// def withUrl(url: URL): PutRequest[Content, ContentType, Accept] = -// copy(url = url) -// def addFilter(filter: Filter[Request, Response, Request, Response]): PutRequest[Content, ContentType, Accept] = -// copy(filters = filter andThen filters) -// def resetFilters: PutRequest[Content, ContentType, Accept] = copy(filters = Filter.identity[Request, Response]) -// -// def withContent[T, Type <: String]( -// content: T, -// typ: Type)(implicit -// witness: Witness.Aux[typ.type] -// ): PutRequest[T, typ.type, Accept] = -// copy[T, typ.type, Accept](content = content) -// -// def send[K]()(implicit -// canBuild: CanBuildRequest[PutRequest[Content, ContentType, Accept]], -// decodeAll: DecodeAll[K, Accept] -// ): Future[K] = sendRequest[K](canBuild, decodeAll) -// -// def send[Error, Success]()(implicit -// canBuild: CanBuildRequest[PutRequest[Content, ContentType, Accept]], -// decodeAllError: DecodeAll[Error, Accept], -// decodeAllSuccess: DecodeAll[Success, Accept] -// ): Future[Either[Error, Success]] = sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) -// -// def sendZip[Error, Success]()(implicit -// canBuild: CanBuildRequest[PutRequest[Content, ContentType, Accept]], -// decodeAllError: DecodeAll[Error, Accept], -// decodeAllSuccess: DecodeAll[Success, Accept] -// ): Future[(Either[Error, Success], Response)] = -// sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) -//} -// -//case class HeadRequest( -// url: URL, -// headers: List[(String, String)] = List.empty, -// charset: Charset = StandardCharsets.UTF_8, -// filters: Filter[Request, Response, Request, Response] -//) extends RequestSyntax[Nothing, HeadRequest] { -// -// def withHeaders(addHeaders: (String, String)*): HeadRequest = copy(headers = headers ::: addHeaders.toList) -// def withCharset(charset: Charset): HeadRequest = copy(charset = charset) -// def withUrl(url: URL): HeadRequest = copy(url = url) -// def addFilter(filter: Filter[Request, Response, Request, Response]): HeadRequest = -// copy(filters = filter andThen filters) -// def resetFilters: HeadRequest = copy(filters = Filter.identity[Request, Response]) -// -// def send()(implicit -// canBuild: CanBuildRequest[HeadRequest], -// decodeAll: DecodeAll[Response, Nothing] -// ): Future[Response] = sendRequest[Response](canBuild, decodeAll) -//} -// -//case class DeleteRequest[Accept <: Coproduct]( -// url: URL, -// headers: List[(String, String)] = List.empty, -// charset: Charset = StandardCharsets.UTF_8, -// filters: Filter[Request, Response, Request, Response] -//) extends RequestSyntax[Accept, DeleteRequest[Accept]] { -// -// def accept[AcceptTypes <: Coproduct]: DeleteRequest[AcceptTypes] = copy[AcceptTypes]() -// def accept[AcceptTypes <: Coproduct](types: String*): DeleteRequest[AcceptTypes] = -// macro CoproductMacros.callAcceptCoproduct -// def withHeaders(addHeaders: (String, String)*): DeleteRequest[Accept] = -// copy(headers = headers ::: addHeaders.toList) -// def withCharset(charset: Charset): DeleteRequest[Accept] = copy(charset = charset) -// def withUrl(url: URL): DeleteRequest[Accept] = copy(url = url) -// def addFilter(filter: Filter[Request, Response, Request, Response]): DeleteRequest[Accept] = -// copy(filters = filter andThen filters) -// def resetFilters: DeleteRequest[Accept] = copy(filters = Filter.identity[Request, Response]) -// -// def send[K]()(implicit -// canBuild: CanBuildRequest[DeleteRequest[Accept]], -// decodeAll: DecodeAll[K, Accept] -// ): Future[K] = sendRequest[K](canBuild, decodeAll) -// -// def send[Error, Success]()(implicit -// canBuild: CanBuildRequest[DeleteRequest[Accept]], -// decodeAllError: DecodeAll[Error, Accept], -// decodeAllSuccess: DecodeAll[Success, Accept] -// ): Future[Either[Error, Success]] = sendRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) -// -// def sendZip[Error, Success]()(implicit -// canBuild: CanBuildRequest[DeleteRequest[Accept]], -// decodeAllError: DecodeAll[Error, Accept], -// decodeAllSuccess: DecodeAll[Success, Accept] -// ): Future[(Either[Error, Success], Response)] = -// sendZipRequest[Error, Success](canBuild, decodeAllSuccess, decodeAllError) -//} -// -// diff --git a/featherbed-test/src/test/scala/featherbed/ClientSpec.scala b/featherbed-core/src/test/scala/featherbed/ClientSpec.scala similarity index 90% rename from featherbed-test/src/test/scala/featherbed/ClientSpec.scala rename to featherbed-core/src/test/scala/featherbed/ClientSpec.scala index 4b36d9e..df51e56 100644 --- a/featherbed-test/src/test/scala/featherbed/ClientSpec.scala +++ b/featherbed-core/src/test/scala/featherbed/ClientSpec.scala @@ -3,9 +3,9 @@ package featherbed import java.nio.charset.Charset import com.twitter.finagle.{Service, SimpleFilter} -import com.twitter.finagle.http.{Method, Request, Response} +import com.twitter.finagle.http.{Request, Response} import com.twitter.util.{Await, Future} -import featherbed.content.ToFormParams +import featherbed.fixture.ClientTest import org.scalamock.scalatest.MockFactory import org.scalatest.FreeSpec @@ -37,7 +37,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Get) + assert(req.method.name == Method.Get) assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) } } @@ -54,7 +54,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Get) + assert(req.method.name == Method.Get) assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) } } @@ -77,7 +77,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar?param=value") - assert(req.method == Method.Get) + assert(req.method.name == Method.Get) assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) } } @@ -102,7 +102,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar?first=10&second=foo") - assert(req.method == Method.Get) + assert(req.method.name == Method.Get) assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) } } @@ -127,7 +127,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Post) + assert(req.method.name == Method.Post) assert(req.headerMap("Content-Type") == s"text/plain; charset=${Charset.defaultCharset.name}") assert(req.contentString == "Hello world") assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) @@ -147,7 +147,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Post) + assert(req.method.name == Method.Post) assert(req.headerMap("Content-Type") == s"text/plain; charset=${Charset.defaultCharset.name}") assert(req.contentString == "Hello world") assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) @@ -167,7 +167,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Post) + assert(req.method.name == Method.Post) assert(req.headerMap("Content-Type") == s"application/x-www-form-urlencoded") assert(req.contentString == "foo=bar&bar=baz") assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) @@ -189,7 +189,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Post) + assert(req.method.name == Method.Post) assert(req.headerMap("Content-Type") == s"application/x-www-form-urlencoded") assert(req.contentString == "foo=bar&bar=baz") assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) @@ -209,7 +209,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Head) + assert(req.method.name == Method.Head) } } @@ -220,7 +220,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Head) + assert(req.method.name == Method.Head) } } } @@ -236,7 +236,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Delete) + assert(req.method.name == Method.Delete) } } @@ -247,7 +247,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Delete) + assert(req.method.name == Method.Delete) } } } @@ -264,7 +264,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Put) + assert(req.method.name == Method.Put) assert(req.contentType.contains(s"text/plain; charset=${Charset.defaultCharset.name}")) assert(req.contentString == "Hello world") } @@ -276,7 +276,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Put) + assert(req.method.name == Method.Put) assert(req.contentType.contains(s"text/plain; charset=${Charset.defaultCharset.name}")) assert(req.contentString == "Hello world") } @@ -294,7 +294,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Get) + assert(req.method.name == Method.Get) assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) } } @@ -312,7 +312,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar?param=value") - assert(req.method == Method.Get) + assert(req.method.name == Method.Get) assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) } } @@ -329,7 +329,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Post) + assert(req.method.name == Method.Post) assert(req.headerMap("Content-Type") == s"text/plain; charset=${Charset.defaultCharset.name}") assert(req.contentString == "Hello world") assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) @@ -346,7 +346,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Post) + assert(req.method.name == Method.Post) assert(req.headerMap("Content-Type") == s"application/x-www-form-urlencoded") assert(req.contentString == "foo=bar&bar=baz") assert((req.accept.toSet diff Set("text/plain", "*/*; q=0")) == Set.empty) @@ -362,7 +362,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Delete) + assert(req.method.name == Method.Delete) } } @@ -376,7 +376,7 @@ class ClientSpec extends FreeSpec with MockFactory with ClientTest { receiver verify request { req => assert(req.uri == "/api/v1/foo/bar") - assert(req.method == Method.Put) + assert(req.method.name == Method.Put) assert(req.contentType.contains(s"text/plain; charset=${Charset.defaultCharset.name}")) assert(req.contentString == "Hello world") } diff --git a/featherbed-test/src/test/scala/featherbed/ErrorHandlingSpec.scala b/featherbed-core/src/test/scala/featherbed/ErrorHandlingSpec.scala similarity index 95% rename from featherbed-test/src/test/scala/featherbed/ErrorHandlingSpec.scala rename to featherbed-core/src/test/scala/featherbed/ErrorHandlingSpec.scala index d60fd4f..02c760b 100644 --- a/featherbed-test/src/test/scala/featherbed/ErrorHandlingSpec.scala +++ b/featherbed-core/src/test/scala/featherbed/ErrorHandlingSpec.scala @@ -1,25 +1,17 @@ package featherbed -import java.nio.charset.Charset - -import featherbed.circe._ -import featherbed.content.Encoder import cats.data.{NonEmptyList, Validated} import cats.data.Validated.{Invalid, Valid} import com.twitter.finagle.{Service, SimpleFilter} -import com.twitter.finagle.http.{Method, Request, Response} +import com.twitter.finagle.http.{Request, Response} import com.twitter.io.Buf import com.twitter.util.{Await, Future} import featherbed.content.{Decoder, Encoder} import featherbed.fixture.ClientTest import featherbed.request._ -import featherbed.request._ -import featherbed.support.DecodeAll -import io.circe.generic.auto._ -import io.circe.syntax._ import org.scalamock.scalatest.MockFactory import org.scalatest.FreeSpec -import shapeless.{:+:, CNil, Witness} +import shapeless.Witness class ErrorHandlingSpec extends FreeSpec with MockFactory with ClientTest { @@ -34,9 +26,9 @@ class ErrorHandlingSpec extends FreeSpec with MockFactory with ClientTest { case object BadContent extends TestContent implicit val testContentEncoder: Encoder[TestContent, Witness.`"test/content"`.T] = Encoder.of("test/content") { - case (GoodContent, _) => Valid(Buf.Utf8("Good")).toValidatedNel - case (BadContent, _) => Invalid(NonEmptyList(new Exception("Bad"), Nil)) - } + case (GoodContent, _) => Valid(Buf.Utf8("Good")).toValidatedNel + case (BadContent, _) => Invalid(NonEmptyList(new Exception("Bad"), Nil)) + } case object DecodingError extends Throwable diff --git a/featherbed-core/src/test/scala/featherbed/fixture/package.scala b/featherbed-core/src/test/scala/featherbed/fixture/package.scala index e69de29..8b3245f 100644 --- a/featherbed-core/src/test/scala/featherbed/fixture/package.scala +++ b/featherbed-core/src/test/scala/featherbed/fixture/package.scala @@ -0,0 +1,35 @@ +package featherbed + +import java.net.URL + +import com.twitter.finagle.http.{Request, Response} +import com.twitter.finagle.{Filter, Http} +import org.scalamock.matchers.Matcher +import org.scalamock.scalatest.MockFactory + +package object fixture { + private[fixture] class MockClient ( + baseUrl: URL, + filter: Filter[Request, Response, Request, Response] + ) extends Client(baseUrl) { + override def clientTransform(c: Http.Client) = c.filtered(filter) + } + + trait ClientTest { self: MockFactory => + class TransportRequestMatcher(f: Request => Unit) extends Matcher[Any] { + override def canEqual(x: Any) = x match { + case x: Request => true + case _ => false + } + override def safeEquals(that: Any): Boolean = that match { + case x: Request => f(x); true + case _ => false + } + } + + def request(f: Request => Unit): TransportRequestMatcher = new TransportRequestMatcher(f) + + def mockClient(url: String, filter: Filter[Request, Response, Request, Response]): Client = + new MockClient(new URL(url), filter) + } +} diff --git a/featherbed-test/src/test/scala/featherbed/package.scala b/featherbed-test/src/test/scala/featherbed/package.scala deleted file mode 100644 index f0aece4..0000000 --- a/featherbed-test/src/test/scala/featherbed/package.scala +++ /dev/null @@ -1,33 +0,0 @@ -import java.net.URL - -import com.twitter.finagle.{Filter, Http} -import com.twitter.finagle.http.{Request, Response} -import org.scalamock.matchers.Matcher -import org.scalamock.scalatest.MockFactory - -package object featherbed { - private[featherbed] class MockClient ( - baseUrl: URL, - filter: Filter[Request, Response, Request, Response] - ) extends Client(baseUrl) { - override def clientTransform(c: Http.Client) = c.filtered(filter) - } - - trait ClientTest { self: MockFactory => - class TransportRequestMatcher(f: Request => Unit) extends Matcher[Any] { - override def canEqual(x: Any) = x match { - case x: Request => true - case _ => false - } - override def safeEquals(that: Any): Boolean = that match { - case x: Request => f(x); true - case _ => false - } - } - - def request(f: Request => Unit): TransportRequestMatcher = new TransportRequestMatcher(f) - - def mockClient(url: String, filter: Filter[Request, Response, Request, Response]): Client = - new MockClient(new URL(url), filter) - } -} diff --git a/project/plugins.sbt b/project/plugins.sbt index 2dcd4af..9717c2f 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -5,6 +5,7 @@ resolvers ++= Seq( ) addSbtPlugin("org.tpolecat" % "tut-plugin" % "0.5.2") +addSbtPlugin("org.tpolecat" % "tut-plugin" % "0.5.0") addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "1.1") addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0") addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "0.8.0") From 2c8b0e0d2307e7ad66034254cbda860123e88314 Mon Sep 17 00:00:00 2001 From: Jeremy Smith Date: Tue, 9 May 2017 14:01:15 -0700 Subject: [PATCH 06/10] Fix build issues --- build.sbt | 9 ++++++++- .../src/main/scala/featherbed/circe/package.scala | 1 - 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/build.sbt b/build.sbt index 46e6ebb..2dc7840 100644 --- a/build.sbt +++ b/build.sbt @@ -15,6 +15,7 @@ lazy val buildSettings = Seq( val finagleVersion = "17.12.0" val shapelessVersion = "2.3.3" val catsVersion = "1.0.1" +val circeVersion = "0.7.1" lazy val docSettings = Seq( autoAPIMappings := true @@ -73,7 +74,13 @@ lazy val `featherbed-core` = project .settings(allSettings) lazy val `featherbed-circe` = project - .settings(allSettings) + .settings( + libraryDependencies ++= Seq( + "io.circe" %% "circe-parser" % circeVersion, + "io.circe" %% "circe-generic" % circeVersion % "test" + ), + allSettings + ) .dependsOn(`featherbed-core`) val scaladocVersionPath = settingKey[String]("Path to this version's ScalaDoc") diff --git a/featherbed-circe/src/main/scala/featherbed/circe/package.scala b/featherbed-circe/src/main/scala/featherbed/circe/package.scala index fa02d34..cb1eee3 100644 --- a/featherbed-circe/src/main/scala/featherbed/circe/package.scala +++ b/featherbed-circe/src/main/scala/featherbed/circe/package.scala @@ -5,7 +5,6 @@ import java.nio.charset.Charset import cats.data.ValidatedNel import cats.implicits._ import io.circe._ -import io.circe.generic.auto._ import io.circe.parser._ import io.circe.syntax._ import shapeless.Witness From df9631fc36dc7bcba3d180f4c0ea9a7089e92fdc Mon Sep 17 00:00:00 2001 From: Jeremy Smith Date: Tue, 9 May 2017 14:07:20 -0700 Subject: [PATCH 07/10] Scalastyle nags --- .../src/main/scala/featherbed/content/Decoder.scala | 1 - .../src/main/scala/featherbed/content/Encoder.scala | 1 - .../src/main/scala/featherbed/content/ToQueryParams.scala | 2 +- .../src/main/scala/featherbed/request/ClientRequest.scala | 3 +-- .../src/main/scala/featherbed/request/CodecFilter.scala | 3 +-- .../src/main/scala/featherbed/request/HTTPRequest.scala | 2 +- 6 files changed, 4 insertions(+), 8 deletions(-) diff --git a/featherbed-core/src/main/scala/featherbed/content/Decoder.scala b/featherbed-core/src/main/scala/featherbed/content/Decoder.scala index 21a7525..20e7c98 100644 --- a/featherbed-core/src/main/scala/featherbed/content/Decoder.scala +++ b/featherbed-core/src/main/scala/featherbed/content/Decoder.scala @@ -1,7 +1,6 @@ package featherbed.content import java.nio.charset.{Charset, CodingErrorAction} - import scala.util.Try import cats.data.{Validated, ValidatedNel} diff --git a/featherbed-core/src/main/scala/featherbed/content/Encoder.scala b/featherbed-core/src/main/scala/featherbed/content/Encoder.scala index 013f8ab..7d32281 100644 --- a/featherbed-core/src/main/scala/featherbed/content/Encoder.scala +++ b/featherbed-core/src/main/scala/featherbed/content/Encoder.scala @@ -2,7 +2,6 @@ package featherbed.content import java.nio.CharBuffer import java.nio.charset.{Charset, CodingErrorAction} - import scala.util.Try import cats.data.{Validated, ValidatedNel} diff --git a/featherbed-core/src/main/scala/featherbed/content/ToQueryParams.scala b/featherbed-core/src/main/scala/featherbed/content/ToQueryParams.scala index 38173b9..d1e5694 100644 --- a/featherbed-core/src/main/scala/featherbed/content/ToQueryParams.scala +++ b/featherbed-core/src/main/scala/featherbed/content/ToQueryParams.scala @@ -66,4 +66,4 @@ trait ToQueryParam0 { implicit def default[T](implicit lowPriority: LowPriority): ToQueryParam[T] = new ToQueryParam[T] { def apply(t: T): String = t.toString } -} \ No newline at end of file +} diff --git a/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala b/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala index 73cb7db..a5e496c 100644 --- a/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala +++ b/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala @@ -308,5 +308,4 @@ object ClientRequest extends RequestTypes[ClientRequest] { def apply(request: Unit, service: Service[None.type, Rep]): Future[Rep] = service(None) } - -} \ No newline at end of file +} diff --git a/featherbed-core/src/main/scala/featherbed/request/CodecFilter.scala b/featherbed-core/src/main/scala/featherbed/request/CodecFilter.scala index 22dffaa..0dd47ad 100644 --- a/featherbed-core/src/main/scala/featherbed/request/CodecFilter.scala +++ b/featherbed-core/src/main/scala/featherbed/request/CodecFilter.scala @@ -106,5 +106,4 @@ object CodecFilter { } } - -} \ No newline at end of file +} diff --git a/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala b/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala index b649806..f6da5b6 100644 --- a/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala +++ b/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala @@ -172,4 +172,4 @@ object HTTPRequest extends RequestSyntax[HTTPRequest] with RequestTypes[HTTPRequ content = MimeContent[Content, contentType.type](content) ) } -} \ No newline at end of file +} From de9c20b2c2bba465f68b43b0ae358931ee05b79d Mon Sep 17 00:00:00 2001 From: Jeremy Smith Date: Mon, 27 Nov 2017 06:13:33 -0800 Subject: [PATCH 08/10] Updating tuts --- build.sbt | 3 + docs/src/main/tut/01-installation.md | 2 +- docs/src/main/tut/02-basic-usage.md | 62 ++++++++++--------- .../main/tut/03-content-types-and-encoders.md | 26 ++++---- docs/src/main/tut/05-error-handling.md | 7 ++- .../featherbed/content/ToFormParams.scala | 2 + .../featherbed/request/ClientRequest.scala | 2 +- .../scala/featherbed/ErrorHandlingSpec.scala | 24 ++++--- .../scala/featherbed/fixture/package.scala | 6 +- .../featherbed/fixture/package.scala~HEAD | 0 project/plugins.sbt | 1 - 11 files changed, 74 insertions(+), 61 deletions(-) rename featherbed-circe/build.sbt => featherbed-core/src/test/scala/featherbed/fixture/package.scala~HEAD (100%) diff --git a/build.sbt b/build.sbt index 2dc7840..09525c4 100644 --- a/build.sbt +++ b/build.sbt @@ -106,6 +106,9 @@ lazy val docs: Project = project case _ => Nil } ) + ), + libraryDependencies ++= Seq( + "io.circe" %% "circe-generic" % circeVersion ) ).dependsOn(`featherbed-core`, `featherbed-circe`) diff --git a/docs/src/main/tut/01-installation.md b/docs/src/main/tut/01-installation.md index 45ae5c8..36efb4e 100644 --- a/docs/src/main/tut/01-installation.md +++ b/docs/src/main/tut/01-installation.md @@ -11,7 +11,7 @@ Add the following to build.sbt resolvers += Resolver.sonatypeRepo("snapshots") libraryDependencies ++= Seq( - "io.github.finagle" %"featherbed_2.11" %"0.3.0" + "io.github.finagle" %"featherbed_2.11" %"0.3.1" ) ``` Next, read about [Basic Usage](02-basic-usage.html) diff --git a/docs/src/main/tut/02-basic-usage.md b/docs/src/main/tut/02-basic-usage.md index 80e42e9..54bbbd2 100644 --- a/docs/src/main/tut/02-basic-usage.md +++ b/docs/src/main/tut/02-basic-usage.md @@ -32,6 +32,7 @@ Create a `Client`, passing the base URL to the REST endpoints: import java.net.URL val client = new featherbed.Client(new URL("http://localhost:8765/api/")) ``` + *Note:* It is important to put a trailing slash on your URL. This is because the resource path you'll pass in below is evaluated as a relative URL to the base URL given. Without a trailing slash, the `api` directory above would be lost when the relative URL is resolved. @@ -41,8 +42,9 @@ Now you can make some requests: ```tut:book import com.twitter.util.Await -Await.result { - val request = client.get("test/resource").toService[Response] +val request = client.get("test/resource").toService[Response] + +Await.result { request() map { response => response.contentString } @@ -61,30 +63,30 @@ Here's an example of using a `POST` request to submit a web form-style request: ```tut:book import java.nio.charset.StandardCharsets._ +val request = client. + post("another/resource"). + withCharset(UTF_8). + withHeaders("X-Foo" -> "scooby-doo"). + form.toService[Map[String, String], Response] + Await.result { - client - .post("another/resource") - .withParams( - "foo" -> "foz", - "bar" -> "baz") - .withCharset(UTF_8) - .withHeaders("X-Foo" -> "scooby-doo") - .send[Response]() - .map { - response => response.contentString - } + request(Map("foo" -> "foz", "bar" -> "baz")) map { + response => response.contentString + } } ``` Here's how you might send a `HEAD` request (note the lack of a type argument to `send()` for a HEAD request): ```tut:book +val request = client.head("head/request").toService + Await.result { - client.head("head/request").send().map(_.headerMap) + request().map(_.headerMap) } ``` -A `DELETE` request: +A `DELETE` request (using the `send` syntax rather than `toService`): ```tut:book Await.result { @@ -94,34 +96,34 @@ Await.result { } ``` -And a `PUT` request - notice how content can be provided to a `PUT` request by giving it a `Buf` buffer and a MIME type -to serve as the `Content-Type`: +And a `PUT` request - notice how a `Content-Type` is provided to the `toService` method, and we use `Buf` in this +example to provide the content of the request. ```tut:book import com.twitter.io.Buf +val request = client. + put("put/request"). + toService[Buf, Response]("text/plain") + Await.result { - client.put("put/request") - .withContent(Buf.Utf8("Hello world!"), "text/plain") - .send[Response]() - .map { - response => response.statusCode - } + request(Buf.Utf8("Hello world!")).map { + response => response.statusCode + } } ``` You can also provide content to a `POST` request in the same fashion: ```tut:book -import com.twitter.io.Buf +val request = client. + post("another/post/request"). + toService[Buf, Response]("text/plain") Await.result { - client.post("another/post/request") - .withContent(Buf.Utf8("Hello world!"), "text/plain") - .send[Response]() - .map { - response => response.contentString - } + request(Buf.Utf8("Hello world!")).map { + response => response.contentString + } } ``` diff --git a/docs/src/main/tut/03-content-types-and-encoders.md b/docs/src/main/tut/03-content-types-and-encoders.md index 08cc671..8bdff73 100644 --- a/docs/src/main/tut/03-content-types-and-encoders.md +++ b/docs/src/main/tut/03-content-types-and-encoders.md @@ -49,11 +49,13 @@ import featherbed.circe._ // An ADT for the request case class Foo(someText : String, someInt : Int) -// It can be passed directly to the POST -val req = client.post("foo/bar").withContent(Foo("Hello world!", 42), "application/json") +// Create a service for the request +val req = client.post("foo/bar"). + toService[Foo, Response]("application/json") +// We can pass Foo directly to the service val result = Await.result { - req.send[Response]() map { + req(Foo("Hello world!", 42)) map { response => response.contentString } } @@ -69,18 +71,16 @@ type's companion object. See the Circe documentation for more details about JSO ### A Note About Evaluation -You may have noticed that above we created a value called `req`, which held the result of specifying the request -type and its parameters. We later called `send[Response]` on that value and `map`ped over the result to specify a +You may have noticed that above we created a value called `req`, which held the result of specifying the request type +and creating a service. We later called `req()` on that value and `map`ped over the result to specify a transformation of the response. -It's important to note that the request itself **is not performed** until the call to `send`. Until that call is made, -you will have an instance of some kind of request, but you will not have a `Future` representing the response. That is, -the request itself is *lazy*. The reason this is important to note is that `req` itself can actually be used to make -the same request again. If another call is made to `send`, a new request of the same parameters will be initiated and a -new `Future` will be returned. This can be a useful and powerful thing, but it can also bite you if you're unaware. - -For more information about lazy tasks, take a look at scalaz's `Task` or cats's `Eval`. Again, this is important to -note, and is different than what people are used to with Finagle's `Future` (which is not lazy). +It's important to note that the request itself **is not performed** until the call to `Service#apply`. Until that call +is made, you will have an instance of some kind of request, but you will not have a `Future` representing the response. +That is, the request itself is *lazy*. The reason this is important to note is that `req` itself can actually be used +to make the same request again. If another call is made to `req()`, a new request of the same configuration will be +initiated with the new input and a new `Future` will be returned. This can be a useful and powerful thing, but it +can also bite you if you're unaware. ### A Note About Types diff --git a/docs/src/main/tut/05-error-handling.md b/docs/src/main/tut/05-error-handling.md index d01b18d..11f8246 100644 --- a/docs/src/main/tut/05-error-handling.md +++ b/docs/src/main/tut/05-error-handling.md @@ -58,10 +58,13 @@ When using the `send[T]` method, the resulting `Future` will *fail* if the serve in order to handle an error, you must handle it at the `Future` level using the `Future` API: ```tut:book:nofail -val req = client.get("not/found").accept("application/json") +val req = client. + get("not/found"). + accept("application/json"). + toService[Foo] Await.result { - req.send[Foo]().handle { + req().handle { case ErrorResponse(request, response) => throw new Exception(s"Error response $response to request $request") } diff --git a/featherbed-core/src/main/scala/featherbed/content/ToFormParams.scala b/featherbed-core/src/main/scala/featherbed/content/ToFormParams.scala index 4b8618c..d335b37 100644 --- a/featherbed-core/src/main/scala/featherbed/content/ToFormParams.scala +++ b/featherbed-core/src/main/scala/featherbed/content/ToFormParams.scala @@ -42,6 +42,8 @@ object ToFormParams { toFormParamsL: ToFormParams[L] ): ToFormParams[P] = Instance(p => toFormParamsL(gen.to(p))) + implicit val stringMap: ToFormParams[Map[String, String]] = + Instance(map => Validated.valid(map.toList.map(SimpleElement.tupled))) implicit val form: ToFormParams[Form] = Instance(form => Validated.valid(form.params.toList)) implicit val multipartForm: ToFormParams[MultipartForm] = Instance(form => form.params.map(_.toList)) diff --git a/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala b/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala index a5e496c..e0ce6e4 100644 --- a/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala +++ b/featherbed-core/src/main/scala/featherbed/request/ClientRequest.scala @@ -105,7 +105,7 @@ case class ClientRequest[Meth <: String, Accept <: Coproduct, Content, ContentTy response => CodecFilter.decodeResponse[K, Accept](response) } - def send[E, S]()(implicit + def trySend[E, S]()(implicit canBuildRequest: CanBuildRequest[HTTPRequest[Meth, Accept, Content, ContentType]], decodeSuccess: DecodeAll[S, Accept], decodeError: DecodeAll[E, Accept] diff --git a/featherbed-core/src/test/scala/featherbed/ErrorHandlingSpec.scala b/featherbed-core/src/test/scala/featherbed/ErrorHandlingSpec.scala index 02c760b..863eab8 100644 --- a/featherbed-core/src/test/scala/featherbed/ErrorHandlingSpec.scala +++ b/featherbed-core/src/test/scala/featherbed/ErrorHandlingSpec.scala @@ -26,9 +26,9 @@ class ErrorHandlingSpec extends FreeSpec with MockFactory with ClientTest { case object BadContent extends TestContent implicit val testContentEncoder: Encoder[TestContent, Witness.`"test/content"`.T] = Encoder.of("test/content") { - case (GoodContent, _) => Valid(Buf.Utf8("Good")).toValidatedNel - case (BadContent, _) => Invalid(NonEmptyList(new Exception("Bad"), Nil)) - } + case (GoodContent, _) => Valid(Buf.Utf8("Good")).toValidatedNel + case (BadContent, _) => Invalid(NonEmptyList(new Exception("Bad"), Nil)) + } case object DecodingError extends Throwable @@ -139,7 +139,7 @@ class ErrorHandlingSpec extends FreeSpec with MockFactory with ClientTest { } - "send[Error, Success]" - { + "trySend[Error, Success]" - { "returns failed future when request is invalid" in { val content: TestContent = BadContent @@ -147,7 +147,7 @@ class ErrorHandlingSpec extends FreeSpec with MockFactory with ClientTest { .withContent(content, "test/content") .accept("test/response") - intercept[RequestBuildingError](Await.result(req.send[TestError, TestResponse]())) + intercept[RequestBuildingError](Await.result(req.trySend[TestError, TestResponse]())) } "returns successful future when request fails with valid error response" - { @@ -165,7 +165,7 @@ class ErrorHandlingSpec extends FreeSpec with MockFactory with ClientTest { .withContent(content, "test/content") .accept("test/response") - val result = Await.result(req.send[TestError, TestResponse]()) + val result = Await.result(req.trySend[TestError, TestResponse]()) assert(result == Left(testError)) } } @@ -183,7 +183,7 @@ class ErrorHandlingSpec extends FreeSpec with MockFactory with ClientTest { val req = client.post("foo") .withContent(content, "test/content") .accept("test/response") - intercept[InvalidResponse](Await.result(req.send[TestError, TestResponse]())) + intercept[InvalidResponse](Await.result(req.trySend[TestError, TestResponse]())) } "returns failed future when successful response fails to decode" in { @@ -198,7 +198,7 @@ class ErrorHandlingSpec extends FreeSpec with MockFactory with ClientTest { val req = client.post("foo") .withContent(content, "test/content") .accept("test/response") - intercept[InvalidResponse](Await.result(req.send[TestError, TestResponse]())) + intercept[InvalidResponse](Await.result(req.trySend[TestError, TestResponse]())) } "returns failed future when error response fails to decode" in { @@ -213,7 +213,7 @@ class ErrorHandlingSpec extends FreeSpec with MockFactory with ClientTest { val req = client.post("foo") .withContent(content, "test/content") .accept("test/response") - intercept[InvalidResponse](Await.result(req.send[TestError, TestResponse]())) + intercept[InvalidResponse](Await.result(req.trySend[TestError, TestResponse]())) } "returns successful future when everything works" in { @@ -228,7 +228,7 @@ class ErrorHandlingSpec extends FreeSpec with MockFactory with ClientTest { val req = client.post("foo") .withContent(content, "test/content") .accept("test/response") - val result = Await.result(req.send[TestError, TestResponse]()) + val result = Await.result(req.trySend[TestError, TestResponse]()) assert(result == Right(testResponse)) } } @@ -273,4 +273,8 @@ class ErrorHandlingSpec extends FreeSpec with MockFactory with ClientTest { assert(result._2.isInstanceOf[Response]) } } + + "toService" - { + + } } diff --git a/featherbed-core/src/test/scala/featherbed/fixture/package.scala b/featherbed-core/src/test/scala/featherbed/fixture/package.scala index 8b3245f..8bfb4a1 100644 --- a/featherbed-core/src/test/scala/featherbed/fixture/package.scala +++ b/featherbed-core/src/test/scala/featherbed/fixture/package.scala @@ -2,8 +2,8 @@ package featherbed import java.net.URL -import com.twitter.finagle.http.{Request, Response} import com.twitter.finagle.{Filter, Http} +import com.twitter.finagle.http.{Request, Response} import org.scalamock.matchers.Matcher import org.scalamock.scalatest.MockFactory @@ -12,12 +12,12 @@ package object fixture { baseUrl: URL, filter: Filter[Request, Response, Request, Response] ) extends Client(baseUrl) { - override def clientTransform(c: Http.Client) = c.filtered(filter) + override def clientTransform(c: Http.Client): Http.Client = c.filtered(filter) } trait ClientTest { self: MockFactory => class TransportRequestMatcher(f: Request => Unit) extends Matcher[Any] { - override def canEqual(x: Any) = x match { + override def canEqual(x: Any): Boolean = x match { case x: Request => true case _ => false } diff --git a/featherbed-circe/build.sbt b/featherbed-core/src/test/scala/featherbed/fixture/package.scala~HEAD similarity index 100% rename from featherbed-circe/build.sbt rename to featherbed-core/src/test/scala/featherbed/fixture/package.scala~HEAD diff --git a/project/plugins.sbt b/project/plugins.sbt index 9717c2f..2dcd4af 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -5,7 +5,6 @@ resolvers ++= Seq( ) addSbtPlugin("org.tpolecat" % "tut-plugin" % "0.5.2") -addSbtPlugin("org.tpolecat" % "tut-plugin" % "0.5.0") addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "1.1") addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0") addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "0.8.0") From fa5b437f1e1c7ca1a463ca852fcae5796ac40ab7 Mon Sep 17 00:00:00 2001 From: Jeremy Smith Date: Wed, 24 Jan 2018 10:47:48 -0800 Subject: [PATCH 09/10] Rebase on master; set snapshot version --- build.sbt | 13 ++++++++----- .../main/scala/featherbed/request/HTTPRequest.scala | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/build.sbt b/build.sbt index 09525c4..d412918 100644 --- a/build.sbt +++ b/build.sbt @@ -7,15 +7,15 @@ enablePlugins(TutPlugin) lazy val buildSettings = Seq( organization := "io.github.finagle", - version := "0.3.3", - scalaVersion := "2.12.2", - crossScalaVersions := Seq("2.11.11", "2.12.2") + version := "0.4.0-SNAPSHOT", + scalaVersion := "2.12.4", + crossScalaVersions := Seq("2.11.11", "2.12.4") ) val finagleVersion = "17.12.0" val shapelessVersion = "2.3.3" val catsVersion = "1.0.1" -val circeVersion = "0.7.1" +val circeVersion = "0.9.0" lazy val docSettings = Seq( autoAPIMappings := true @@ -29,7 +29,10 @@ lazy val baseSettings = docSettings ++ Seq( "org.scalamock" %% "scalamock-scalatest-support" % "3.6.0" % "test", "org.scalatest" %% "scalatest" % "3.0.3" % "test" ), - resolvers += Resolver.sonatypeRepo("snapshots") + resolvers += Resolver.sonatypeRepo("snapshots"), + scalacOptions ++= Seq( + "-Ypartial-unification" + ) ) lazy val publishSettings = Seq( diff --git a/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala b/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala index f6da5b6..1bd1421 100644 --- a/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala +++ b/featherbed-core/src/main/scala/featherbed/request/HTTPRequest.scala @@ -133,20 +133,20 @@ object HTTPRequest extends RequestSyntax[HTTPRequest] with RequestTypes[HTTPRequ val newContent = req.content match { case MimeContent(Form(existing), _) => MimeContent[MultipartForm, MimeContent.MultipartForm]( MultipartForm( - NonEmptyList(first, rest.toList).map((ToFormParam.file.apply _).tupled).sequenceU map { + NonEmptyList(first, rest.toList).map((ToFormParam.file.apply _).tupled).sequence map { validFiles => validFiles ++ existing.toList } ) ) case MimeContent(MultipartForm(existing), _) => MimeContent[MultipartForm, MimeContent.MultipartForm]( MultipartForm( - NonEmptyList(first, rest.toList).map((ToFormParam.file.apply _).tupled).sequenceU andThen { + NonEmptyList(first, rest.toList).map((ToFormParam.file.apply _).tupled).sequence andThen { validFiles => existing map (validFiles ++ _.toList) } ) ) case _ => MimeContent[MultipartForm, MimeContent.MultipartForm](MultipartForm( - NonEmptyList(first, rest.toList).map((ToFormParam.file.apply _).tupled).sequenceU + NonEmptyList(first, rest.toList).map((ToFormParam.file.apply _).tupled).sequence )) } req.copy[Method.Post, Accept, MultipartForm, MimeContent.MultipartForm]( From f67414300a0eae139f13779e3069faea9a9a45e0 Mon Sep 17 00:00:00 2001 From: Jeremy Smith Date: Wed, 24 Jan 2018 11:32:28 -0800 Subject: [PATCH 10/10] Fix import ordering --- .../src/test/scala/featherbed/circe/CirceSpec.scala | 5 ----- 1 file changed, 5 deletions(-) diff --git a/featherbed-circe/src/test/scala/featherbed/circe/CirceSpec.scala b/featherbed-circe/src/test/scala/featherbed/circe/CirceSpec.scala index d1e088d..7b7e899 100644 --- a/featherbed-circe/src/test/scala/featherbed/circe/CirceSpec.scala +++ b/featherbed-circe/src/test/scala/featherbed/circe/CirceSpec.scala @@ -1,12 +1,7 @@ package featherbed.circe -import cats.implicits._ -import io.circe.generic.auto._ -import io.circe.parser.parse -import io.circe.syntax._ import cats.implicits._ import com.twitter.util.Future -import io.circe._ import io.circe.generic.auto._ import io.circe.parser.parse import io.circe.syntax._