Skip to content

Commit c3546cc

Browse files
feat: Add jsonPayload trait (#89)
* feat: Add jsonPayload trait * Remove useless comments * Add tests for JsonPayloadValidator * Transform errors as well * Use traitValidator --------- Co-authored-by: ghostbuster91 <[email protected]>
1 parent a76d283 commit c3546cc

File tree

11 files changed

+240
-20
lines changed

11 files changed

+240
-20
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package jsonrpclib
2+
3+
import jsonrpclib.ModelUtils.assembleModel
4+
import jsonrpclib.ModelUtils.eventsWithoutLocations
5+
import software.amazon.smithy.model.shapes.ShapeId
6+
import software.amazon.smithy.model.validation.Severity
7+
import software.amazon.smithy.model.validation.ValidationEvent
8+
import weaver._
9+
10+
object JsonPayloadValidatorSpec extends FunSuite {
11+
test("no error when jsonPayload is used on the input, output or error structure's member") {
12+
13+
assembleModel(
14+
"""$version: "2"
15+
|namespace test
16+
|
17+
|use jsonrpclib#jsonRPC
18+
|use jsonrpclib#jsonRequest
19+
|use jsonrpclib#jsonPayload
20+
|
21+
|@jsonRPC
22+
|service MyService {
23+
| operations: [OpA]
24+
|}
25+
|
26+
|@jsonRequest("foo")
27+
|operation OpA {
28+
| input: OpInput
29+
| output: OpOutput
30+
| errors: [OpError]
31+
|}
32+
|
33+
|structure OpInput {
34+
| @jsonPayload
35+
| data: String
36+
|}
37+
|
38+
|structure OpOutput {
39+
| @jsonPayload
40+
| data: String
41+
|}
42+
|
43+
|@error("client")
44+
|structure OpError {
45+
| @jsonPayload
46+
| data: String
47+
|}
48+
|
49+
|""".stripMargin
50+
).unwrap()
51+
52+
success
53+
}
54+
test("return an error when jsonPayload is used in a nested structure") {
55+
val events = eventsWithoutLocations(
56+
assembleModel(
57+
"""$version: "2"
58+
|namespace test
59+
|
60+
|use jsonrpclib#jsonRPC
61+
|use jsonrpclib#jsonRequest
62+
|use jsonrpclib#jsonPayload
63+
|
64+
|@jsonRPC
65+
|service MyService {
66+
| operations: [OpA]
67+
|}
68+
|
69+
|@jsonRequest("foo")
70+
|operation OpA {
71+
| input: OpInput
72+
|}
73+
|
74+
|structure OpInput {
75+
| data: NestedStructure
76+
|}
77+
|
78+
|structure NestedStructure {
79+
| @jsonPayload
80+
| data: String
81+
|}
82+
|""".stripMargin
83+
)
84+
)
85+
86+
val expected = ValidationEvent
87+
.builder()
88+
.id("jsonPayload.OnlyTopLevel")
89+
.shapeId(ShapeId.fromParts("test", "NestedStructure", "data"))
90+
.severity(Severity.ERROR)
91+
.message(
92+
"Found an incompatible shape when validating the constraints of the `jsonrpclib#jsonPayload` trait attached to `test#NestedStructure$data`: jsonPayload can only be used on the top level of an operation input/output/error."
93+
)
94+
.build()
95+
96+
assert(events.contains(expected))
97+
}
98+
99+
}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
jsonrpclib.validation.JsonNotificationOutputValidator
22
jsonrpclib.validation.UniqueJsonRpcMethodNamesValidator
3-
jsonrpclib.validation.JsonRpcOperationValidator
3+
jsonrpclib.validation.JsonRpcOperationValidator

modules/smithy/src/main/resources/META-INF/smithy/jsonrpclib.smithy

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ namespace jsonrpclib
77
@protocolDefinition(traits: [
88
jsonRequest
99
jsonNotification
10+
jsonPayload
1011
smithy.api#jsonName
1112
smithy.api#length
1213
smithy.api#pattern
@@ -31,3 +32,16 @@ string jsonRequest
3132
/// see https://www.jsonrpc.org/specification#notification
3233
@trait(selector: "operation", conflicts: [jsonRequest])
3334
string jsonNotification
35+
36+
37+
/// Binds a single structure member to the payload of a jsonrpc message.
38+
/// Just like @httpPayload, but for jsonRPC.
39+
@trait(selector: "structure > member", structurallyExclusive: "member")
40+
@traitValidators({
41+
"jsonPayload.OnlyTopLevel": {
42+
message: "jsonPayload can only be used on the top level of an operation input/output/error.",
43+
severity: "ERROR",
44+
selector: "$allowedShapes(:root(operation -[input, output, error]-> structure > member)) :not(:in(${allowedShapes}))"
45+
}
46+
})
47+
structure jsonPayload {}

modules/smithy4s-tests/src/main/smithy/spec.smithy

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ namespace test
55
use jsonrpclib#jsonNotification
66
use jsonrpclib#jsonRPC
77
use jsonrpclib#jsonRequest
8+
use jsonrpclib#jsonPayload
89

910
@jsonRPC
1011
service TestServer {
@@ -29,6 +30,36 @@ operation Greet {
2930
errors: [NotWelcomeError]
3031
}
3132

33+
34+
@jsonRPC
35+
service TestServerWithPayload {
36+
operations: [GreetWithPayload]
37+
}
38+
39+
@jsonRequest("greetWithPayload")
40+
operation GreetWithPayload {
41+
input := {
42+
@required
43+
@jsonPayload
44+
payload: GreetInputPayload
45+
}
46+
output := {
47+
@required
48+
@jsonPayload
49+
payload: GreetOutputPayload
50+
}
51+
}
52+
53+
structure GreetInputPayload {
54+
@required
55+
name: String
56+
}
57+
58+
structure GreetOutputPayload {
59+
@required
60+
message: String
61+
}
62+
3263
@error("client")
3364
structure NotWelcomeError {
3465
@required

modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestClientSpec.scala

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,7 @@ import io.circe.Decoder
77
import io.circe.Encoder
88
import jsonrpclib._
99
import jsonrpclib.fs2._
10-
import test.GreetInput
11-
import test.GreetOutput
12-
import test.PingInput
13-
import test.TestServer
10+
import test._
1411
import weaver._
1512

1613
import scala.concurrent.duration._
@@ -66,4 +63,19 @@ object TestClientSpec extends SimpleIOSuite {
6663
expect.same(result, Some(PingInput("hello")))
6764
}
6865
}
66+
67+
testRes("Round trip with jsonPayload") {
68+
implicit val greetInputDecoder: Decoder[GreetInput] = CirceJsonCodec.fromSchema
69+
implicit val greetOutputEncoder: Encoder[GreetOutput] = CirceJsonCodec.fromSchema
70+
val endpoint: Endpoint[IO] =
71+
Endpoint[IO]("greetWithPayload").simple[GreetInput, GreetOutput](in => IO(GreetOutput(s"Hello ${in.name}")))
72+
73+
for {
74+
clientSideChannel <- setup(endpoint)
75+
clientStub = ClientStub(TestServerWithPayload, clientSideChannel)
76+
result <- clientStub.greetWithPayload(GreetInputPayload("Bob")).toStream
77+
} yield {
78+
expect.same(result.payload.message, "Hello Bob")
79+
}
80+
}
6981
}

modules/smithy4s-tests/src/test/scala/jsonrpclib/smithy4sinterop/TestServerSpec.scala

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,8 @@ import jsonrpclib.Monadic
1212
import jsonrpclib.Payload
1313
import smithy4s.kinds.FunctorAlgebra
1414
import smithy4s.Service
15-
import test.GetWeatherInput
16-
import test.GetWeatherOutput
17-
import test.GreetInput
18-
import test.GreetOutput
19-
import test.NotWelcomeError
20-
import test.PingInput
21-
import test.TestClient
22-
import test.TestServer
23-
import test.TestServerOperation
24-
import test.TestServerOperation.GreetError
25-
import test.WeatherService
15+
import test._
16+
import test.TestServerOperation._
2617
import weaver._
2718

2819
import scala.concurrent.duration._
@@ -241,4 +232,22 @@ object TestServerSpec extends SimpleIOSuite {
241232
expect.same(getWeatherResult.weather, "sunny")
242233
}
243234
}
235+
236+
testRes("Round trip with jsonPayload") {
237+
implicit val greetInputEncoder: Encoder[GreetInput] = CirceJsonCodec.fromSchema
238+
implicit val greetOutputDecoder: Decoder[GreetOutput] = CirceJsonCodec.fromSchema
239+
240+
object ServerImpl extends TestServerWithPayload[IO] {
241+
def greetWithPayload(payload: GreetInputPayload): IO[GreetWithPayloadOutput] =
242+
IO.pure(GreetWithPayloadOutput(GreetOutputPayload(s"Hello ${payload.name}")))
243+
}
244+
245+
for {
246+
clientSideChannel <- setup(_ => AlgebraWrapper(ServerImpl))
247+
remoteFunction = clientSideChannel.simpleStub[GreetInput, GreetOutput]("greetWithPayload")
248+
result <- remoteFunction(GreetInput("Bob")).toStream
249+
} yield {
250+
expect.same(result.message, "Hello Bob")
251+
}
252+
}
244253
}

modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ClientStub.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ object ClientStub {
2424
* Supports both standard request-response and fire-and-forget notification endpoints.
2525
*/
2626
def apply[Alg[_[_, _, _, _, _]], F[_]: Monadic](service: Service[Alg], channel: Channel[F]): service.Impl[F] =
27-
new ClientStub(service, channel).compile
27+
new ClientStub(JsonRpcTransformations.apply(service), channel).compile
2828
}
2929

3030
private class ClientStub[Alg[_[_, _, _, _, _]], F[_]: Monadic](val service: Service[Alg], channel: Channel[F]) {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package jsonrpclib.smithy4sinterop
2+
3+
import jsonrpclib.JsonPayload
4+
import smithy4s.~>
5+
import smithy4s.Schema
6+
import smithy4s.Schema.StructSchema
7+
8+
private[jsonrpclib] object JsonPayloadTransformation extends (Schema ~> Schema) {
9+
10+
def apply[A0](fa: Schema[A0]): Schema[A0] =
11+
fa match {
12+
case struct: StructSchema[b] =>
13+
struct.fields
14+
.collectFirst {
15+
case field if field.hints.has[JsonPayload] =>
16+
field.schema.biject[b]((f: Any) => struct.make(Vector(f)))(field.get)
17+
}
18+
.getOrElse(fa)
19+
case _ => fa
20+
}
21+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package jsonrpclib.smithy4sinterop
2+
3+
import smithy4s.~>
4+
import smithy4s.schema.ErrorSchema
5+
import smithy4s.schema.OperationSchema
6+
import smithy4s.Endpoint
7+
import smithy4s.Schema
8+
import smithy4s.Service
9+
10+
private[jsonrpclib] object JsonRpcTransformations {
11+
12+
def apply[Alg[_[_, _, _, _, _]]]: Service[Alg] => Service[Alg] =
13+
_.toBuilder
14+
.mapEndpointEach(
15+
Endpoint.mapSchema(
16+
OperationSchema
17+
.mapInputK(JsonPayloadTransformation)
18+
.andThen(OperationSchema.mapOutputK(JsonPayloadTransformation))
19+
.andThen(OperationSchema.mapErrorK(errorTransformation))
20+
)
21+
)
22+
.build
23+
24+
private val payloadTransformation: Schema ~> Schema = Schema
25+
.transformTransitivelyK(JsonPayloadTransformation)
26+
27+
private val errorTransformation: ErrorSchema ~> ErrorSchema =
28+
new smithy4s.kinds.PolyFunction[ErrorSchema, ErrorSchema] {
29+
def apply[A](e: ErrorSchema[A]): ErrorSchema[A] = {
30+
payloadTransformation(e.schema).error(e.unliftError)(e.liftError.unlift)
31+
}
32+
}
33+
}

modules/smithy4s/src/main/scala/jsonrpclib/smithy4sinterop/ServerEndpoints.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ object ServerEndpoints {
3232
def apply[Alg[_[_, _, _, _, _]], F[_]](
3333
impl: FunctorAlgebra[Alg, F]
3434
)(implicit service: Service[Alg], F: Monadic[F]): List[Endpoint[F]] = {
35-
val interpreter: service.FunctorInterpreter[F] = service.toPolyFunction(impl)
36-
service.endpoints.toList.flatMap { smithy4sEndpoint =>
35+
val transformedService = JsonRpcTransformations.apply(service)
36+
val interpreter: transformedService.FunctorInterpreter[F] = transformedService.toPolyFunction(impl)
37+
transformedService.endpoints.toList.flatMap { smithy4sEndpoint =>
3738
EndpointSpec
3839
.fromHints(smithy4sEndpoint.hints)
3940
.map { endpointSpec =>

project/plugins.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@ addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1")
1414

1515
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4")
1616

17-
addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.35")
17+
addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.36")
1818

1919
addDependencyTreePlugin

0 commit comments

Comments
 (0)