-
Notifications
You must be signed in to change notification settings - Fork 37
Add support for custom remote function mapping via Annotation #1553
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
Changes from all commits
8ef867e
14a3533
1068e58
1c0b3a5
cb16593
be883e0
d587e8a
95bedbf
855afda
753c410
c0d988f
d979a5a
f0a6982
428a9cf
f1d760a
cb2e95c
9c123a4
8b45634
cce160f
cafc522
3e7a192
4bd2f4b
80681d7
d34b57f
5bde2e2
9ffecc6
13839d0
a7e6fe4
1c8a114
6ad3ef1
0ef3e16
f92a9cd
56c0794
e8a82d2
6d4fc90
57c4acc
df9da2d
c2db582
3a3f729
ce9a8c7
9dff3df
e2b63e6
7105638
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| // Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). | ||
| // | ||
| // WSO2 LLC. licenses this file to you under the Apache License, | ||
| // Version 2.0 (the "License"); you may not use this file except | ||
| // in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, | ||
| // software distributed under the License is distributed on an | ||
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| // KIND, either express or implied. See the License for the | ||
| // specific language governing permissions and limitations | ||
| // under the License. | ||
|
|
||
| import ballerina/test; | ||
|
|
||
| type Subscribe record {| | ||
| string event = "subscribe"; | ||
| string data; | ||
| |}; | ||
|
|
||
| @ServiceConfig { | ||
| dispatcherKey: "event" | ||
| } | ||
| service / on new Listener(22103) { | ||
| resource function get .() returns Service|UpgradeError { | ||
| return new WsService22103(); | ||
| } | ||
| } | ||
|
|
||
| service class WsService22103 { | ||
| *Service; | ||
|
|
||
| @DispatcherMapping { | ||
| value: "subscribe" | ||
| } | ||
| remote function onSubscribeMessage(Subscribe message) returns string { | ||
| return "onSubscribeMessage"; | ||
| } | ||
|
|
||
| remote function onSubscribeMessageError(Caller caller, error message) returns error? { | ||
| check caller->writeMessage("onSubscribeMessageError"); | ||
| } | ||
| } | ||
|
|
||
| @test:Config { | ||
| groups: ["dispatcherMappingAnnotation"] | ||
| } | ||
| public function testDispatcherMappingAnnotation() returns error? { | ||
| Client wsClient = check new ("ws://localhost:22103/"); | ||
| check wsClient->writeMessage({event: "subscribe", data: "test"}); | ||
| string res = check wsClient->readMessage(); | ||
| test:assertEquals(res, "onSubscribeMessage"); | ||
| } | ||
|
|
||
| @test:Config { | ||
| groups: ["dispatcherMappingAnnotation"] | ||
| } | ||
| public function testDispatcherMappingAnnotationWithCustomOnError() returns error? { | ||
| Client wsClient = check new ("ws://localhost:22103/"); | ||
| check wsClient->writeMessage({event: "subscribe", invalidField: "test"}); | ||
| string res = check wsClient->readMessage(); | ||
| test:assertEquals(res, "onSubscribeMessageError"); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| [package] | ||
| org = "websocket_test" | ||
| name = "sample_63" | ||
| version = "0.1.0" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| // Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). | ||
| // | ||
| // WSO2 LLC. licenses this file to you under the Apache License, | ||
| // Version 2.0 (the "License"); you may not use this file except | ||
| // in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, | ||
| // software distributed under the License is distributed on an | ||
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| // KIND, either express or implied. See the License for the | ||
| // specific language governing permissions and limitations | ||
| // under the License. | ||
|
|
||
| import ballerina/websocket as ws; | ||
|
|
||
| type Subscribe record {| | ||
| string event = "subscribe"; | ||
| string data; | ||
| |}; | ||
|
|
||
| @ws:ServiceConfig { | ||
| dispatcherKey: "event" | ||
| } | ||
| service / on new ws:Listener(9090) { | ||
| resource function get .() returns ws:Service|ws:UpgradeError { | ||
| return new WsService(); | ||
| } | ||
| } | ||
|
|
||
| service class WsService { | ||
| *ws:Service; | ||
|
|
||
| remote function onSubscribe(Subscribe message) returns string { | ||
| return "onSubscribe"; | ||
| } | ||
|
|
||
| @ws:DispatcherMapping { | ||
| value: "subscribe" | ||
| } | ||
| remote function onSubscribeMessage(Subscribe message) returns string { | ||
| return "onSubscribeMessage"; | ||
| } | ||
|
|
||
| @ws:DispatcherMapping { | ||
| value: "subscribe" | ||
| } | ||
| remote function onSubscribeText(Subscribe message) returns string { | ||
| return "onSubscribeText"; | ||
| } | ||
|
|
||
| @ws:DispatcherMapping { | ||
| value: "ping" | ||
| } | ||
| remote function onPing(Subscribe message) returns string { | ||
| return "onPing"; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,30 +20,85 @@ | |
| import io.ballerina.compiler.api.symbols.FunctionTypeSymbol; | ||
| import io.ballerina.compiler.api.symbols.MethodSymbol; | ||
| import io.ballerina.compiler.api.symbols.Qualifier; | ||
| import io.ballerina.compiler.api.symbols.Symbol; | ||
| import io.ballerina.compiler.syntax.tree.AnnotationNode; | ||
| import io.ballerina.compiler.syntax.tree.ClassDefinitionNode; | ||
| import io.ballerina.compiler.syntax.tree.ExpressionNode; | ||
| import io.ballerina.compiler.syntax.tree.FunctionDefinitionNode; | ||
| import io.ballerina.compiler.syntax.tree.MappingConstructorExpressionNode; | ||
| import io.ballerina.compiler.syntax.tree.Node; | ||
| import io.ballerina.compiler.syntax.tree.NodeList; | ||
| import io.ballerina.compiler.syntax.tree.SpecificFieldNode; | ||
| import io.ballerina.compiler.syntax.tree.SyntaxKind; | ||
| import io.ballerina.compiler.syntax.tree.Token; | ||
| import io.ballerina.projects.plugins.SyntaxNodeAnalysisContext; | ||
| import io.ballerina.stdlib.websocket.WebSocketConstants; | ||
| import io.ballerina.tools.diagnostics.DiagnosticFactory; | ||
| import io.ballerina.tools.diagnostics.DiagnosticInfo; | ||
| import io.ballerina.tools.diagnostics.DiagnosticSeverity; | ||
|
|
||
| import java.util.HashSet; | ||
| import java.util.Map; | ||
| import java.util.Optional; | ||
| import java.util.Set; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| import static io.ballerina.stdlib.websocket.WebSocketConstants.ANNOTATION_ATTR_DISPATCHER_VALUE; | ||
| import static io.ballerina.stdlib.websocket.WebSocketResourceDispatcher.createCustomRemoteFunction; | ||
| import static io.ballerina.stdlib.websocket.plugin.PluginConstants.CompilationErrors.DUPLICATED_DISPATCHER_MAPPING_VALUE; | ||
| import static io.ballerina.stdlib.websocket.plugin.PluginConstants.CompilationErrors.INVALID_FUNCTION_ANNOTATION; | ||
| import static io.ballerina.stdlib.websocket.plugin.PluginConstants.CompilationErrors.RE_DECLARED_REMOTE_FUNCTIONS; | ||
|
|
||
| /** | ||
| * A class for validating websocket service. | ||
| */ | ||
| public class WebSocketServiceValidator { | ||
| public static final String GENERIC_FUNCTION = "generic function"; | ||
| private final Set<String> specialRemoteMethods = Set.of(PluginConstants.ON_OPEN, PluginConstants.ON_CLOSE, | ||
| PluginConstants.ON_ERROR, PluginConstants.ON_IDLE_TIMEOUT, PluginConstants.ON_TEXT_MESSAGE, | ||
| PluginConstants.ON_BINARY_MESSAGE, PluginConstants.ON_MESSAGE, PluginConstants.ON_PING_MESSAGE, | ||
| PluginConstants.ON_PONG_MESSAGE); | ||
| private SyntaxNodeAnalysisContext ctx; | ||
|
|
||
| WebSocketServiceValidator(SyntaxNodeAnalysisContext syntaxNodeAnalysisContext) { | ||
| this.ctx = syntaxNodeAnalysisContext; | ||
| } | ||
|
|
||
| private static Optional<String> getDispatcherMappingAnnotatedFunctionName(FunctionDefinitionNode node, | ||
| SyntaxNodeAnalysisContext ctx) { | ||
| if (node.metadata().isEmpty()) { | ||
| return Optional.empty(); | ||
| } | ||
| for (AnnotationNode annotationNode : node.metadata().get().annotations()) { | ||
| Optional<Symbol> annotationType = ctx.semanticModel().symbol(annotationNode); | ||
| if (annotationType.isEmpty()) { | ||
| continue; | ||
| } | ||
| if (!annotationType.get().getModule().flatMap(Symbol::getName) | ||
| .orElse("").equals(WebSocketConstants.PACKAGE_WEBSOCKET) || | ||
| !annotationType.get().getName().orElse("") | ||
| .equals(WebSocketConstants.WEBSOCKET_DISPATCHER_MAPPING_ANNOTATION)) { | ||
| continue; | ||
| } | ||
| if (annotationNode.annotValue().isEmpty()) { | ||
| return Optional.empty(); | ||
| } | ||
| MappingConstructorExpressionNode annotationValue = annotationNode.annotValue().get(); | ||
| for (Node field : annotationValue.fields()) { | ||
| if (!field.kind().equals(SyntaxKind.SPECIFIC_FIELD)) { | ||
| continue; | ||
| } | ||
| String fieldName = ((SpecificFieldNode) field).fieldName().toString().strip(); | ||
| Optional<ExpressionNode> filedValue = ((SpecificFieldNode) field).valueExpr(); | ||
| if (!fieldName.equals(ANNOTATION_ATTR_DISPATCHER_VALUE) || filedValue.isEmpty()) { | ||
| continue; | ||
| } | ||
| return Optional.of(filedValue.get().toString().replaceAll("\"", "").strip()); | ||
| } | ||
| } | ||
| return Optional.empty(); | ||
| } | ||
|
|
||
| public void validate() { | ||
| ClassDefinitionNode classDefNode = (ClassDefinitionNode) ctx.node(); | ||
| Map<String, Boolean> functionSet = classDefNode.members().stream().filter(child -> | ||
|
|
@@ -70,7 +125,7 @@ public void validate() { | |
| classDefNode.location(), PluginConstants.ON_TEXT_MESSAGE); | ||
| } | ||
| if (functionSet.containsKey(PluginConstants.ON_MESSAGE) && | ||
| functionSet.containsKey(PluginConstants.ON_BINARY_MESSAGE)) { | ||
| functionSet.containsKey(PluginConstants.ON_BINARY_MESSAGE)) { | ||
| Utils.reportDiagnostics(ctx, PluginConstants.CompilationErrors.INVALID_REMOTE_FUNCTIONS, | ||
| classDefNode.location(), PluginConstants.ON_BINARY_MESSAGE); | ||
| } | ||
|
|
@@ -105,6 +160,45 @@ public void validate() { | |
| !functionSet.containsKey(PluginConstants.ON_BINARY_MESSAGE)) { | ||
| reportDiagnostic(classDefNode, PluginConstants.CompilationErrors.ON_MESSAGE_GENERATION_HINT); | ||
| } | ||
| validateDispatcherMappingAnnotations(classDefNode, functionSet); | ||
| } | ||
|
|
||
| private void validateDispatcherMappingAnnotations(ClassDefinitionNode classDefNode, | ||
| Map<String, Boolean> functionSet) { | ||
| Set<String> seenAnnotationValues = new HashSet<>(); | ||
| for (Node node : classDefNode.members()) { | ||
| if (!node.kind().equals(SyntaxKind.OBJECT_METHOD_DEFINITION)) { | ||
| continue; | ||
| } | ||
| FunctionDefinitionNode funcDefinitionNode = (FunctionDefinitionNode) node; | ||
| if (funcDefinitionNode.qualifierList().stream() | ||
| .noneMatch(token -> token.text().equals(Qualifier.REMOTE.getValue()))) { | ||
| continue; | ||
| } | ||
|
Comment on lines
+174
to
+177
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is okay, but ideally, we should check this on function symbol. Can do something like this: The reasons are follows; There are two main APIs we use in compiler plugins;
Semantic API is more convenient and robust when it comes to validations IMO, because it exposes the semantic model instead of the raw syntax tree. When we use the Syntax API in compiler plugins to validate code, it takes more effort as we have to rely on string manipulations, string concatenations, etc., where semantic API provides more easy-to-use APIs for validation. (Of course there are instances where we have to use syntax APIs, and this is not to discourage the use of syntax API, but in the scenarios like these, it would be better to use Semantic API). FunctionSymbol functionSymbol = // Get the node from the semantic model
if (!functionSymbol.qualifiers.contains(Qualifier.REMOTE) {
continue;
} |
||
| Optional<String> funcName = ctx.semanticModel().symbol(funcDefinitionNode).flatMap(Symbol::getName); | ||
| Optional<String> annoDispatchingValue = getDispatcherMappingAnnotatedFunctionName(funcDefinitionNode, ctx); | ||
| if (funcName.isEmpty() || annoDispatchingValue.isEmpty()) { | ||
ThisaruGuruge marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| continue; | ||
| } | ||
| if (seenAnnotationValues.contains(annoDispatchingValue.get())) { | ||
| Utils.reportDiagnostics(ctx, DUPLICATED_DISPATCHER_MAPPING_VALUE, | ||
| funcDefinitionNode.location(), annoDispatchingValue.get()); | ||
| continue; | ||
| } | ||
| seenAnnotationValues.add(annoDispatchingValue.get()); | ||
| String customRemoteFunctionName = createCustomRemoteFunction(annoDispatchingValue.get()); | ||
| if (this.specialRemoteMethods.contains(funcName.get())) { | ||
| Utils.reportDiagnostics(ctx, INVALID_FUNCTION_ANNOTATION, funcDefinitionNode.location(), | ||
| funcName.get()); | ||
| continue; | ||
| } | ||
| if (functionSet.containsKey(customRemoteFunctionName) && | ||
| !customRemoteFunctionName.equals(funcName.get()) && | ||
| !this.specialRemoteMethods.contains(customRemoteFunctionName)) { | ||
| Utils.reportDiagnostics(ctx, RE_DECLARED_REMOTE_FUNCTIONS, classDefNode.location(), | ||
| customRemoteFunctionName, annoDispatchingValue.get(), funcName.get()); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private void filterRemoteFunctions(FunctionDefinitionNode functionDefinitionNode) { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.