Skip to content

Commit 74403b0

Browse files
authored
Introduce Connect error codes in the Error type; cache error mappings (#44)
1 parent 016cbdd commit 74403b0

File tree

8 files changed

+180
-49
lines changed

8 files changed

+180
-49
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ docker build . --output "out" --progress=plain
148148
Execution results are output to STDOUT.
149149
Diagnostic data from the server itself is written to the log file `out/out.log`.
150150

151-
### Conformance tests status
151+
### Connect protocol conformance tests status
152152

153153
Current status: __77/79__ tests pass.
154154

@@ -159,4 +159,5 @@ Known issues:
159159
## Future improvements
160160

161161
- [x] Support GET-requests
162+
- [ ] Support `google.api.http` annotations (GRPC transcoding)
162163
- [ ] Support non-unary (streaming) methods

conformance/src/main/scala/org/ivovk/connect_rpc_scala/conformance/Main.scala

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import cats.effect.{IO, IOApp, Sync}
44
import com.comcast.ip4s.{Port, host, port}
55
import connectrpc.conformance.v1.{ConformanceServiceFs2GrpcTrailers, ServerCompatRequest, ServerCompatResponse}
66
import org.http4s.ember.server.EmberServerBuilder
7+
import org.http4s.server.middleware.Logger
78
import org.ivovk.connect_rpc_scala.ConnectRouteBuilder
9+
import org.slf4j.LoggerFactory
810

911
import java.io.InputStream
1012
import java.nio.ByteBuffer
@@ -27,6 +29,8 @@ import java.nio.ByteBuffer
2729
*/
2830
object Main extends IOApp.Simple {
2931

32+
private val logger = LoggerFactory.getLogger(getClass)
33+
3034
override def run: IO[Unit] = {
3135
val res = for
3236
req <- ServerCompatSerDeser.readRequest[IO](System.in).toResource
@@ -36,7 +40,7 @@ object Main extends IOApp.Simple {
3640
)
3741

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

52+
logger = Logger.httpApp[IO](
53+
logHeaders = false,
54+
logBody = false,
55+
logAction = Some(str => IO(this.logger.trace(str)))
56+
)(app)
57+
4858
server <- EmberServerBuilder.default[IO]
4959
.withHost(host"127.0.0.1")
5060
.withPort(port"0") // random port
51-
.withHttpApp(app)
61+
.withHttpApp(logger)
5262
.build
5363

5464
addr = server.address

core/src/main/protobuf/connectrpc/error.proto

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,27 @@ syntax = "proto3";
1616

1717
package connectrpc;
1818

19+
enum Code {
20+
CODE_UNSPECIFIED = 0;
21+
CODE_CANCELED = 1;
22+
CODE_UNKNOWN = 2;
23+
CODE_INVALID_ARGUMENT = 3;
24+
CODE_DEADLINE_EXCEEDED = 4;
25+
CODE_NOT_FOUND = 5;
26+
CODE_ALREADY_EXISTS = 6;
27+
CODE_PERMISSION_DENIED = 7;
28+
CODE_RESOURCE_EXHAUSTED = 8;
29+
CODE_FAILED_PRECONDITION = 9;
30+
CODE_ABORTED = 10;
31+
CODE_OUT_OF_RANGE = 11;
32+
CODE_UNIMPLEMENTED = 12;
33+
CODE_INTERNAL = 13;
34+
CODE_UNAVAILABLE = 14;
35+
CODE_DATA_LOSS = 15;
36+
CODE_UNAUTHENTICATED = 16;
37+
}
38+
39+
1940
// This message is similar to the google.protobuf.Any message.
2041
//
2142
// Separate type was needed to introduce a separate JSON serializer for this message, since Any in error details
@@ -30,7 +51,7 @@ message ErrorDetailsAny {
3051
message Error {
3152
// The error code.
3253
// For a list of Connect error codes see: https://connectrpc.com/docs/protocol#error-codes
33-
string code = 1;
54+
Code code = 1;
3455
// If this value is absent in a test case response definition, the contents of the
3556
// actual error message will not be checked. This is useful for certain kinds of
3657
// error conditions where the exact message to be used is not specified, only the

core/src/main/scala/org/ivovk/connect_rpc_scala/Mappings.scala

Lines changed: 62 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -52,53 +52,74 @@ trait HeaderMappings {
5252

5353
trait StatusCodeMappings {
5454

55+
private val httpStatusCodesByGrpcStatusCode: Array[org.http4s.Status] = {
56+
val maxCode = io.grpc.Status.Code.values().map(_.value()).max
57+
val codes = new Array[org.http4s.Status](maxCode + 1)
58+
59+
io.grpc.Status.Code.values().foreach { code =>
60+
codes(code.value()) = code match {
61+
case io.grpc.Status.Code.CANCELLED =>
62+
org.http4s.Status.fromInt(499).getOrElse(sys.error("Should not happen"))
63+
case io.grpc.Status.Code.UNKNOWN => org.http4s.Status.InternalServerError
64+
case io.grpc.Status.Code.INVALID_ARGUMENT => org.http4s.Status.BadRequest
65+
case io.grpc.Status.Code.DEADLINE_EXCEEDED => org.http4s.Status.GatewayTimeout
66+
case io.grpc.Status.Code.NOT_FOUND => org.http4s.Status.NotFound
67+
case io.grpc.Status.Code.ALREADY_EXISTS => org.http4s.Status.Conflict
68+
case io.grpc.Status.Code.PERMISSION_DENIED => org.http4s.Status.Forbidden
69+
case io.grpc.Status.Code.RESOURCE_EXHAUSTED => org.http4s.Status.TooManyRequests
70+
case io.grpc.Status.Code.FAILED_PRECONDITION => org.http4s.Status.BadRequest
71+
case io.grpc.Status.Code.ABORTED => org.http4s.Status.Conflict
72+
case io.grpc.Status.Code.OUT_OF_RANGE => org.http4s.Status.BadRequest
73+
case io.grpc.Status.Code.UNIMPLEMENTED => org.http4s.Status.NotImplemented
74+
case io.grpc.Status.Code.INTERNAL => org.http4s.Status.InternalServerError
75+
case io.grpc.Status.Code.UNAVAILABLE => org.http4s.Status.ServiceUnavailable
76+
case io.grpc.Status.Code.DATA_LOSS => org.http4s.Status.InternalServerError
77+
case io.grpc.Status.Code.UNAUTHENTICATED => org.http4s.Status.Unauthorized
78+
case _ => org.http4s.Status.InternalServerError
79+
}
80+
}
81+
82+
codes
83+
}
84+
85+
private val connectErrorCodeByGrpcStatusCode: Array[connectrpc.Code] = {
86+
val maxCode = io.grpc.Status.Code.values().map(_.value()).max
87+
val codes = new Array[connectrpc.Code](maxCode + 1)
88+
89+
io.grpc.Status.Code.values().foreach { code =>
90+
codes(code.value()) = code match {
91+
case io.grpc.Status.Code.CANCELLED => connectrpc.Code.Canceled
92+
case io.grpc.Status.Code.UNKNOWN => connectrpc.Code.Unknown
93+
case io.grpc.Status.Code.INVALID_ARGUMENT => connectrpc.Code.InvalidArgument
94+
case io.grpc.Status.Code.DEADLINE_EXCEEDED => connectrpc.Code.DeadlineExceeded
95+
case io.grpc.Status.Code.NOT_FOUND => connectrpc.Code.NotFound
96+
case io.grpc.Status.Code.ALREADY_EXISTS => connectrpc.Code.AlreadyExists
97+
case io.grpc.Status.Code.PERMISSION_DENIED => connectrpc.Code.PermissionDenied
98+
case io.grpc.Status.Code.RESOURCE_EXHAUSTED => connectrpc.Code.ResourceExhausted
99+
case io.grpc.Status.Code.FAILED_PRECONDITION => connectrpc.Code.FailedPrecondition
100+
case io.grpc.Status.Code.ABORTED => connectrpc.Code.Aborted
101+
case io.grpc.Status.Code.OUT_OF_RANGE => connectrpc.Code.OutOfRange
102+
case io.grpc.Status.Code.UNIMPLEMENTED => connectrpc.Code.Unimplemented
103+
case io.grpc.Status.Code.INTERNAL => connectrpc.Code.Internal
104+
case io.grpc.Status.Code.UNAVAILABLE => connectrpc.Code.Unavailable
105+
case io.grpc.Status.Code.DATA_LOSS => connectrpc.Code.DataLoss
106+
case io.grpc.Status.Code.UNAUTHENTICATED => connectrpc.Code.Unauthenticated
107+
case _ => connectrpc.Code.Internal
108+
}
109+
}
110+
111+
codes
112+
}
113+
55114
extension (status: io.grpc.Status) {
56115
def toHttpStatus: org.http4s.Status = status.getCode.toHttpStatus
57-
def toConnectCode: String = status.getCode.toConnectCode
116+
def toConnectCode: connectrpc.Code = status.getCode.toConnectCode
58117
}
59118

60119
// Url: https://connectrpc.com/docs/protocol/#error-codes
61120
extension (code: io.grpc.Status.Code) {
62-
def toHttpStatus: org.http4s.Status = code match {
63-
case io.grpc.Status.Code.CANCELLED =>
64-
org.http4s.Status.fromInt(499).getOrElse(org.http4s.Status.InternalServerError)
65-
case io.grpc.Status.Code.UNKNOWN => org.http4s.Status.InternalServerError
66-
case io.grpc.Status.Code.INVALID_ARGUMENT => org.http4s.Status.BadRequest
67-
case io.grpc.Status.Code.DEADLINE_EXCEEDED => org.http4s.Status.GatewayTimeout
68-
case io.grpc.Status.Code.NOT_FOUND => org.http4s.Status.NotFound
69-
case io.grpc.Status.Code.ALREADY_EXISTS => org.http4s.Status.Conflict
70-
case io.grpc.Status.Code.PERMISSION_DENIED => org.http4s.Status.Forbidden
71-
case io.grpc.Status.Code.RESOURCE_EXHAUSTED => org.http4s.Status.TooManyRequests
72-
case io.grpc.Status.Code.FAILED_PRECONDITION => org.http4s.Status.BadRequest
73-
case io.grpc.Status.Code.ABORTED => org.http4s.Status.Conflict
74-
case io.grpc.Status.Code.OUT_OF_RANGE => org.http4s.Status.BadRequest
75-
case io.grpc.Status.Code.UNIMPLEMENTED => org.http4s.Status.NotImplemented
76-
case io.grpc.Status.Code.INTERNAL => org.http4s.Status.InternalServerError
77-
case io.grpc.Status.Code.UNAVAILABLE => org.http4s.Status.ServiceUnavailable
78-
case io.grpc.Status.Code.DATA_LOSS => org.http4s.Status.InternalServerError
79-
case io.grpc.Status.Code.UNAUTHENTICATED => org.http4s.Status.Unauthorized
80-
case _ => org.http4s.Status.InternalServerError
81-
}
82-
83-
def toConnectCode: String = code match {
84-
case io.grpc.Status.Code.CANCELLED => "canceled"
85-
case io.grpc.Status.Code.UNKNOWN => "unknown"
86-
case io.grpc.Status.Code.INVALID_ARGUMENT => "invalid_argument"
87-
case io.grpc.Status.Code.DEADLINE_EXCEEDED => "deadline_exceeded"
88-
case io.grpc.Status.Code.NOT_FOUND => "not_found"
89-
case io.grpc.Status.Code.ALREADY_EXISTS => "already_exists"
90-
case io.grpc.Status.Code.PERMISSION_DENIED => "permission_denied"
91-
case io.grpc.Status.Code.RESOURCE_EXHAUSTED => "resource_exhausted"
92-
case io.grpc.Status.Code.FAILED_PRECONDITION => "failed_precondition"
93-
case io.grpc.Status.Code.ABORTED => "aborted"
94-
case io.grpc.Status.Code.OUT_OF_RANGE => "out_of_range"
95-
case io.grpc.Status.Code.UNIMPLEMENTED => "unimplemented"
96-
case io.grpc.Status.Code.INTERNAL => "internal"
97-
case io.grpc.Status.Code.UNAVAILABLE => "unavailable"
98-
case io.grpc.Status.Code.DATA_LOSS => "data_loss"
99-
case io.grpc.Status.Code.UNAUTHENTICATED => "unauthenticated"
100-
case _ => "internal"
101-
}
121+
def toHttpStatus: org.http4s.Status = httpStatusCodesByGrpcStatusCode(code.value())
122+
def toConnectCode: connectrpc.Code = connectErrorCodeByGrpcStatusCode(code.value())
102123
}
103124

104125
}

core/src/main/scala/org/ivovk/connect_rpc_scala/http/codec/JsonMessageCodecBuilder.scala

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package org.ivovk.connect_rpc_scala.http.codec
22

33
import cats.effect.Sync
4-
import org.ivovk.connect_rpc_scala.http.json.ErrorDetailsAnyFormat
4+
import org.ivovk.connect_rpc_scala.http.json.{ConnectErrorFormat, ErrorDetailsAnyFormat}
55
import scalapb.json4s.{FormatRegistry, JsonFormat, TypeRegistry}
66
import scalapb.{GeneratedMessage, GeneratedMessageCompanion, json4s}
77

@@ -29,7 +29,11 @@ case class JsonMessageCodecBuilder[F[_] : Sync] private(
2929
val formatRegistry = this.formatRegistry
3030
.registerMessageFormatter[connectrpc.ErrorDetailsAny](
3131
ErrorDetailsAnyFormat.writer,
32-
ErrorDetailsAnyFormat.printer
32+
ErrorDetailsAnyFormat.parser
33+
)
34+
.registerMessageFormatter[connectrpc.Error](
35+
ConnectErrorFormat.writer,
36+
ConnectErrorFormat.parser
3337
)
3438

3539
val parser = new json4s.Parser()
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package org.ivovk.connect_rpc_scala.http.json
2+
3+
import connectrpc.{Error, ErrorDetailsAny}
4+
import org.json4s.JsonAST.{JArray, JString, JValue}
5+
import org.json4s.MonadicJValue.*
6+
import org.json4s.{JNothing, JObject}
7+
import scalapb.json4s.{Parser, Printer}
8+
9+
object ConnectErrorFormat {
10+
11+
private val stringErrorCodes: Array[JString] = {
12+
val maxCode = connectrpc.Code.values.map(_.value).max
13+
val codes = new Array[JString](maxCode + 1)
14+
15+
connectrpc.Code.values.foreach { code =>
16+
codes(code.value) = JString(code.name.substring("CODE_".length).toLowerCase)
17+
}
18+
19+
codes
20+
}
21+
22+
val writer: (Printer, Error) => JValue = { (printer, error) =>
23+
JObject(List.concat(
24+
Some("code" -> stringErrorCodes(error.code.value)),
25+
error.message.map("message" -> JString(_)),
26+
Option(error.details).filterNot(_.isEmpty).map(d => "details" -> JArray(d.map(printer.toJson).toList)),
27+
))
28+
}
29+
30+
val parser: (Parser, JValue) => Error = {
31+
case (parser, obj@JObject(fields)) =>
32+
val code = obj \ "code" match
33+
case JString(code) =>
34+
connectrpc.Code.fromName(s"CODE_${code.toUpperCase}")
35+
.getOrElse(throw new IllegalArgumentException(s"Unknown error code: $code"))
36+
case _ => throw new IllegalArgumentException(s"Error parsing Error: $obj")
37+
38+
val message = obj \ "message" match
39+
case JString(message) => Some(message)
40+
case JNothing => None
41+
case _ => throw new IllegalArgumentException(s"Error parsing Error: $obj")
42+
43+
val details = obj \ "details" match
44+
case JArray(details) => details.map(parser.fromJson[ErrorDetailsAny])
45+
case JNothing => Seq.empty
46+
case _ => throw new IllegalArgumentException(s"Error parsing Error: $obj")
47+
48+
Error(
49+
code = code,
50+
message = message,
51+
details = details,
52+
)
53+
case (_, other) =>
54+
throw new IllegalArgumentException(s"Expected an object, got $other")
55+
}
56+
57+
}

core/src/main/scala/org/ivovk/connect_rpc_scala/http/json/ErrorDetailsAnyFormat.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ object ErrorDetailsAnyFormat {
2222
)
2323
}
2424

25-
val printer: (Parser, JValue) => ErrorDetailsAny = {
25+
val parser: (Parser, JValue) => ErrorDetailsAny = {
2626
case (parser, obj@JObject(fields)) =>
2727
(obj \ "type", obj \ "value") match {
2828
case (JString(t), JString(v)) =>

core/src/test/scala/org/ivovk/connect_rpc_scala/http/json/JsonSerializationTest.scala

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class JsonSerializationTest extends AnyFunSuite {
1010
val formatRegistry = json4s.JsonFormat.DefaultRegistry
1111
.registerMessageFormatter[connectrpc.ErrorDetailsAny](
1212
ErrorDetailsAnyFormat.writer,
13-
ErrorDetailsAnyFormat.printer
13+
ErrorDetailsAnyFormat.parser
1414
)
1515

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

2323
assert(parsed == any)
2424
}
25+
26+
test("Error serialization") {
27+
val formatRegistry = json4s.JsonFormat.DefaultRegistry
28+
.registerMessageFormatter[connectrpc.Error](
29+
ConnectErrorFormat.writer,
30+
ConnectErrorFormat.parser
31+
)
32+
33+
val parser = new json4s.Parser().withFormatRegistry(formatRegistry)
34+
val printer = new json4s.Printer().withFormatRegistry(formatRegistry)
35+
36+
val error = connectrpc.Error(connectrpc.Code.FailedPrecondition, Some("message"), Seq.empty)
37+
val json = printer.print(error)
38+
val parsed = parser.fromJsonString[connectrpc.Error](json)
39+
40+
assert(parsed == error)
41+
}
2542
}

0 commit comments

Comments
 (0)