Skip to content

Commit

Permalink
Introduce Connect error codes in the Error type; cache error mappings (
Browse files Browse the repository at this point in the history
  • Loading branch information
igor-vovk authored Dec 6, 2024
1 parent 016cbdd commit 74403b0
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 49 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ docker build . --output "out" --progress=plain
Execution results are output to STDOUT.
Diagnostic data from the server itself is written to the log file `out/out.log`.

### Conformance tests status
### Connect protocol conformance tests status

Current status: __77/79__ tests pass.

Expand All @@ -159,4 +159,5 @@ Known issues:
## Future improvements

- [x] Support GET-requests
- [ ] Support `google.api.http` annotations (GRPC transcoding)
- [ ] Support non-unary (streaming) methods
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import cats.effect.{IO, IOApp, Sync}
import com.comcast.ip4s.{Port, host, port}
import connectrpc.conformance.v1.{ConformanceServiceFs2GrpcTrailers, ServerCompatRequest, ServerCompatResponse}
import org.http4s.ember.server.EmberServerBuilder
import org.http4s.server.middleware.Logger
import org.ivovk.connect_rpc_scala.ConnectRouteBuilder
import org.slf4j.LoggerFactory

import java.io.InputStream
import java.nio.ByteBuffer
Expand All @@ -27,6 +29,8 @@ import java.nio.ByteBuffer
*/
object Main extends IOApp.Simple {

private val logger = LoggerFactory.getLogger(getClass)

override def run: IO[Unit] = {
val res = for
req <- ServerCompatSerDeser.readRequest[IO](System.in).toResource
Expand All @@ -36,7 +40,7 @@ object Main extends IOApp.Simple {
)

app <- ConnectRouteBuilder.forService[IO](service)
.withJsonCodecConfigurator {
.withJsonCodecConfigurator {
// Registering message types in TypeRegistry is required to pass com.google.protobuf.any.Any
// JSON-serialization conformance tests
_
Expand All @@ -45,10 +49,16 @@ object Main extends IOApp.Simple {
}
.build

logger = Logger.httpApp[IO](
logHeaders = false,
logBody = false,
logAction = Some(str => IO(this.logger.trace(str)))
)(app)

server <- EmberServerBuilder.default[IO]
.withHost(host"127.0.0.1")
.withPort(port"0") // random port
.withHttpApp(app)
.withHttpApp(logger)
.build

addr = server.address
Expand Down
23 changes: 22 additions & 1 deletion core/src/main/protobuf/connectrpc/error.proto
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,27 @@ syntax = "proto3";

package connectrpc;

enum Code {
CODE_UNSPECIFIED = 0;
CODE_CANCELED = 1;
CODE_UNKNOWN = 2;
CODE_INVALID_ARGUMENT = 3;
CODE_DEADLINE_EXCEEDED = 4;
CODE_NOT_FOUND = 5;
CODE_ALREADY_EXISTS = 6;
CODE_PERMISSION_DENIED = 7;
CODE_RESOURCE_EXHAUSTED = 8;
CODE_FAILED_PRECONDITION = 9;
CODE_ABORTED = 10;
CODE_OUT_OF_RANGE = 11;
CODE_UNIMPLEMENTED = 12;
CODE_INTERNAL = 13;
CODE_UNAVAILABLE = 14;
CODE_DATA_LOSS = 15;
CODE_UNAUTHENTICATED = 16;
}


// This message is similar to the google.protobuf.Any message.
//
// Separate type was needed to introduce a separate JSON serializer for this message, since Any in error details
Expand All @@ -30,7 +51,7 @@ message ErrorDetailsAny {
message Error {
// The error code.
// For a list of Connect error codes see: https://connectrpc.com/docs/protocol#error-codes
string code = 1;
Code code = 1;
// If this value is absent in a test case response definition, the contents of the
// actual error message will not be checked. This is useful for certain kinds of
// error conditions where the exact message to be used is not specified, only the
Expand Down
103 changes: 62 additions & 41 deletions core/src/main/scala/org/ivovk/connect_rpc_scala/Mappings.scala
Original file line number Diff line number Diff line change
Expand Up @@ -52,53 +52,74 @@ trait HeaderMappings {

trait StatusCodeMappings {

private val httpStatusCodesByGrpcStatusCode: Array[org.http4s.Status] = {
val maxCode = io.grpc.Status.Code.values().map(_.value()).max
val codes = new Array[org.http4s.Status](maxCode + 1)

io.grpc.Status.Code.values().foreach { code =>
codes(code.value()) = code match {
case io.grpc.Status.Code.CANCELLED =>
org.http4s.Status.fromInt(499).getOrElse(sys.error("Should not happen"))
case io.grpc.Status.Code.UNKNOWN => org.http4s.Status.InternalServerError
case io.grpc.Status.Code.INVALID_ARGUMENT => org.http4s.Status.BadRequest
case io.grpc.Status.Code.DEADLINE_EXCEEDED => org.http4s.Status.GatewayTimeout
case io.grpc.Status.Code.NOT_FOUND => org.http4s.Status.NotFound
case io.grpc.Status.Code.ALREADY_EXISTS => org.http4s.Status.Conflict
case io.grpc.Status.Code.PERMISSION_DENIED => org.http4s.Status.Forbidden
case io.grpc.Status.Code.RESOURCE_EXHAUSTED => org.http4s.Status.TooManyRequests
case io.grpc.Status.Code.FAILED_PRECONDITION => org.http4s.Status.BadRequest
case io.grpc.Status.Code.ABORTED => org.http4s.Status.Conflict
case io.grpc.Status.Code.OUT_OF_RANGE => org.http4s.Status.BadRequest
case io.grpc.Status.Code.UNIMPLEMENTED => org.http4s.Status.NotImplemented
case io.grpc.Status.Code.INTERNAL => org.http4s.Status.InternalServerError
case io.grpc.Status.Code.UNAVAILABLE => org.http4s.Status.ServiceUnavailable
case io.grpc.Status.Code.DATA_LOSS => org.http4s.Status.InternalServerError
case io.grpc.Status.Code.UNAUTHENTICATED => org.http4s.Status.Unauthorized
case _ => org.http4s.Status.InternalServerError
}
}

codes
}

private val connectErrorCodeByGrpcStatusCode: Array[connectrpc.Code] = {
val maxCode = io.grpc.Status.Code.values().map(_.value()).max
val codes = new Array[connectrpc.Code](maxCode + 1)

io.grpc.Status.Code.values().foreach { code =>
codes(code.value()) = code match {
case io.grpc.Status.Code.CANCELLED => connectrpc.Code.Canceled
case io.grpc.Status.Code.UNKNOWN => connectrpc.Code.Unknown
case io.grpc.Status.Code.INVALID_ARGUMENT => connectrpc.Code.InvalidArgument
case io.grpc.Status.Code.DEADLINE_EXCEEDED => connectrpc.Code.DeadlineExceeded
case io.grpc.Status.Code.NOT_FOUND => connectrpc.Code.NotFound
case io.grpc.Status.Code.ALREADY_EXISTS => connectrpc.Code.AlreadyExists
case io.grpc.Status.Code.PERMISSION_DENIED => connectrpc.Code.PermissionDenied
case io.grpc.Status.Code.RESOURCE_EXHAUSTED => connectrpc.Code.ResourceExhausted
case io.grpc.Status.Code.FAILED_PRECONDITION => connectrpc.Code.FailedPrecondition
case io.grpc.Status.Code.ABORTED => connectrpc.Code.Aborted
case io.grpc.Status.Code.OUT_OF_RANGE => connectrpc.Code.OutOfRange
case io.grpc.Status.Code.UNIMPLEMENTED => connectrpc.Code.Unimplemented
case io.grpc.Status.Code.INTERNAL => connectrpc.Code.Internal
case io.grpc.Status.Code.UNAVAILABLE => connectrpc.Code.Unavailable
case io.grpc.Status.Code.DATA_LOSS => connectrpc.Code.DataLoss
case io.grpc.Status.Code.UNAUTHENTICATED => connectrpc.Code.Unauthenticated
case _ => connectrpc.Code.Internal
}
}

codes
}

extension (status: io.grpc.Status) {
def toHttpStatus: org.http4s.Status = status.getCode.toHttpStatus
def toConnectCode: String = status.getCode.toConnectCode
def toConnectCode: connectrpc.Code = status.getCode.toConnectCode
}

// Url: https://connectrpc.com/docs/protocol/#error-codes
extension (code: io.grpc.Status.Code) {
def toHttpStatus: org.http4s.Status = code match {
case io.grpc.Status.Code.CANCELLED =>
org.http4s.Status.fromInt(499).getOrElse(org.http4s.Status.InternalServerError)
case io.grpc.Status.Code.UNKNOWN => org.http4s.Status.InternalServerError
case io.grpc.Status.Code.INVALID_ARGUMENT => org.http4s.Status.BadRequest
case io.grpc.Status.Code.DEADLINE_EXCEEDED => org.http4s.Status.GatewayTimeout
case io.grpc.Status.Code.NOT_FOUND => org.http4s.Status.NotFound
case io.grpc.Status.Code.ALREADY_EXISTS => org.http4s.Status.Conflict
case io.grpc.Status.Code.PERMISSION_DENIED => org.http4s.Status.Forbidden
case io.grpc.Status.Code.RESOURCE_EXHAUSTED => org.http4s.Status.TooManyRequests
case io.grpc.Status.Code.FAILED_PRECONDITION => org.http4s.Status.BadRequest
case io.grpc.Status.Code.ABORTED => org.http4s.Status.Conflict
case io.grpc.Status.Code.OUT_OF_RANGE => org.http4s.Status.BadRequest
case io.grpc.Status.Code.UNIMPLEMENTED => org.http4s.Status.NotImplemented
case io.grpc.Status.Code.INTERNAL => org.http4s.Status.InternalServerError
case io.grpc.Status.Code.UNAVAILABLE => org.http4s.Status.ServiceUnavailable
case io.grpc.Status.Code.DATA_LOSS => org.http4s.Status.InternalServerError
case io.grpc.Status.Code.UNAUTHENTICATED => org.http4s.Status.Unauthorized
case _ => org.http4s.Status.InternalServerError
}

def toConnectCode: String = code match {
case io.grpc.Status.Code.CANCELLED => "canceled"
case io.grpc.Status.Code.UNKNOWN => "unknown"
case io.grpc.Status.Code.INVALID_ARGUMENT => "invalid_argument"
case io.grpc.Status.Code.DEADLINE_EXCEEDED => "deadline_exceeded"
case io.grpc.Status.Code.NOT_FOUND => "not_found"
case io.grpc.Status.Code.ALREADY_EXISTS => "already_exists"
case io.grpc.Status.Code.PERMISSION_DENIED => "permission_denied"
case io.grpc.Status.Code.RESOURCE_EXHAUSTED => "resource_exhausted"
case io.grpc.Status.Code.FAILED_PRECONDITION => "failed_precondition"
case io.grpc.Status.Code.ABORTED => "aborted"
case io.grpc.Status.Code.OUT_OF_RANGE => "out_of_range"
case io.grpc.Status.Code.UNIMPLEMENTED => "unimplemented"
case io.grpc.Status.Code.INTERNAL => "internal"
case io.grpc.Status.Code.UNAVAILABLE => "unavailable"
case io.grpc.Status.Code.DATA_LOSS => "data_loss"
case io.grpc.Status.Code.UNAUTHENTICATED => "unauthenticated"
case _ => "internal"
}
def toHttpStatus: org.http4s.Status = httpStatusCodesByGrpcStatusCode(code.value())
def toConnectCode: connectrpc.Code = connectErrorCodeByGrpcStatusCode(code.value())
}

}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package org.ivovk.connect_rpc_scala.http.codec

import cats.effect.Sync
import org.ivovk.connect_rpc_scala.http.json.ErrorDetailsAnyFormat
import org.ivovk.connect_rpc_scala.http.json.{ConnectErrorFormat, ErrorDetailsAnyFormat}
import scalapb.json4s.{FormatRegistry, JsonFormat, TypeRegistry}
import scalapb.{GeneratedMessage, GeneratedMessageCompanion, json4s}

Expand Down Expand Up @@ -29,7 +29,11 @@ case class JsonMessageCodecBuilder[F[_] : Sync] private(
val formatRegistry = this.formatRegistry
.registerMessageFormatter[connectrpc.ErrorDetailsAny](
ErrorDetailsAnyFormat.writer,
ErrorDetailsAnyFormat.printer
ErrorDetailsAnyFormat.parser
)
.registerMessageFormatter[connectrpc.Error](
ConnectErrorFormat.writer,
ConnectErrorFormat.parser
)

val parser = new json4s.Parser()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package org.ivovk.connect_rpc_scala.http.json

import connectrpc.{Error, ErrorDetailsAny}
import org.json4s.JsonAST.{JArray, JString, JValue}
import org.json4s.MonadicJValue.*
import org.json4s.{JNothing, JObject}
import scalapb.json4s.{Parser, Printer}

object ConnectErrorFormat {

private val stringErrorCodes: Array[JString] = {
val maxCode = connectrpc.Code.values.map(_.value).max
val codes = new Array[JString](maxCode + 1)

connectrpc.Code.values.foreach { code =>
codes(code.value) = JString(code.name.substring("CODE_".length).toLowerCase)
}

codes
}

val writer: (Printer, Error) => JValue = { (printer, error) =>
JObject(List.concat(
Some("code" -> stringErrorCodes(error.code.value)),
error.message.map("message" -> JString(_)),
Option(error.details).filterNot(_.isEmpty).map(d => "details" -> JArray(d.map(printer.toJson).toList)),
))
}

val parser: (Parser, JValue) => Error = {
case (parser, obj@JObject(fields)) =>
val code = obj \ "code" match
case JString(code) =>
connectrpc.Code.fromName(s"CODE_${code.toUpperCase}")
.getOrElse(throw new IllegalArgumentException(s"Unknown error code: $code"))
case _ => throw new IllegalArgumentException(s"Error parsing Error: $obj")

val message = obj \ "message" match
case JString(message) => Some(message)
case JNothing => None
case _ => throw new IllegalArgumentException(s"Error parsing Error: $obj")

val details = obj \ "details" match
case JArray(details) => details.map(parser.fromJson[ErrorDetailsAny])
case JNothing => Seq.empty
case _ => throw new IllegalArgumentException(s"Error parsing Error: $obj")

Error(
code = code,
message = message,
details = details,
)
case (_, other) =>
throw new IllegalArgumentException(s"Expected an object, got $other")
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ object ErrorDetailsAnyFormat {
)
}

val printer: (Parser, JValue) => ErrorDetailsAny = {
val parser: (Parser, JValue) => ErrorDetailsAny = {
case (parser, obj@JObject(fields)) =>
(obj \ "type", obj \ "value") match {
case (JString(t), JString(v)) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class JsonSerializationTest extends AnyFunSuite {
val formatRegistry = json4s.JsonFormat.DefaultRegistry
.registerMessageFormatter[connectrpc.ErrorDetailsAny](
ErrorDetailsAnyFormat.writer,
ErrorDetailsAnyFormat.printer
ErrorDetailsAnyFormat.parser
)

val parser = new json4s.Parser().withFormatRegistry(formatRegistry)
Expand All @@ -22,4 +22,21 @@ class JsonSerializationTest extends AnyFunSuite {

assert(parsed == any)
}

test("Error serialization") {
val formatRegistry = json4s.JsonFormat.DefaultRegistry
.registerMessageFormatter[connectrpc.Error](
ConnectErrorFormat.writer,
ConnectErrorFormat.parser
)

val parser = new json4s.Parser().withFormatRegistry(formatRegistry)
val printer = new json4s.Printer().withFormatRegistry(formatRegistry)

val error = connectrpc.Error(connectrpc.Code.FailedPrecondition, Some("message"), Seq.empty)
val json = printer.print(error)
val parsed = parser.fromJsonString[connectrpc.Error](json)

assert(parsed == error)
}
}

0 comments on commit 74403b0

Please sign in to comment.