From aad686cd566ac5b48eba4859afec45aebb897747 Mon Sep 17 00:00:00 2001 From: artem-v Date: Mon, 17 Nov 2025 16:51:40 +0200 Subject: [PATCH 1/2] WIP on enhanced service registration for RESTful services --- .../java/io/scalecube/services/Reflect.java | 27 +++++- .../methods/ServiceMethodInvokerTest.java | 6 +- .../rest/RestServiceDefinitionTest.java | 87 +++++++++++++++++++ .../io/scalecube/services/ServiceScanner.java | 4 +- .../registry/ServiceRegistryImpl.java | 2 +- 5 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 services-gateway/src/test/java/io/scalecube/services/gateway/rest/RestServiceDefinitionTest.java diff --git a/services-api/src/main/java/io/scalecube/services/Reflect.java b/services-api/src/main/java/io/scalecube/services/Reflect.java index 0cfe00471..b40341a8a 100644 --- a/services-api/src/main/java/io/scalecube/services/Reflect.java +++ b/services-api/src/main/java/io/scalecube/services/Reflect.java @@ -220,10 +220,29 @@ private static Map transformArrayToMap(Tag[] array) { * @param serviceInterface with {@link Service} annotation * @return service name */ - public static Map serviceMethods(Class serviceInterface) { - return Arrays.stream(serviceInterface.getMethods()) - .filter(method -> method.isAnnotationPresent(ServiceMethod.class)) - .collect(Collectors.toMap(Reflect::methodName, Function.identity())); + public static Collection serviceMethods(Class serviceInterface) { + final var methodList = + Arrays.stream(serviceInterface.getMethods()) + .filter(method -> method.isAnnotationPresent(ServiceMethod.class)) + .toList(); + + //noinspection unused + final var collect = + methodList.stream() + .collect( + Collectors.toMap( + method -> + String.join(":", Reflect.methodName(method), Reflect.restMethod(method)), + Function.identity(), + (method, duplicate) -> { + throw new IllegalArgumentException( + "Duplicate method found for method: " + + method + + ", duplicate: " + + duplicate); + })); + + return methodList; } /** diff --git a/services-api/src/test/java/io/scalecube/services/methods/ServiceMethodInvokerTest.java b/services-api/src/test/java/io/scalecube/services/methods/ServiceMethodInvokerTest.java index 7aadff2a8..458041256 100644 --- a/services-api/src/test/java/io/scalecube/services/methods/ServiceMethodInvokerTest.java +++ b/services-api/src/test/java/io/scalecube/services/methods/ServiceMethodInvokerTest.java @@ -368,7 +368,11 @@ private static Consumer assertError(int errorCode, String errorM private static MethodInfo getMethodInfo(Object serviceInstance, String methodName) { final var serviceInstanceClass = serviceInstance.getClass(); final Class serviceInterface = Reflect.serviceInterfaces(serviceInstance).toList().get(0); - final var method = Reflect.serviceMethods(serviceInterface).get(methodName); + final var method = + Reflect.serviceMethods(serviceInterface).stream() + .filter(m -> m.getName().equals(methodName)) + .findFirst() + .get(); // get service instance method Method serviceMethod; diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/rest/RestServiceDefinitionTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/rest/RestServiceDefinitionTest.java new file mode 100644 index 000000000..5a502f8a1 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/rest/RestServiceDefinitionTest.java @@ -0,0 +1,87 @@ +package io.scalecube.services.gateway.rest; + +import static io.scalecube.services.api.ServiceMessage.HEADER_REQUEST_METHOD; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.mock; + +import io.scalecube.services.Microservices; +import io.scalecube.services.Microservices.Context; +import io.scalecube.services.annotations.RestMethod; +import io.scalecube.services.annotations.Service; +import io.scalecube.services.annotations.ServiceMethod; +import io.scalecube.services.api.ServiceMessage; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +public class RestServiceDefinitionTest { + + @Test + void registerInvalidService() { + try { + Microservices.start(new Context().services(mock(BadService.class))); + fail("Expected exception"); + } catch (Exception e) { + assertInstanceOf(IllegalArgumentException.class, e, e::getMessage); + assertThat(e.getMessage(), Matchers.startsWith("Duplicate method found for method")); + } + } + + @Test + void registerValidService() { + try (final var microservices = + Microservices.start(new Context().services(mock(GoodService.class)))) { + final var serviceRegistry = microservices.serviceRegistry(); + + final var foo = System.nanoTime(); + final var methodInvokerWithoutRestMethod = + serviceRegistry.lookupInvoker( + ServiceMessage.builder().qualifier("v1/service/echo/" + foo).build()); + assertNull(methodInvokerWithoutRestMethod); + + final var methodInvokerByGet = + serviceRegistry.lookupInvoker( + ServiceMessage.builder() + .header(HEADER_REQUEST_METHOD, "GET") + .qualifier("v1/service/echo/" + foo) + .build()); + assertNotNull(methodInvokerByGet); + + final var methodInvokerByPost = + serviceRegistry.lookupInvoker( + ServiceMessage.builder() + .header(HEADER_REQUEST_METHOD, "POST") + .qualifier("v1/service/echo/" + foo) + .build()); + assertNotNull(methodInvokerByPost); + } + } + + @Service("v1/service") + interface BadService { + + @RestMethod("GET") + @ServiceMethod("get/:foo") + Mono echo(); + + @RestMethod("GET") + @ServiceMethod("get/:foo") + Mono ping(); + } + + @Service("v1/service") + interface GoodService { + + @RestMethod("GET") + @ServiceMethod("echo/:foo") + Mono echo(); + + @RestMethod("POST") + @ServiceMethod("echo/:foo") + Mono ping(); + } +} diff --git a/services/src/main/java/io/scalecube/services/ServiceScanner.java b/services/src/main/java/io/scalecube/services/ServiceScanner.java index 148a8c656..156b9a753 100644 --- a/services/src/main/java/io/scalecube/services/ServiceScanner.java +++ b/services/src/main/java/io/scalecube/services/ServiceScanner.java @@ -32,7 +32,7 @@ public static List toServiceRegistrations(ServiceInfo servi final var namespace = Reflect.serviceName(serviceInterface); final var methodDefinitions = - Reflect.serviceMethods(serviceInterface).values().stream() + Reflect.serviceMethods(serviceInterface).stream() .map( method -> { // validate method @@ -113,7 +113,7 @@ public static Collection collectServiceRoles( serviceInterface -> Reflect.serviceMethods(serviceInterface) .forEach( - (key, method) -> { + method -> { // validate method Reflect.validateMethodOrThrow(method); diff --git a/services/src/main/java/io/scalecube/services/registry/ServiceRegistryImpl.java b/services/src/main/java/io/scalecube/services/registry/ServiceRegistryImpl.java index 4d84a9f16..abb04dcdd 100644 --- a/services/src/main/java/io/scalecube/services/registry/ServiceRegistryImpl.java +++ b/services/src/main/java/io/scalecube/services/registry/ServiceRegistryImpl.java @@ -152,7 +152,7 @@ public void registerService( serviceInterface -> Reflect.serviceMethods(serviceInterface) .forEach( - (key, method) -> { + method -> { // validate method Reflect.validateMethodOrThrow(method); From c00b7044e36e707e63ec8e3c3f13049bd377a8b2 Mon Sep 17 00:00:00 2001 From: artem-v Date: Mon, 17 Nov 2025 17:12:55 +0200 Subject: [PATCH 2/2] Enhanced service registration for RESTful services --- .../java/io/scalecube/services/Reflect.java | 26 +-- .../rest/RestServiceDefinitionTest.java | 87 ---------- .../gateway/rest/ServiceRegistrationTest.java | 162 ++++++++++++++++++ 3 files changed, 165 insertions(+), 110 deletions(-) delete mode 100644 services-gateway/src/test/java/io/scalecube/services/gateway/rest/RestServiceDefinitionTest.java create mode 100644 services-gateway/src/test/java/io/scalecube/services/gateway/rest/ServiceRegistrationTest.java diff --git a/services-api/src/main/java/io/scalecube/services/Reflect.java b/services-api/src/main/java/io/scalecube/services/Reflect.java index b40341a8a..854efd7fb 100644 --- a/services-api/src/main/java/io/scalecube/services/Reflect.java +++ b/services-api/src/main/java/io/scalecube/services/Reflect.java @@ -25,7 +25,6 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; -import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; import org.reactivestreams.Publisher; @@ -221,28 +220,9 @@ private static Map transformArrayToMap(Tag[] array) { * @return service name */ public static Collection serviceMethods(Class serviceInterface) { - final var methodList = - Arrays.stream(serviceInterface.getMethods()) - .filter(method -> method.isAnnotationPresent(ServiceMethod.class)) - .toList(); - - //noinspection unused - final var collect = - methodList.stream() - .collect( - Collectors.toMap( - method -> - String.join(":", Reflect.methodName(method), Reflect.restMethod(method)), - Function.identity(), - (method, duplicate) -> { - throw new IllegalArgumentException( - "Duplicate method found for method: " - + method - + ", duplicate: " - + duplicate); - })); - - return methodList; + return Arrays.stream(serviceInterface.getMethods()) + .filter(method -> method.isAnnotationPresent(ServiceMethod.class)) + .toList(); } /** diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/rest/RestServiceDefinitionTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/rest/RestServiceDefinitionTest.java deleted file mode 100644 index 5a502f8a1..000000000 --- a/services-gateway/src/test/java/io/scalecube/services/gateway/rest/RestServiceDefinitionTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package io.scalecube.services.gateway.rest; - -import static io.scalecube.services.api.ServiceMessage.HEADER_REQUEST_METHOD; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.fail; -import static org.mockito.Mockito.mock; - -import io.scalecube.services.Microservices; -import io.scalecube.services.Microservices.Context; -import io.scalecube.services.annotations.RestMethod; -import io.scalecube.services.annotations.Service; -import io.scalecube.services.annotations.ServiceMethod; -import io.scalecube.services.api.ServiceMessage; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; - -public class RestServiceDefinitionTest { - - @Test - void registerInvalidService() { - try { - Microservices.start(new Context().services(mock(BadService.class))); - fail("Expected exception"); - } catch (Exception e) { - assertInstanceOf(IllegalArgumentException.class, e, e::getMessage); - assertThat(e.getMessage(), Matchers.startsWith("Duplicate method found for method")); - } - } - - @Test - void registerValidService() { - try (final var microservices = - Microservices.start(new Context().services(mock(GoodService.class)))) { - final var serviceRegistry = microservices.serviceRegistry(); - - final var foo = System.nanoTime(); - final var methodInvokerWithoutRestMethod = - serviceRegistry.lookupInvoker( - ServiceMessage.builder().qualifier("v1/service/echo/" + foo).build()); - assertNull(methodInvokerWithoutRestMethod); - - final var methodInvokerByGet = - serviceRegistry.lookupInvoker( - ServiceMessage.builder() - .header(HEADER_REQUEST_METHOD, "GET") - .qualifier("v1/service/echo/" + foo) - .build()); - assertNotNull(methodInvokerByGet); - - final var methodInvokerByPost = - serviceRegistry.lookupInvoker( - ServiceMessage.builder() - .header(HEADER_REQUEST_METHOD, "POST") - .qualifier("v1/service/echo/" + foo) - .build()); - assertNotNull(methodInvokerByPost); - } - } - - @Service("v1/service") - interface BadService { - - @RestMethod("GET") - @ServiceMethod("get/:foo") - Mono echo(); - - @RestMethod("GET") - @ServiceMethod("get/:foo") - Mono ping(); - } - - @Service("v1/service") - interface GoodService { - - @RestMethod("GET") - @ServiceMethod("echo/:foo") - Mono echo(); - - @RestMethod("POST") - @ServiceMethod("echo/:foo") - Mono ping(); - } -} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/rest/ServiceRegistrationTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/rest/ServiceRegistrationTest.java new file mode 100644 index 000000000..379e99ce4 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/rest/ServiceRegistrationTest.java @@ -0,0 +1,162 @@ +package io.scalecube.services.gateway.rest; + +import static io.scalecube.services.api.ServiceMessage.HEADER_REQUEST_METHOD; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.mock; + +import io.scalecube.services.Microservices; +import io.scalecube.services.Microservices.Context; +import io.scalecube.services.annotations.RestMethod; +import io.scalecube.services.annotations.Service; +import io.scalecube.services.annotations.ServiceMethod; +import io.scalecube.services.api.ServiceMessage; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; + +public class ServiceRegistrationTest { + + @ParameterizedTest + @ValueSource( + classes = { + EchoService.class, + GoodRestService.class, + CreateRestService.class, + UpdateRestService.class + }) + void registerDuplicateService(Class serviceInterface) { + try (final var microservices = + Microservices.start( + new Context().services(mock(serviceInterface), mock(serviceInterface)))) { + fail("Expected exception"); + } catch (Exception e) { + assertInstanceOf(IllegalStateException.class, e, e::getMessage); + assertThat(e.getMessage(), Matchers.startsWith("MethodInvoker already exists")); + } + } + + @Test + void registerInvalidRestService() { + try (final var microservices = + Microservices.start(new Context().services(mock(BadRestService.class)))) { + fail("Expected exception"); + } catch (Exception e) { + assertInstanceOf(IllegalStateException.class, e, e::getMessage); + assertThat(e.getMessage(), Matchers.startsWith("MethodInvoker already exists")); + } + } + + @Test + void registerSingleValidRestService() { + try (final var microservices = + Microservices.start(new Context().services(mock(GoodRestService.class)))) { + final var serviceRegistry = microservices.serviceRegistry(); + + final var foo = System.nanoTime(); + final var methodInvokerWithoutRestMethod = + serviceRegistry.lookupInvoker( + ServiceMessage.builder().qualifier("v1/service/echo/" + foo).build()); + assertNull(methodInvokerWithoutRestMethod); + + final var methodInvokerByGet = + serviceRegistry.lookupInvoker( + ServiceMessage.builder() + .header(HEADER_REQUEST_METHOD, "GET") + .qualifier("v1/service/echo/" + foo) + .build()); + assertNotNull(methodInvokerByGet); + + final var methodInvokerByPost = + serviceRegistry.lookupInvoker( + ServiceMessage.builder() + .header(HEADER_REQUEST_METHOD, "POST") + .qualifier("v1/service/echo/" + foo) + .build()); + assertNotNull(methodInvokerByPost); + } + } + + @Test + void registerMultipleValidRestServices() { + try (final var microservices = + Microservices.start( + new Context().services(mock(CreateRestService.class), mock(UpdateRestService.class)))) { + final var serviceRegistry = microservices.serviceRegistry(); + + final var foo = System.nanoTime(); + final var methodInvokerWithoutRestMethod = + serviceRegistry.lookupInvoker( + ServiceMessage.builder().qualifier("v1/service/account/" + foo).build()); + assertNull(methodInvokerWithoutRestMethod); + + final var methodInvokerByPost = + serviceRegistry.lookupInvoker( + ServiceMessage.builder() + .header(HEADER_REQUEST_METHOD, "POST") + .qualifier("v1/service/account/" + foo) + .build()); + assertNotNull(methodInvokerByPost); + + final var methodInvokerByPut = + serviceRegistry.lookupInvoker( + ServiceMessage.builder() + .header(HEADER_REQUEST_METHOD, "PUT") + .qualifier("v1/service/account/" + foo) + .build()); + assertNotNull(methodInvokerByPut); + } + } + + @Service("v1/service") + interface EchoService { + + @ServiceMethod("get/:foo") + Mono echo(); + } + + @Service("v1/service") + interface BadRestService { + + @RestMethod("GET") + @ServiceMethod("get/:foo") + Mono echo(); + + @RestMethod("GET") + @ServiceMethod("get/:foo") + Mono ping(); + } + + @Service("v1/service") + interface GoodRestService { + + @RestMethod("GET") + @ServiceMethod("echo/:foo") + Mono echo(); + + @RestMethod("POST") + @ServiceMethod("echo/:foo") + Mono ping(); + } + + @Service("v1/service") + interface CreateRestService { + + @RestMethod("POST") + @ServiceMethod("account/:foo") + Mono account(); + } + + @Service("v1/service") + interface UpdateRestService { + + @RestMethod("PUT") + @ServiceMethod("account/:foo") + Mono account(); + } +}