Skip to content

Commit 9395f05

Browse files
Add smithy validations (#88)
1 parent fdb23b4 commit 9395f05

File tree

13 files changed

+430
-4
lines changed

13 files changed

+430
-4
lines changed

build.sbt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ val smithy = projectMatrix
102102
smithyTraitCodegenNamespace := "jsonrpclib"
103103
)
104104

105+
val smithyTests = projectMatrix
106+
.in(file("modules/smithy-tests"))
107+
.jvmPlatform(Seq(scala213))
108+
.dependsOn(smithy)
109+
.settings(
110+
publish / skip := true,
111+
libraryDependencies ++= Seq(
112+
"com.disneystreaming" %%% "weaver-cats" % "0.8.4" % Test
113+
)
114+
)
115+
.disablePlugins(MimaPlugin)
116+
105117
lazy val buildTimeProtocolDependency =
106118
/** By default, smithy4sInternalDependenciesAsJars doesn't contain the jars in the "smithy4s" configuration. We have
107119
* to add them manually - this is the equivalent of a "% Smithy4s"-scoped dependency.
@@ -137,7 +149,7 @@ val smithy4s = projectMatrix
137149
)
138150

139151
val smithy4sTests = projectMatrix
140-
.in(file("modules") / "smithy4sTests")
152+
.in(file("modules") / "smithy4s-tests")
141153
.jvmPlatform(jvmScalaVersions, commonJvmSettings)
142154
.jsPlatform(jsScalaVersions)
143155
.nativePlatform(Seq(scala3))
@@ -251,6 +263,7 @@ val root = project
251263
exampleServer,
252264
exampleClient,
253265
smithy,
266+
smithyTests,
254267
smithy4s,
255268
smithy4sTests,
256269
exampleSmithyShared,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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 JsonNotificationOutputValidatorSpec extends FunSuite {
11+
test("no error when a @jsonNotification operation has unit output") {
12+
assembleModel(
13+
"""$version: "2"
14+
|namespace test
15+
|
16+
|use jsonrpclib#jsonNotification
17+
|
18+
|@jsonNotification("notify")
19+
|operation NotifySomething {
20+
|}
21+
|""".stripMargin
22+
)
23+
success
24+
}
25+
test("return an error when a @jsonNotification operation does not have unit output") {
26+
val events = eventsWithoutLocations(
27+
assembleModel(
28+
"""$version: "2"
29+
|namespace test
30+
|
31+
|use jsonrpclib#jsonNotification
32+
|
33+
|@jsonNotification("notify")
34+
|operation NotifySomething {
35+
| output:={
36+
| message: String
37+
| }
38+
|}
39+
|
40+
|""".stripMargin
41+
)
42+
)
43+
44+
val expected = ValidationEvent
45+
.builder()
46+
.id("JsonNotificationOutput")
47+
.shapeId(ShapeId.fromParts("test", "NotifySomething"))
48+
.severity(Severity.ERROR)
49+
.message(
50+
"Operation marked as @jsonNotification must not return anything, but found `test#NotifySomethingOutput`."
51+
)
52+
.build()
53+
54+
assert(events.contains(expected))
55+
}
56+
57+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 JsonRpcOperationValidatorSpec extends FunSuite {
11+
test("no error when all operations in @jsonRPC service are properly annotated") {
12+
assembleModel(
13+
"""$version: "2"
14+
|namespace test
15+
|
16+
|use jsonrpclib#jsonRPC
17+
|use jsonrpclib#jsonRequest
18+
|use jsonrpclib#jsonNotification
19+
|
20+
|@jsonRPC
21+
|service MyService {
22+
| operations: [OpA, OpB]
23+
|}
24+
|
25+
|@jsonRequest("methodA")
26+
|operation OpA {}
27+
|
28+
|@jsonNotification("methodB")
29+
|operation OpB {
30+
| output: unit
31+
|}
32+
|""".stripMargin
33+
)
34+
success
35+
}
36+
37+
test("return an error when a @jsonRPC service has an operation without @jsonRequest or @jsonNotification") {
38+
val events = eventsWithoutLocations(
39+
assembleModel(
40+
"""$version: "2"
41+
|namespace test
42+
|
43+
|use jsonrpclib#jsonRPC
44+
|use jsonrpclib#jsonRequest
45+
|
46+
|@jsonRPC
47+
|service MyService {
48+
| operations: [GoodOp, BadOp]
49+
|}
50+
|
51+
|@jsonRequest("good")
52+
|operation GoodOp {}
53+
|
54+
|operation BadOp {} // ❌ missing jsonRequest or jsonNotification
55+
|""".stripMargin
56+
)
57+
)
58+
59+
val expected =
60+
ValidationEvent
61+
.builder()
62+
.id("JsonRpcOperation")
63+
.shapeId(ShapeId.fromParts("test", "BadOp"))
64+
.severity(Severity.ERROR)
65+
.message(
66+
"Operation is part of service `test#MyService` marked with @jsonRPC but is missing @jsonRequest or @jsonNotification."
67+
)
68+
.build()
69+
70+
assert(events.contains(expected))
71+
}
72+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package jsonrpclib
2+
3+
import software.amazon.smithy.model.validation.ValidatedResult
4+
import software.amazon.smithy.model.validation.ValidationEvent
5+
import software.amazon.smithy.model.Model
6+
import software.amazon.smithy.model.SourceLocation
7+
8+
import scala.jdk.CollectionConverters._
9+
10+
private object ModelUtils {
11+
12+
def assembleModel(text: String): ValidatedResult[Model] = {
13+
Model
14+
.assembler()
15+
.discoverModels()
16+
.addUnparsedModel(
17+
"test.smithy",
18+
text
19+
)
20+
.assemble()
21+
}
22+
23+
def eventsWithoutLocations(result: ValidatedResult[?]): List[ValidationEvent] = {
24+
if (!result.isBroken) sys.error("Expected a broken result")
25+
result.getValidationEvents.asScala.toList.map(e => e.toBuilder.sourceLocation(SourceLocation.NONE).build())
26+
}
27+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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 UniqueJsonRpcMethodNamesValidatorSpec extends FunSuite {
11+
test("no error when all jsonRpc method names are unique within a service") {
12+
13+
assembleModel(
14+
"""$version: "2"
15+
|namespace test
16+
|
17+
|use jsonrpclib#jsonRPC
18+
|use jsonrpclib#jsonRequest
19+
|use jsonrpclib#jsonNotification
20+
|
21+
|@jsonRPC
22+
|service MyService {
23+
| operations: [OpA, OpB]
24+
|}
25+
|
26+
|@jsonRequest("foo")
27+
|operation OpA {}
28+
|
29+
|@jsonNotification("bar")
30+
|operation OpB {}
31+
|""".stripMargin
32+
).unwrap()
33+
34+
success
35+
}
36+
test("return an error when two operations use the same jsonRpc method name in a service") {
37+
val events = eventsWithoutLocations(
38+
assembleModel(
39+
"""$version: "2"
40+
|namespace test
41+
|
42+
|use jsonrpclib#jsonRPC
43+
|use jsonrpclib#jsonRequest
44+
|use jsonrpclib#jsonNotification
45+
|
46+
|@jsonRPC
47+
|service MyService {
48+
| operations: [OpA, OpB]
49+
|}
50+
|
51+
|@jsonRequest("foo")
52+
|operation OpA {}
53+
|
54+
|@jsonNotification("foo")
55+
|operation OpB {} // duplicate method name "foo"
56+
|""".stripMargin
57+
)
58+
)
59+
60+
val expected = ValidationEvent
61+
.builder()
62+
.id("UniqueJsonRpcMethodNames")
63+
.shapeId(ShapeId.fromParts("test", "MyService"))
64+
.severity(Severity.ERROR)
65+
.message(
66+
"Duplicate JSON-RPC method name `foo` in service `test#MyService`. It is used by: test#OpA, test#OpB"
67+
)
68+
.build()
69+
70+
assert(events.contains(expected))
71+
}
72+
73+
test("no error if two services use the same operation") {
74+
assembleModel(
75+
"""$version: "2"
76+
|namespace test
77+
|
78+
|use jsonrpclib#jsonRPC
79+
|use jsonrpclib#jsonRequest
80+
|use jsonrpclib#jsonNotification
81+
|
82+
|@jsonRPC
83+
|service MyService {
84+
| operations: [OpA]
85+
|}
86+
|
87+
|@jsonRPC
88+
|service MyOtherService {
89+
| operations: [OpA]
90+
|}
91+
|
92+
|@jsonRequest("foo")
93+
|operation OpA {}
94+
|
95+
|""".stripMargin
96+
).unwrap()
97+
success
98+
}
99+
100+
test("no error if two services use the same operation") {
101+
assembleModel(
102+
"""$version: "2"
103+
|namespace test
104+
|
105+
|use jsonrpclib#jsonRequest
106+
|use jsonrpclib#jsonNotification
107+
|
108+
|
109+
|service NonJsonRpcService {
110+
| operations: [OpA]
111+
|}
112+
|
113+
|@jsonRequest("foo")
114+
|operation OpA {}
115+
|
116+
|@jsonNotification("foo")
117+
|operation OpB {} // duplicate method name "foo"
118+
|""".stripMargin
119+
).unwrap()
120+
success
121+
}
122+
123+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package jsonrpclib.validation;
2+
3+
import jsonrpclib.JsonNotificationTrait;
4+
import software.amazon.smithy.model.Model;
5+
import software.amazon.smithy.model.shapes.ShapeId;
6+
import software.amazon.smithy.model.validation.AbstractValidator;
7+
import software.amazon.smithy.model.validation.ValidationEvent;
8+
9+
import java.util.List;
10+
import java.util.stream.Collectors;
11+
import java.util.stream.Stream;
12+
13+
/**
14+
* Validates that operations marked with @jsonNotification don't have any
15+
* output.
16+
*/
17+
public class JsonNotificationOutputValidator extends AbstractValidator {
18+
19+
@Override
20+
public List<ValidationEvent> validate(Model model) {
21+
return model.getShapesWithTrait(JsonNotificationTrait.ID).stream().flatMap(op -> {
22+
ShapeId outputShapeId = op.asOperationShape().orElseThrow().getOutputShape();
23+
var outputShape = model.expectShape(outputShapeId);
24+
if (outputShape.asStructureShape().map(s -> !s.members().isEmpty()).orElse(true)) {
25+
return Stream.of(error(op, String.format(
26+
"Operation marked as @jsonNotification must not return anything, but found `%s`.", outputShapeId)));
27+
} else {
28+
return Stream.empty();
29+
}
30+
}).collect(Collectors.toUnmodifiableList());
31+
}
32+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package jsonrpclib.validation;
2+
3+
import jsonrpclib.JsonNotificationTrait;
4+
import jsonrpclib.JsonRPCTrait;
5+
import jsonrpclib.JsonRequestTrait;
6+
import software.amazon.smithy.model.Model;
7+
import software.amazon.smithy.model.shapes.ServiceShape;
8+
import software.amazon.smithy.model.shapes.Shape;
9+
import software.amazon.smithy.model.validation.AbstractValidator;
10+
import software.amazon.smithy.model.validation.ValidationEvent;
11+
12+
import java.util.List;
13+
import java.util.stream.Collectors;
14+
import java.util.stream.Stream;
15+
16+
public class JsonRpcOperationValidator extends AbstractValidator {
17+
18+
@Override
19+
public List<ValidationEvent> validate(Model model) {
20+
return model.getServiceShapes().stream()
21+
.filter(service -> service.hasTrait(JsonRPCTrait.class))
22+
.flatMap(service -> validateService(model, service))
23+
.collect(Collectors.toList());
24+
}
25+
26+
private Stream<ValidationEvent> validateService(Model model, ServiceShape service) {
27+
return service.getAllOperations().stream()
28+
.map(model::expectShape)
29+
.filter(op -> !hasJsonRpcMethod(op))
30+
.map(op -> error(op, String.format(
31+
"Operation is part of service `%s` marked with @jsonRPC but is missing @jsonRequest or @jsonNotification.", service.getId())));
32+
}
33+
34+
private boolean hasJsonRpcMethod(Shape op) {
35+
return op.hasTrait(JsonRequestTrait.ID) || op.hasTrait(JsonNotificationTrait.ID);
36+
}
37+
}
38+

0 commit comments

Comments
 (0)