-
Notifications
You must be signed in to change notification settings - Fork 34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Sample http4s REST client/server with client macro derivation #552
Changes from 35 commits
ed9cf04
93f8ace
b03637c
2a1cc42
a74604e
6424dab
8caee87
65d705d
98df299
a216c63
051519d
8eb36b4
c172539
20aae64
15abe94
5d5b726
683147c
54990d8
fe34e47
1132fc2
97d1c73
6db7ab3
2a9cd3d
06f2b83
3888be7
965fdd4
d6ce882
3133ec5
34c0504
795eb12
c922e0f
9b0a8f3
fb91483
854b0d6
f6eab68
ad90c2d
f1f60ff
94dba66
bf6e853
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
/* | ||
* Copyright 2017-2019 47 Degrees, LLC. <http://www.47deg.com> | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package higherkindness.mu.http | ||
|
||
import cats.ApplicativeError | ||
import cats.effect._ | ||
import cats.implicits._ | ||
import fs2.{RaiseThrowable, Stream} | ||
import io.grpc.Status.Code._ | ||
import org.typelevel.jawn.ParseException | ||
import io.circe._ | ||
import io.circe.generic.auto._ | ||
import io.circe.jawn.CirceSupportParser.facade | ||
import io.circe.syntax._ | ||
import io.grpc.{Status => _, _} | ||
import jawnfs2._ | ||
import org.http4s._ | ||
import org.http4s.dsl.Http4sDsl | ||
import org.http4s.Status.Ok | ||
import scala.util.control.NoStackTrace | ||
|
||
object implicits { | ||
|
||
implicit class MessageOps[F[_]](private val message: Message[F]) extends AnyVal { | ||
|
||
def jsonBodyAsStream[A]( | ||
implicit decoder: Decoder[A], | ||
F: ApplicativeError[F, Throwable]): Stream[F, A] = | ||
message.body.chunks.parseJsonStream.map(_.as[A]).rethrow | ||
} | ||
|
||
implicit class RequestOps[F[_]](private val request: Request[F]) { | ||
|
||
def asStream[A](implicit decoder: Decoder[A], F: ApplicativeError[F, Throwable]): Stream[F, A] = | ||
request | ||
.jsonBodyAsStream[A] | ||
.adaptError { // mimic behavior of MessageOps.as[T] in handling of parsing errors | ||
case ex: ParseException => | ||
MalformedMessageBodyFailure(ex.getMessage, Some(ex)) // will return 400 instead of 500 | ||
} | ||
} | ||
|
||
implicit class ResponseOps[F[_]](private val response: Response[F]) { | ||
|
||
implicit private val throwableDecoder: Decoder[Throwable] = | ||
Decoder.decodeTuple2[String, String].map { | ||
case (cls, msg) => | ||
Class | ||
.forName(cls) | ||
.getConstructor(classOf[String]) | ||
.newInstance(msg) | ||
.asInstanceOf[Throwable] | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since we're losing the stacktrace here, I'd use our custom final case class UnexpectedError(status: Status, msg: Option[String] = None)
extends RuntimeException(status + msg.fold("")(": " + _))
with NoStackTrace
//...
implicit val unexpectedErrorDecoder: Decoder[UnexpectedError] = deriveDecoder There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMO we need to avoid custom logic in the encoders/decoders |
||
|
||
def asStream[A]( | ||
implicit decoder: Decoder[A], | ||
F: ApplicativeError[F, Throwable], | ||
R: RaiseThrowable[F]): Stream[F, A] = | ||
if (response.status.code != Ok.code) Stream.raiseError(ResponseError(response.status)) | ||
else response.jsonBodyAsStream[Either[Throwable, A]].rethrow | ||
} | ||
|
||
implicit class Fs2StreamOps[F[_], A](private val stream: Stream[F, A]) { | ||
|
||
implicit val throwableEncoder: Encoder[Throwable] = new Encoder[Throwable] { | ||
def apply(ex: Throwable): Json = (ex.getClass.getName, ex.getMessage).asJson | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Based on the above: implicit val unexpectedErrorEncoder: Encoder[UnexpectedError] = deriveEncoder |
||
|
||
def asJsonEither(implicit encoder: Encoder[A]): Stream[F, Json] = stream.attempt.map(_.asJson) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. def asJsonEither(implicit encoder: Encoder[A]): Stream[F, Json] =
stream.attempt.bimap(e => UnexpectedError(e.getClass.getName, Option(e.getMessage)), _.asJson) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm sorry @fedefernandez, I don't really understand how you expect to instantiate the Maybe you're proposing case class UnexpectedError(status: String, msg: Option[String] = None) instead of case class UnexpectedError(status: Status, msg: Option[String] = None) |
||
} | ||
|
||
implicit class FResponseOps[F[_]: Sync](private val response: F[Response[F]]) | ||
extends Http4sDsl[F] { | ||
|
||
def adaptErrors: F[Response[F]] = response.handleErrorWith { | ||
case se: StatusException => errorFromStatus(se.getStatus, se.getMessage) | ||
case sre: StatusRuntimeException => errorFromStatus(sre.getStatus, sre.getMessage) | ||
case other: Throwable => InternalServerError(other.getMessage) | ||
} | ||
|
||
private def errorFromStatus(status: io.grpc.Status, message: String): F[Response[F]] = | ||
status.getCode match { | ||
case INVALID_ARGUMENT => BadRequest(message) | ||
case UNAUTHENTICATED => Forbidden(message) | ||
case PERMISSION_DENIED => Forbidden(message) | ||
case NOT_FOUND => NotFound(message) | ||
case UNAVAILABLE => ServiceUnavailable(message) | ||
case _ => InternalServerError(message) | ||
} | ||
} | ||
|
||
def handleResponseError[F[_]: Sync](errorResponse: Response[F]): F[Throwable] = | ||
errorResponse.bodyAsText.compile.foldMonoid.map(body => | ||
ResponseError(errorResponse.status, Some(body).filter(_.nonEmpty))) | ||
} | ||
|
||
final case class ResponseError(status: Status, msg: Option[String] = None) | ||
extends RuntimeException(status + msg.fold("")(": " + _)) | ||
with NoStackTrace |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
/* | ||
* Copyright 2017-2019 47 Degrees, LLC. <http://www.47deg.com> | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package higherkindness.mu.http | ||
|
||
import cats.effect.{ConcurrentEffect, Timer} | ||
import org.http4s.HttpRoutes | ||
import org.http4s.server.blaze.BlazeServerBuilder | ||
import org.http4s.implicits._ | ||
import org.http4s.server.Router | ||
|
||
case class RouteMap[F[_]](prefix: String, route: HttpRoutes[F]) | ||
|
||
object HttpServer { | ||
|
||
def bind[F[_]: ConcurrentEffect: Timer]( | ||
port: Int, | ||
host: String, | ||
routes: RouteMap[F]*): BlazeServerBuilder[F] = | ||
BlazeServerBuilder[F] | ||
.bindHttp(port, host) | ||
.withHttpApp(Router(routes.map(r => (s"/${r.prefix}", r.route)): _*).orNotFound) | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
<configuration debug="true"> | ||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> | ||
<encoder> | ||
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> | ||
</encoder> | ||
</appender> | ||
|
||
<root level="debug"> | ||
<appender-ref ref="STDOUT" /> | ||
</root> | ||
</configuration> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please, help me to understand this. This is only used for streams and observable, right? The server generates an Either that is parsed in the client. Am I right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
implicits.scala
was enterally created by @L-Lavigne, so I'll let him answer all your questions.