Skip to content

feat: Add jsonPayload trait #89

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

Merged
merged 5 commits into from
May 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package jsonrpclib

import jsonrpclib.ModelUtils.assembleModel
import jsonrpclib.ModelUtils.eventsWithoutLocations
import software.amazon.smithy.model.shapes.ShapeId
import software.amazon.smithy.model.validation.Severity
import software.amazon.smithy.model.validation.ValidationEvent
import weaver._

object JsonPayloadValidatorSpec extends FunSuite {
test("no error when jsonPayload is used on the input, output or error structure's member") {

assembleModel(
"""$version: "2"
|namespace test
|
|use jsonrpclib#jsonRPC
|use jsonrpclib#jsonRequest
|use jsonrpclib#jsonPayload
|
|@jsonRPC
|service MyService {
| operations: [OpA]
|}
|
|@jsonRequest("foo")
|operation OpA {
| input: OpInput
| output: OpOutput
| errors: [OpError]
|}
|
|structure OpInput {
| @jsonPayload
| data: String
|}
|
|structure OpOutput {
| @jsonPayload
| data: String
|}
|
|@error("client")
|structure OpError {
| @jsonPayload
| data: String
|}
|
|""".stripMargin
).unwrap()

success
}
test("return an error when jsonPayload is used in a nested structure") {
val events = eventsWithoutLocations(
assembleModel(
"""$version: "2"
|namespace test
|
|use jsonrpclib#jsonRPC
|use jsonrpclib#jsonRequest
|use jsonrpclib#jsonPayload
|
|@jsonRPC
|service MyService {
| operations: [OpA]
|}
|
|@jsonRequest("foo")
|operation OpA {
| input: OpInput
|}
|
|structure OpInput {
| data: NestedStructure
|}
|
|structure NestedStructure {
| @jsonPayload
| data: String
|}
|""".stripMargin
)
)

val expected = ValidationEvent
.builder()
.id("jsonPayload.OnlyTopLevel")
.shapeId(ShapeId.fromParts("test", "NestedStructure", "data"))
.severity(Severity.ERROR)
.message(
"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."
)
.build()

assert(events.contains(expected))
}

}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
jsonrpclib.validation.JsonNotificationOutputValidator
jsonrpclib.validation.UniqueJsonRpcMethodNamesValidator
jsonrpclib.validation.JsonRpcOperationValidator
jsonrpclib.validation.JsonRpcOperationValidator
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace jsonrpclib
@protocolDefinition(traits: [
jsonRequest
jsonNotification
jsonPayload
smithy.api#jsonName
smithy.api#length
smithy.api#pattern
Expand All @@ -31,3 +32,16 @@ string jsonRequest
/// see https://www.jsonrpc.org/specification#notification
@trait(selector: "operation", conflicts: [jsonRequest])
string jsonNotification


/// Binds a single structure member to the payload of a jsonrpc message.
/// Just like @httpPayload, but for jsonRPC.
@trait(selector: "structure > member", structurallyExclusive: "member")
@traitValidators({
"jsonPayload.OnlyTopLevel": {
message: "jsonPayload can only be used on the top level of an operation input/output/error.",
severity: "ERROR",
selector: "$allowedShapes(:root(operation -[input, output, error]-> structure > member)) :not(:in(${allowedShapes}))"
}
})
structure jsonPayload {}
31 changes: 31 additions & 0 deletions modules/smithy4s-tests/src/main/smithy/spec.smithy
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace test
use jsonrpclib#jsonNotification
use jsonrpclib#jsonRPC
use jsonrpclib#jsonRequest
use jsonrpclib#jsonPayload

@jsonRPC
service TestServer {
Expand All @@ -29,6 +30,36 @@ operation Greet {
errors: [NotWelcomeError]
}


@jsonRPC
service TestServerWithPayload {
operations: [GreetWithPayload]
}

@jsonRequest("greetWithPayload")
operation GreetWithPayload {
input := {
@required
@jsonPayload
payload: GreetInputPayload
}
output := {
@required
@jsonPayload
payload: GreetOutputPayload
}
}

structure GreetInputPayload {
@required
name: String
}

structure GreetOutputPayload {
@required
message: String
}

@error("client")
structure NotWelcomeError {
@required
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@ import io.circe.Decoder
import io.circe.Encoder
import jsonrpclib._
import jsonrpclib.fs2._
import test.GreetInput
import test.GreetOutput
import test.PingInput
import test.TestServer
import test._
import weaver._

import scala.concurrent.duration._
Expand Down Expand Up @@ -66,4 +63,19 @@ object TestClientSpec extends SimpleIOSuite {
expect.same(result, Some(PingInput("hello")))
}
}

testRes("Round trip with jsonPayload") {
implicit val greetInputDecoder: Decoder[GreetInput] = CirceJsonCodec.fromSchema
implicit val greetOutputEncoder: Encoder[GreetOutput] = CirceJsonCodec.fromSchema
val endpoint: Endpoint[IO] =
Endpoint[IO]("greetWithPayload").simple[GreetInput, GreetOutput](in => IO(GreetOutput(s"Hello ${in.name}")))

for {
clientSideChannel <- setup(endpoint)
clientStub = ClientStub(TestServerWithPayload, clientSideChannel)
result <- clientStub.greetWithPayload(GreetInputPayload("Bob")).toStream
} yield {
expect.same(result.payload.message, "Hello Bob")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,8 @@ import jsonrpclib.Monadic
import jsonrpclib.Payload
import smithy4s.kinds.FunctorAlgebra
import smithy4s.Service
import test.GetWeatherInput
import test.GetWeatherOutput
import test.GreetInput
import test.GreetOutput
import test.NotWelcomeError
import test.PingInput
import test.TestClient
import test.TestServer
import test.TestServerOperation
import test.TestServerOperation.GreetError
import test.WeatherService
import test._
import test.TestServerOperation._
import weaver._

import scala.concurrent.duration._
Expand Down Expand Up @@ -241,4 +232,22 @@ object TestServerSpec extends SimpleIOSuite {
expect.same(getWeatherResult.weather, "sunny")
}
}

testRes("Round trip with jsonPayload") {
implicit val greetInputEncoder: Encoder[GreetInput] = CirceJsonCodec.fromSchema
implicit val greetOutputDecoder: Decoder[GreetOutput] = CirceJsonCodec.fromSchema

object ServerImpl extends TestServerWithPayload[IO] {
def greetWithPayload(payload: GreetInputPayload): IO[GreetWithPayloadOutput] =
IO.pure(GreetWithPayloadOutput(GreetOutputPayload(s"Hello ${payload.name}")))
}

for {
clientSideChannel <- setup(_ => AlgebraWrapper(ServerImpl))
remoteFunction = clientSideChannel.simpleStub[GreetInput, GreetOutput]("greetWithPayload")
result <- remoteFunction(GreetInput("Bob")).toStream
} yield {
expect.same(result.message, "Hello Bob")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ object ClientStub {
* Supports both standard request-response and fire-and-forget notification endpoints.
*/
def apply[Alg[_[_, _, _, _, _]], F[_]: Monadic](service: Service[Alg], channel: Channel[F]): service.Impl[F] =
new ClientStub(service, channel).compile
new ClientStub(JsonRpcTransformations.apply(service), channel).compile
}

private class ClientStub[Alg[_[_, _, _, _, _]], F[_]: Monadic](val service: Service[Alg], channel: Channel[F]) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package jsonrpclib.smithy4sinterop

import jsonrpclib.JsonPayload
import smithy4s.~>
import smithy4s.Schema
import smithy4s.Schema.StructSchema

private[jsonrpclib] object JsonPayloadTransformation extends (Schema ~> Schema) {

def apply[A0](fa: Schema[A0]): Schema[A0] =
fa match {
case struct: StructSchema[b] =>
struct.fields
.collectFirst {
case field if field.hints.has[JsonPayload] =>
field.schema.biject[b]((f: Any) => struct.make(Vector(f)))(field.get)
}
.getOrElse(fa)
case _ => fa
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package jsonrpclib.smithy4sinterop

import smithy4s.~>
import smithy4s.schema.ErrorSchema
import smithy4s.schema.OperationSchema
import smithy4s.Endpoint
import smithy4s.Schema
import smithy4s.Service

private[jsonrpclib] object JsonRpcTransformations {

def apply[Alg[_[_, _, _, _, _]]]: Service[Alg] => Service[Alg] =
_.toBuilder
.mapEndpointEach(
Endpoint.mapSchema(
OperationSchema
.mapInputK(JsonPayloadTransformation)
.andThen(OperationSchema.mapOutputK(JsonPayloadTransformation))
.andThen(OperationSchema.mapErrorK(errorTransformation))
)
)
.build

private val payloadTransformation: Schema ~> Schema = Schema
.transformTransitivelyK(JsonPayloadTransformation)

private val errorTransformation: ErrorSchema ~> ErrorSchema =
new smithy4s.kinds.PolyFunction[ErrorSchema, ErrorSchema] {
def apply[A](e: ErrorSchema[A]): ErrorSchema[A] = {
payloadTransformation(e.schema).error(e.unliftError)(e.liftError.unlift)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ object ServerEndpoints {
def apply[Alg[_[_, _, _, _, _]], F[_]](
impl: FunctorAlgebra[Alg, F]
)(implicit service: Service[Alg], F: Monadic[F]): List[Endpoint[F]] = {
val interpreter: service.FunctorInterpreter[F] = service.toPolyFunction(impl)
service.endpoints.toList.flatMap { smithy4sEndpoint =>
val transformedService = JsonRpcTransformations.apply(service)
val interpreter: transformedService.FunctorInterpreter[F] = transformedService.toPolyFunction(impl)
transformedService.endpoints.toList.flatMap { smithy4sEndpoint =>
EndpointSpec
.fromHints(smithy4sEndpoint.hints)
.map { endpointSpec =>
Expand Down
2 changes: 1 addition & 1 deletion project/plugins.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1")

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

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

addDependencyTreePlugin