diff --git a/pom.xml b/pom.xml index d32e9dc81..c5802f940 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,7 @@ - + 4.0.0 @@ -85,6 +87,7 @@ services services-api services-transport-parent + services-gateway services-discovery services-security services-examples diff --git a/services-api/pom.xml b/services-api/pom.xml index 3914a76ab..913bd59a1 100644 --- a/services-api/pom.xml +++ b/services-api/pom.xml @@ -1,5 +1,7 @@ - + 4.0.0 diff --git a/services-discovery/pom.xml b/services-discovery/pom.xml index e0e5cbade..db7a680ea 100644 --- a/services-discovery/pom.xml +++ b/services-discovery/pom.xml @@ -1,4 +1,6 @@ - + 4.0.0 diff --git a/services-examples/pom.xml b/services-examples/pom.xml index 15f21b7b5..7c2bbbeb7 100644 --- a/services-examples/pom.xml +++ b/services-examples/pom.xml @@ -1,5 +1,7 @@ - + 4.0.0 diff --git a/services-examples/src/main/java/io/scalecube/services/examples/gateway/HttpGatewayExample.java b/services-examples/src/main/java/io/scalecube/services/examples/gateway/HttpGatewayExample.java new file mode 100644 index 000000000..6e50cf75d --- /dev/null +++ b/services-examples/src/main/java/io/scalecube/services/examples/gateway/HttpGatewayExample.java @@ -0,0 +1,51 @@ +package io.scalecube.services.examples.gateway; + +import io.scalecube.net.Address; +import io.scalecube.services.gateway.Gateway; +import io.scalecube.services.gateway.GatewayOptions; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.concurrent.ThreadLocalRandom; +import reactor.core.publisher.Mono; + +public class HttpGatewayExample implements Gateway { + + private final GatewayOptions options; + private final InetSocketAddress address; + + public HttpGatewayExample(GatewayOptions options) { + this.options = options; + this.address = new InetSocketAddress(options.port()); + } + + @Override + public String id() { + return options.id(); + } + + @Override + public Address address() { + return Address.create(address.getHostString(), address.getPort()); + } + + @Override + public Mono start() { + return Mono.defer( + () -> { + System.out.println("Starting HTTP gateway..."); + + return Mono.delay(Duration.ofMillis(ThreadLocalRandom.current().nextInt(100, 500))) + .map(tick -> this) + .doOnSuccess(gw -> System.out.println("HTTP gateway is started on " + gw.address)); + }); + } + + @Override + public Mono stop() { + return Mono.defer( + () -> { + System.out.println("Stopping HTTP gateway..."); + return Mono.empty(); + }); + } +} diff --git a/services-examples/src/main/java/io/scalecube/services/examples/gateway/WebsocketGatewayExample.java b/services-examples/src/main/java/io/scalecube/services/examples/gateway/WebsocketGatewayExample.java new file mode 100644 index 000000000..82b6dcd3f --- /dev/null +++ b/services-examples/src/main/java/io/scalecube/services/examples/gateway/WebsocketGatewayExample.java @@ -0,0 +1,51 @@ +package io.scalecube.services.examples.gateway; + +import io.scalecube.net.Address; +import io.scalecube.services.gateway.Gateway; +import io.scalecube.services.gateway.GatewayOptions; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.concurrent.ThreadLocalRandom; +import reactor.core.publisher.Mono; + +public class WebsocketGatewayExample implements Gateway { + + private final GatewayOptions options; + private final InetSocketAddress address; + + public WebsocketGatewayExample(GatewayOptions options) { + this.options = options; + this.address = new InetSocketAddress(options.port()); + } + + @Override + public String id() { + return options.id(); + } + + @Override + public Address address() { + return Address.create(address.getHostString(), address.getPort()); + } + + @Override + public Mono start() { + return Mono.defer( + () -> { + System.out.println("Starting WS gateway..."); + + return Mono.delay(Duration.ofMillis(ThreadLocalRandom.current().nextInt(100, 500))) + .map(tick -> this) + .doOnSuccess(gw -> System.out.println("WS gateway is started on " + gw.address)); + }); + } + + @Override + public Mono stop() { + return Mono.defer( + () -> { + System.out.println("Stopping WS gateway..."); + return Mono.empty(); + }); + } +} diff --git a/services-gateway/pom.xml b/services-gateway/pom.xml new file mode 100644 index 000000000..f7467843e --- /dev/null +++ b/services-gateway/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + + io.scalecube + scalecube-services-parent + 2.10.26-SNAPSHOT + + + services-gateway + jar + + + + io.scalecube + scalecube-services + ${project.parent.version} + + + + io.projectreactor.netty + reactor-netty + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-databind + + + + org.jctools + jctools-core + + + org.slf4j + slf4j-api + + + + + io.scalecube + scalecube-services-examples + ${project.parent.version} + test + + + io.scalecube + scalecube-services-discovery + ${project.parent.version} + test + + + io.scalecube + scalecube-services-transport-rsocket + ${project.parent.version} + test + + + io.scalecube + scalecube-services-transport-jackson + ${project.parent.version} + test + + + org.apache.logging.log4j + log4j-slf4j-impl + test + + + org.apache.logging.log4j + log4j-core + test + + + com.lmax + disruptor + test + + + + diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/GatewaySession.java b/services-gateway/src/main/java/io/scalecube/services/gateway/GatewaySession.java new file mode 100644 index 000000000..aa3b3d95a --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/GatewaySession.java @@ -0,0 +1,20 @@ +package io.scalecube.services.gateway; + +import java.util.Map; + +public interface GatewaySession { + + /** + * Session id representation to be unique per client session. + * + * @return session id + */ + long sessionId(); + + /** + * Returns headers associated with session. + * + * @return headers map + */ + Map headers(); +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/GatewaySessionHandler.java b/services-gateway/src/main/java/io/scalecube/services/gateway/GatewaySessionHandler.java new file mode 100644 index 000000000..650894d59 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/GatewaySessionHandler.java @@ -0,0 +1,110 @@ +package io.scalecube.services.gateway; + +import io.netty.buffer.ByteBuf; +import io.scalecube.services.api.ServiceMessage; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; + +public interface GatewaySessionHandler { + + Logger LOGGER = LoggerFactory.getLogger(GatewaySessionHandler.class); + + GatewaySessionHandler DEFAULT_INSTANCE = new GatewaySessionHandler() {}; + + /** + * Message mapper function. + * + * @param session webscoket session (not null) + * @param message request message (not null) + * @return message + */ + default ServiceMessage mapMessage( + GatewaySession session, ServiceMessage message, Context context) { + return message; + } + + /** + * Request mapper function. + * + * @param session session + * @param byteBuf request buffer + * @param context subscriber context + * @return context + */ + default Context onRequest(GatewaySession session, ByteBuf byteBuf, Context context) { + return context; + } + + /** + * On response handler. + * + * @param session session + * @param byteBuf response buffer + * @param message response message + * @param context subscriber context + */ + default void onResponse( + GatewaySession session, ByteBuf byteBuf, ServiceMessage message, Context context) { + // no-op + } + + /** + * Error handler function. + * + * @param session webscoket session (not null) + * @param throwable an exception that occurred (not null) + * @param context subscriber context + */ + default void onError(GatewaySession session, Throwable throwable, Context context) { + LOGGER.error( + "Exception occurred on session: {}, on context: {}, cause:", + session.sessionId(), + context, + throwable); + } + + /** + * Error handler function. + * + * @param session webscoket session (not null) + * @param throwable an exception that occurred (not null) + */ + default void onSessionError(GatewaySession session, Throwable throwable) { + LOGGER.error("Exception occurred on session: {}, cause:", session.sessionId(), throwable); + } + + /** + * On connection open handler. + * + * @param sessionId session id + * @param headers connection/session headers + * @return mono result + */ + default Mono onConnectionOpen(long sessionId, Map headers) { + return Mono.fromRunnable( + () -> + LOGGER.debug( + "Connection opened, sessionId: {}, headers({})", sessionId, headers.size())); + } + + /** + * On session open handler. + * + * @param session websocket session (not null) + */ + default void onSessionOpen(GatewaySession session) { + LOGGER.info("Session opened: {}", session); + } + + /** + * On session close handler. + * + * @param session websocket session (not null) + */ + default void onSessionClose(GatewaySession session) { + LOGGER.info("Session closed: {}", session); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/GatewayTemplate.java b/services-gateway/src/main/java/io/scalecube/services/gateway/GatewayTemplate.java new file mode 100644 index 000000000..dbc5af217 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/GatewayTemplate.java @@ -0,0 +1,82 @@ +package io.scalecube.services.gateway; + +import java.net.InetSocketAddress; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServer; +import reactor.netty.resources.LoopResources; + +public abstract class GatewayTemplate implements Gateway { + + private static final Logger LOGGER = LoggerFactory.getLogger(GatewayTemplate.class); + + protected final GatewayOptions options; + + protected GatewayTemplate(GatewayOptions options) { + this.options = + new GatewayOptions() + .id(options.id()) + .port(options.port()) + .workerPool(options.workerPool()) + .call(options.call()); + } + + @Override + public final String id() { + return options.id(); + } + + /** + * Builds generic http server with given parameters. + * + * @param loopResources loop resources + * @param port listen port + * @return http server + */ + protected HttpServer prepareHttpServer(LoopResources loopResources, int port) { + return HttpServer.create() + .tcpConfiguration( + tcpServer -> { + if (loopResources != null) { + tcpServer = tcpServer.runOn(loopResources); + } + return tcpServer.bindAddress(() -> new InetSocketAddress(port)); + }); + } + + /** + * Shutting down loopResources if it's not null. + * + * @return mono handle + */ + protected final Mono shutdownLoopResources(LoopResources loopResources) { + return Mono.defer( + () -> { + if (loopResources == null) { + return Mono.empty(); + } + return loopResources + .disposeLater() + .doOnError(e -> LOGGER.warn("Failed to close loopResources: " + e)); + }); + } + + /** + * Shutting down server of type {@link DisposableServer} if it's not null. + * + * @param server server + * @return mono hanle + */ + protected final Mono shutdownServer(DisposableServer server) { + return Mono.defer( + () -> { + if (server == null) { + return Mono.empty(); + } + server.dispose(); + return server.onDispose().doOnError(e -> LOGGER.warn("Failed to close server: " + e)); + }); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/ReferenceCountUtil.java b/services-gateway/src/main/java/io/scalecube/services/gateway/ReferenceCountUtil.java new file mode 100644 index 000000000..1c534365e --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/ReferenceCountUtil.java @@ -0,0 +1,31 @@ +package io.scalecube.services.gateway; + +import io.netty.util.ReferenceCounted; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class ReferenceCountUtil { + + private static final Logger LOGGER = LoggerFactory.getLogger(ReferenceCountUtil.class); + + private ReferenceCountUtil() { + // Do not instantiate + } + + /** + * Try to release input object iff it's instance is of {@link ReferenceCounted} type and its + * refCount greater than zero. + * + * @return true if msg release taken place + */ + public static boolean safestRelease(Object msg) { + try { + return (msg instanceof ReferenceCounted) + && ((ReferenceCounted) msg).refCnt() > 0 + && ((ReferenceCounted) msg).release(); + } catch (Throwable t) { + LOGGER.warn("Failed to release reference counted object: {}, cause: {}", msg, t.toString()); + return false; + } + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/http/HttpGateway.java b/services-gateway/src/main/java/io/scalecube/services/gateway/http/HttpGateway.java new file mode 100644 index 000000000..e0516599f --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/http/HttpGateway.java @@ -0,0 +1,172 @@ +package io.scalecube.services.gateway.http; + +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.cors.CorsConfig; +import io.netty.handler.codec.http.cors.CorsConfigBuilder; +import io.netty.handler.codec.http.cors.CorsHandler; +import io.scalecube.net.Address; +import io.scalecube.services.exceptions.DefaultErrorMapper; +import io.scalecube.services.exceptions.ServiceProviderErrorMapper; +import io.scalecube.services.gateway.Gateway; +import io.scalecube.services.gateway.GatewayOptions; +import io.scalecube.services.gateway.GatewayTemplate; +import java.net.InetSocketAddress; +import java.util.Map.Entry; +import java.util.StringJoiner; +import java.util.function.UnaryOperator; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServer; +import reactor.netty.resources.LoopResources; + +public class HttpGateway extends GatewayTemplate { + + private final ServiceProviderErrorMapper errorMapper; + + private DisposableServer server; + private LoopResources loopResources; + + private boolean corsEnabled = false; + private CorsConfigBuilder corsConfigBuilder = + CorsConfigBuilder.forAnyOrigin() + .allowNullOrigin() + .maxAge(3600) + .allowedRequestMethods(HttpMethod.POST); + + public HttpGateway(GatewayOptions options) { + this(options, DefaultErrorMapper.INSTANCE); + } + + public HttpGateway(GatewayOptions options, ServiceProviderErrorMapper errorMapper) { + super(options); + this.errorMapper = errorMapper; + } + + private HttpGateway(HttpGateway other) { + super(other.options); + this.server = other.server; + this.loopResources = other.loopResources; + this.corsEnabled = other.corsEnabled; + this.corsConfigBuilder = copy(other.corsConfigBuilder); + this.errorMapper = other.errorMapper; + } + + /** + * CORS enable. + * + * @param corsEnabled if set to true. + * @return HttpGateway with CORS settings. + */ + public HttpGateway corsEnabled(boolean corsEnabled) { + HttpGateway g = new HttpGateway(this); + g.corsEnabled = corsEnabled; + return g; + } + + /** + * Configure CORS with options. + * + * @param op for CORS. + * @return HttpGateway with CORS settings. + */ + public HttpGateway corsConfig(UnaryOperator op) { + HttpGateway g = new HttpGateway(this); + g.corsConfigBuilder = copy(op.apply(g.corsConfigBuilder)); + return g; + } + + private CorsConfigBuilder copy(CorsConfigBuilder other) { + CorsConfig config = other.build(); + CorsConfigBuilder corsConfigBuilder; + if (config.isAnyOriginSupported()) { + corsConfigBuilder = CorsConfigBuilder.forAnyOrigin(); + } else { + corsConfigBuilder = CorsConfigBuilder.forOrigins(config.origins().toArray(new String[0])); + } + + if (!config.isCorsSupportEnabled()) { + corsConfigBuilder.disable(); + } + + corsConfigBuilder + .exposeHeaders(config.exposedHeaders().toArray(new String[0])) + .allowedRequestHeaders(config.allowedRequestHeaders().toArray(new String[0])) + .allowedRequestMethods(config.allowedRequestMethods().toArray(new HttpMethod[0])) + .maxAge(config.maxAge()); + + for (Entry header : config.preflightResponseHeaders()) { + corsConfigBuilder.preflightResponseHeader(header.getKey(), header.getValue()); + } + + if (config.isShortCircuit()) { + corsConfigBuilder.shortCircuit(); + } + + if (config.isNullOriginAllowed()) { + corsConfigBuilder.allowNullOrigin(); + } + + if (config.isCredentialsAllowed()) { + corsConfigBuilder.allowCredentials(); + } + + return corsConfigBuilder; + } + + @Override + public Mono start() { + return Mono.defer( + () -> { + HttpGatewayAcceptor acceptor = new HttpGatewayAcceptor(options.call(), errorMapper); + + loopResources = LoopResources.create("http-gateway"); + + return prepareHttpServer(loopResources, options.port()) + .handle(acceptor) + .bind() + .doOnSuccess(server -> this.server = server) + .thenReturn(this); + }); + } + + @Override + public Address address() { + InetSocketAddress address = (InetSocketAddress) server.address(); + return Address.create(address.getHostString(), address.getPort()); + } + + @Override + public Mono stop() { + return Flux.concatDelayError(shutdownServer(server), shutdownLoopResources(loopResources)) + .then(); + } + + protected HttpServer prepareHttpServer(LoopResources loopResources, int port) { + HttpServer httpServer = HttpServer.create(); + + if (loopResources != null) { + httpServer = httpServer.runOn(loopResources); + } + + return httpServer + .bindAddress(() -> new InetSocketAddress(port)) + .doOnConnection( + connection -> { + if (corsEnabled) { + connection.addHandlerLast(new CorsHandler(corsConfigBuilder.build())); + } + }); + } + + @Override + public String toString() { + return new StringJoiner(", ", HttpGateway.class.getSimpleName() + "[", "]") + .add("server=" + server) + .add("loopResources=" + loopResources) + .add("corsEnabled=" + corsEnabled) + .add("corsConfigBuilder=" + corsConfigBuilder) + .add("options=" + options) + .toString(); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/http/HttpGatewayAcceptor.java b/services-gateway/src/main/java/io/scalecube/services/gateway/http/HttpGatewayAcceptor.java new file mode 100644 index 000000000..115623fa8 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/http/HttpGatewayAcceptor.java @@ -0,0 +1,146 @@ +package io.scalecube.services.gateway.http; + +import static io.netty.handler.codec.http.HttpHeaderNames.ALLOW; +import static io.netty.handler.codec.http.HttpMethod.POST; +import static io.netty.handler.codec.http.HttpResponseStatus.METHOD_NOT_ALLOWED; +import static io.netty.handler.codec.http.HttpResponseStatus.NO_CONTENT; +import static io.netty.handler.codec.http.HttpResponseStatus.OK; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.ByteBufOutputStream; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.scalecube.services.ServiceCall; +import io.scalecube.services.api.ErrorData; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.exceptions.DefaultErrorMapper; +import io.scalecube.services.exceptions.ServiceProviderErrorMapper; +import io.scalecube.services.gateway.ReferenceCountUtil; +import io.scalecube.services.transport.api.DataCodec; +import java.util.function.BiFunction; +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.netty.ByteBufMono; +import reactor.netty.http.server.HttpServerRequest; +import reactor.netty.http.server.HttpServerResponse; + +public class HttpGatewayAcceptor + implements BiFunction> { + + private static final Logger LOGGER = LoggerFactory.getLogger(HttpGatewayAcceptor.class); + + private static final String ERROR_NAMESPACE = "io.scalecube.services.error"; + + private final ServiceCall serviceCall; + private final ServiceProviderErrorMapper errorMapper; + + HttpGatewayAcceptor(ServiceCall serviceCall) { + this(serviceCall, DefaultErrorMapper.INSTANCE); + } + + HttpGatewayAcceptor(ServiceCall serviceCall, ServiceProviderErrorMapper errorMapper) { + this.serviceCall = serviceCall; + this.errorMapper = errorMapper; + } + + @Override + public Publisher apply(HttpServerRequest httpRequest, HttpServerResponse httpResponse) { + LOGGER.debug( + "Accepted request: {}, headers: {}, params: {}", + httpRequest, + httpRequest.requestHeaders(), + httpRequest.params()); + + if (httpRequest.method() != POST) { + LOGGER.error("Unsupported HTTP method. Expected POST, actual {}", httpRequest.method()); + return methodNotAllowed(httpResponse); + } + + return httpRequest + .receive() + .aggregate() + .switchIfEmpty(Mono.defer(() -> ByteBufMono.just(Unpooled.EMPTY_BUFFER))) + .map(ByteBuf::retain) + .flatMap(content -> handleRequest(content, httpRequest, httpResponse)) + .onErrorResume(t -> error(httpResponse, errorMapper.toMessage(ERROR_NAMESPACE, t))); + } + + private Mono handleRequest( + ByteBuf content, HttpServerRequest httpRequest, HttpServerResponse httpResponse) { + + ServiceMessage request = + ServiceMessage.builder().qualifier(getQualifier(httpRequest)).data(content).build(); + + return serviceCall + .requestOne(request) + .switchIfEmpty(Mono.defer(() -> emptyMessage(httpRequest))) + .doOnError(th -> releaseRequestOnError(request)) + .flatMap( + response -> + response.isError() // check error + ? error(httpResponse, response) + : response.hasData() // check data + ? ok(httpResponse, response) + : noContent(httpResponse)); + } + + private Mono emptyMessage(HttpServerRequest httpRequest) { + return Mono.just(ServiceMessage.builder().qualifier(getQualifier(httpRequest)).build()); + } + + private static String getQualifier(HttpServerRequest httpRequest) { + return httpRequest.uri().substring(1); + } + + private Publisher methodNotAllowed(HttpServerResponse httpResponse) { + return httpResponse.addHeader(ALLOW, POST.name()).status(METHOD_NOT_ALLOWED).send(); + } + + private Mono error(HttpServerResponse httpResponse, ServiceMessage response) { + int code = response.errorType(); + HttpResponseStatus status = HttpResponseStatus.valueOf(code); + + ByteBuf content = + response.hasData(ErrorData.class) + ? encodeData(response.data(), response.dataFormatOrDefault()) + : ((ByteBuf) response.data()); + + // send with publisher (defer buffer cleanup to netty) + return httpResponse.status(status).send(Mono.just(content)).then(); + } + + private Mono noContent(HttpServerResponse httpResponse) { + return httpResponse.status(NO_CONTENT).send(); + } + + private Mono ok(HttpServerResponse httpResponse, ServiceMessage response) { + ByteBuf content = + response.hasData(ByteBuf.class) + ? ((ByteBuf) response.data()) + : encodeData(response.data(), response.dataFormatOrDefault()); + + // send with publisher (defer buffer cleanup to netty) + return httpResponse.status(OK).send(Mono.just(content)).then(); + } + + private ByteBuf encodeData(Object data, String dataFormat) { + ByteBuf byteBuf = ByteBufAllocator.DEFAULT.buffer(); + + try { + DataCodec.getInstance(dataFormat).encode(new ByteBufOutputStream(byteBuf), data); + } catch (Throwable t) { + ReferenceCountUtil.safestRelease(byteBuf); + LOGGER.error("Failed to encode data: {}", data, t); + return Unpooled.EMPTY_BUFFER; + } + + return byteBuf; + } + + private void releaseRequestOnError(ServiceMessage request) { + ReferenceCountUtil.safestRelease(request.data()); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClient.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClient.java new file mode 100644 index 000000000..eac90f453 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClient.java @@ -0,0 +1,46 @@ +package io.scalecube.services.gateway.transport; + +import io.scalecube.services.api.ServiceMessage; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface GatewayClient { + + /** + * Communication mode that gives single response to single request. + * + * @param request request message. + * @return Publisher that emits single response form remote server as it's ready. + */ + Mono requestResponse(ServiceMessage request); + + /** + * Communication mode that gives stream of responses to single request. + * + * @param request request message. + * @return Publisher that emits responses from remote server. + */ + Flux requestStream(ServiceMessage request); + + /** + * Communication mode that gives stream of responses to stream of requests. + * + * @param requests request stream. + * @return Publisher that emits responses from remote server. + */ + Flux requestChannel(Flux requests); + + /** + * Initiate cleaning of underlying resources (if any) like closing websocket connection or rSocket + * session. Subsequent calls of requestOne() or requestMany() must issue new connection creation. + * Note that close is not the end of client lifecycle. + */ + void close(); + + /** + * Return close completion signal of the gateway client. + * + * @return close completion signal + */ + Mono onClose(); +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientChannel.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientChannel.java new file mode 100644 index 000000000..bb8c72c65 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientChannel.java @@ -0,0 +1,39 @@ +package io.scalecube.services.gateway.transport; + +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.transport.api.ClientChannel; +import java.lang.reflect.Type; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class GatewayClientChannel implements ClientChannel { + + private final GatewayClient gatewayClient; + + GatewayClientChannel(GatewayClient gatewayClient) { + this.gatewayClient = gatewayClient; + } + + @Override + public Mono requestResponse(ServiceMessage clientMessage, Type responseType) { + return gatewayClient + .requestResponse(clientMessage) + .map(msg -> ServiceMessageCodec.decodeData(msg, responseType)); + } + + @Override + public Flux requestStream(ServiceMessage clientMessage, Type responseType) { + return gatewayClient + .requestStream(clientMessage) + .map(msg -> ServiceMessageCodec.decodeData(msg, responseType)); + } + + @Override + public Flux requestChannel( + Publisher clientMessageStream, Type responseType) { + return gatewayClient + .requestChannel(Flux.from(clientMessageStream)) + .map(msg -> ServiceMessageCodec.decodeData(msg, responseType)); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientCodec.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientCodec.java new file mode 100644 index 000000000..814f8bc95 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientCodec.java @@ -0,0 +1,42 @@ +package io.scalecube.services.gateway.transport; + +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.exceptions.MessageCodecException; +import java.lang.reflect.Type; + +/** + * Describes encoding/decoding operations for {@link ServiceMessage} to/from {@link T} type. + * + * @param represents source or result for decoding or encoding operations respectively + */ +public interface GatewayClientCodec { + + /** + * Data decoder function. + * + * @param message client message. + * @param dataType data type class. + * @return client message object. + * @throws MessageCodecException in case if data decoding fails. + */ + default ServiceMessage decodeData(ServiceMessage message, Type dataType) + throws MessageCodecException { + return ServiceMessageCodec.decodeData(message, dataType); + } + + /** + * Encodes {@link ServiceMessage} to {@link T} type. + * + * @param message client message to encode + * @return encoded message represented by {@link T} type + */ + T encode(ServiceMessage message); + + /** + * Decodes message represented by {@link T} type to {@link ServiceMessage} object. + * + * @param encodedMessage message to decode + * @return decoded message represented by {@link ServiceMessage} type + */ + ServiceMessage decode(T encodedMessage); +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientSettings.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientSettings.java new file mode 100644 index 000000000..c02acbc58 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientSettings.java @@ -0,0 +1,213 @@ +package io.scalecube.services.gateway.transport; + +import io.scalecube.net.Address; +import io.scalecube.services.exceptions.DefaultErrorMapper; +import io.scalecube.services.exceptions.ServiceClientErrorMapper; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import reactor.netty.tcp.SslProvider; + +public class GatewayClientSettings { + + private static final String DEFAULT_HOST = "localhost"; + private static final String DEFAULT_CONTENT_TYPE = "application/json"; + private static final Duration DEFAULT_KEEPALIVE_INTERVAL = Duration.ZERO; + + private final String host; + private final int port; + private final String contentType; + private final boolean followRedirect; + private final SslProvider sslProvider; + private final ServiceClientErrorMapper errorMapper; + private final Duration keepAliveInterval; + private final boolean wiretap; + private final Map headers; + + private GatewayClientSettings(Builder builder) { + this.host = builder.host; + this.port = builder.port; + this.contentType = builder.contentType; + this.followRedirect = builder.followRedirect; + this.sslProvider = builder.sslProvider; + this.errorMapper = builder.errorMapper; + this.keepAliveInterval = builder.keepAliveInterval; + this.wiretap = builder.wiretap; + this.headers = builder.headers; + } + + public String host() { + return host; + } + + public int port() { + return port; + } + + public String contentType() { + return this.contentType; + } + + public boolean followRedirect() { + return followRedirect; + } + + public SslProvider sslProvider() { + return sslProvider; + } + + public ServiceClientErrorMapper errorMapper() { + return errorMapper; + } + + public Duration keepAliveInterval() { + return this.keepAliveInterval; + } + + public boolean wiretap() { + return this.wiretap; + } + + public Map headers() { + return headers; + } + + public static Builder builder() { + return new Builder(); + } + + public static Builder from(GatewayClientSettings gatewayClientSettings) { + return new Builder(gatewayClientSettings); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("GatewayClientSettings{"); + sb.append("host='").append(host).append('\''); + sb.append(", port=").append(port); + sb.append(", contentType='").append(contentType).append('\''); + sb.append(", followRedirect=").append(followRedirect); + sb.append(", keepAliveInterval=").append(keepAliveInterval); + sb.append(", wiretap=").append(wiretap); + sb.append(", sslProvider=").append(sslProvider); + sb.append('}'); + return sb.toString(); + } + + public static class Builder { + + private String host = DEFAULT_HOST; + private int port; + private String contentType = DEFAULT_CONTENT_TYPE; + private boolean followRedirect = true; + private SslProvider sslProvider; + private ServiceClientErrorMapper errorMapper = DefaultErrorMapper.INSTANCE; + private Duration keepAliveInterval = DEFAULT_KEEPALIVE_INTERVAL; + private boolean wiretap = false; + private Map headers = Collections.emptyMap(); + + private Builder() {} + + private Builder(GatewayClientSettings originalSettings) { + this.host = originalSettings.host; + this.port = originalSettings.port; + this.contentType = originalSettings.contentType; + this.followRedirect = originalSettings.followRedirect; + this.sslProvider = originalSettings.sslProvider; + this.errorMapper = originalSettings.errorMapper; + this.keepAliveInterval = originalSettings.keepAliveInterval; + this.wiretap = originalSettings.wiretap; + this.headers = Collections.unmodifiableMap(new HashMap<>(originalSettings.headers)); + } + + public Builder host(String host) { + this.host = host; + return this; + } + + public Builder port(int port) { + this.port = port; + return this; + } + + public Builder address(Address address) { + return host(address.host()).port(address.port()); + } + + public Builder contentType(String contentType) { + this.contentType = contentType; + return this; + } + + /** + * Specifies is auto-redirect enabled for HTTP 301/302 status codes. Enabled by default. + * + * @param followRedirect if true auto-redirect is enabled, otherwise disabled + * @return builder + */ + public Builder followRedirect(boolean followRedirect) { + this.followRedirect = followRedirect; + return this; + } + + /** + * Use default SSL client provider. + * + * @return builder + */ + public Builder secure() { + this.sslProvider = SslProvider.defaultClientProvider(); + return this; + } + + /** + * Use specified SSL provider. + * + * @param sslProvider SSL provider + * @return builder + */ + public Builder secure(SslProvider sslProvider) { + this.sslProvider = sslProvider; + return this; + } + + /** + * Keepalive interval. If client's channel doesn't have any activity at channel during this + * period, it will send a keepalive message to the server. + * + * @param keepAliveInterval keepalive interval. + * @return builder + */ + public Builder keepAliveInterval(Duration keepAliveInterval) { + this.keepAliveInterval = keepAliveInterval; + return this; + } + + /** + * Specifies whether to enaple 'wiretap' option for connections. That logs full netty traffic. + * Default is {@code false} + * + * @param wiretap whether to enable 'wiretap' handler at connection. Default - false + * @return builder + */ + public Builder wiretap(boolean wiretap) { + this.wiretap = wiretap; + return this; + } + + public Builder errorMapper(ServiceClientErrorMapper errorMapper) { + this.errorMapper = errorMapper; + return this; + } + + public Builder headers(Map headers) { + this.headers = Collections.unmodifiableMap(new HashMap<>(headers)); + return this; + } + + public GatewayClientSettings build() { + return new GatewayClientSettings(this); + } + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientTransport.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientTransport.java new file mode 100644 index 000000000..c8b9e6010 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientTransport.java @@ -0,0 +1,19 @@ +package io.scalecube.services.gateway.transport; + +import io.scalecube.services.ServiceReference; +import io.scalecube.services.transport.api.ClientChannel; +import io.scalecube.services.transport.api.ClientTransport; + +public class GatewayClientTransport implements ClientTransport { + + private final GatewayClient gatewayClient; + + public GatewayClientTransport(GatewayClient gatewayClient) { + this.gatewayClient = gatewayClient; + } + + @Override + public ClientChannel create(ServiceReference serviceReference) { + return new GatewayClientChannel(gatewayClient); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientTransports.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientTransports.java new file mode 100644 index 000000000..f5e7d583d --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/GatewayClientTransports.java @@ -0,0 +1,48 @@ +package io.scalecube.services.gateway.transport; + +import io.scalecube.services.gateway.transport.http.HttpGatewayClient; +import io.scalecube.services.gateway.transport.http.HttpGatewayClientCodec; +import io.scalecube.services.gateway.transport.websocket.WebsocketGatewayClient; +import io.scalecube.services.gateway.transport.websocket.WebsocketGatewayClientCodec; +import io.scalecube.services.transport.api.ClientTransport; +import io.scalecube.services.transport.api.DataCodec; +import java.util.function.Function; + +public class GatewayClientTransports { + + private static final String CONTENT_TYPE = "application/json"; + + public static final WebsocketGatewayClientCodec WEBSOCKET_CLIENT_CODEC = + new WebsocketGatewayClientCodec(); + + public static final HttpGatewayClientCodec HTTP_CLIENT_CODEC = + new HttpGatewayClientCodec(DataCodec.getInstance(CONTENT_TYPE)); + + private GatewayClientTransports() { + // utils + } + + /** + * ClientTransport that is capable of communicating with Gateway over websocket. + * + * @param cs client settings for gateway client transport + * @return client transport + */ + public static ClientTransport websocketGatewayClientTransport(GatewayClientSettings cs) { + final Function function = + settings -> new WebsocketGatewayClient(settings, WEBSOCKET_CLIENT_CODEC); + return new GatewayClientTransport(function.apply(cs)); + } + + /** + * ClientTransport that is capable of communicating with Gateway over http. + * + * @param cs client settings for gateway client transport + * @return client transport + */ + public static ClientTransport httpGatewayClientTransport(GatewayClientSettings cs) { + final Function function = + settings -> new HttpGatewayClient(settings, HTTP_CLIENT_CODEC); + return new GatewayClientTransport(function.apply(cs)); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/ServiceMessageCodec.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/ServiceMessageCodec.java new file mode 100644 index 000000000..8661df8fc --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/ServiceMessageCodec.java @@ -0,0 +1,46 @@ +package io.scalecube.services.gateway.transport; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufInputStream; +import io.scalecube.services.api.ErrorData; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.exceptions.MessageCodecException; +import io.scalecube.services.transport.api.DataCodec; +import java.lang.reflect.Type; + +public final class ServiceMessageCodec { + + private ServiceMessageCodec() {} + + /** + * Decode message. + * + * @param message the original message (with {@link ByteBuf} data) + * @param dataType the type of the data. + * @return a new Service message that upon {@link ServiceMessage#data()} returns the actual data + * (of type data type) + * @throws MessageCodecException when decode fails + */ + public static ServiceMessage decodeData(ServiceMessage message, Type dataType) + throws MessageCodecException { + if (dataType == null + || !message.hasData(ByteBuf.class) + || ((ByteBuf) message.data()).readableBytes() == 0 + || ByteBuf.class == dataType) { + return message; + } + + Object data; + Type targetType = message.isError() ? ErrorData.class : dataType; + + ByteBuf dataBuffer = message.data(); + try (ByteBufInputStream inputStream = new ByteBufInputStream(dataBuffer, true)) { + DataCodec dataCodec = DataCodec.getInstance(message.dataFormatOrDefault()); + data = dataCodec.decode(inputStream, targetType); + } catch (Throwable ex) { + throw new MessageCodecException("Failed to decode service message data", ex); + } + + return ServiceMessage.from(message).data(data).build(); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/StaticAddressRouter.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/StaticAddressRouter.java new file mode 100644 index 000000000..4979ff48c --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/StaticAddressRouter.java @@ -0,0 +1,38 @@ +package io.scalecube.services.gateway.transport; + +import io.scalecube.net.Address; +import io.scalecube.services.ServiceEndpoint; +import io.scalecube.services.ServiceMethodDefinition; +import io.scalecube.services.ServiceReference; +import io.scalecube.services.ServiceRegistration; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.registry.api.ServiceRegistry; +import io.scalecube.services.routing.Router; +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; + +/** Syntethic router for returning preconstructed static service reference with given address. */ +public class StaticAddressRouter implements Router { + + private final ServiceReference staticServiceReference; + + /** + * Constructor. + * + * @param address address + */ + public StaticAddressRouter(Address address) { + this.staticServiceReference = + new ServiceReference( + new ServiceMethodDefinition(UUID.randomUUID().toString()), + new ServiceRegistration( + UUID.randomUUID().toString(), Collections.emptyMap(), Collections.emptyList()), + ServiceEndpoint.builder().id(UUID.randomUUID().toString()).address(address).build()); + } + + @Override + public Optional route(ServiceRegistry serviceRegistry, ServiceMessage request) { + return Optional.of(staticServiceReference); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/http/HttpGatewayClient.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/http/HttpGatewayClient.java new file mode 100644 index 000000000..3363a7691 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/http/HttpGatewayClient.java @@ -0,0 +1,165 @@ +package io.scalecube.services.gateway.transport.http; + +import static io.scalecube.reactor.RetryNonSerializedEmitFailureHandler.RETRY_NON_SERIALIZED; + +import io.netty.buffer.ByteBuf; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.api.ServiceMessage.Builder; +import io.scalecube.services.gateway.transport.GatewayClient; +import io.scalecube.services.gateway.transport.GatewayClientCodec; +import io.scalecube.services.gateway.transport.GatewayClientSettings; +import java.util.function.BiFunction; +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.netty.NettyOutbound; +import reactor.netty.http.client.HttpClient; +import reactor.netty.http.client.HttpClientRequest; +import reactor.netty.http.client.HttpClientResponse; +import reactor.netty.resources.ConnectionProvider; +import reactor.netty.resources.LoopResources; + +public final class HttpGatewayClient implements GatewayClient { + + private static final Logger LOGGER = LoggerFactory.getLogger(HttpGatewayClient.class); + + private final GatewayClientCodec codec; + private final HttpClient httpClient; + private final LoopResources loopResources; + private final boolean ownsLoopResources; + + private final Sinks.One close = Sinks.one(); + private final Sinks.One onClose = Sinks.one(); + + /** + * Constructor. + * + * @param settings settings + * @param codec codec + */ + public HttpGatewayClient(GatewayClientSettings settings, GatewayClientCodec codec) { + this(settings, codec, LoopResources.create("http-gateway-client"), true); + } + + /** + * Constructor. + * + * @param settings settings + * @param codec codec + * @param loopResources loopResources + */ + public HttpGatewayClient( + GatewayClientSettings settings, + GatewayClientCodec codec, + LoopResources loopResources) { + this(settings, codec, loopResources, false); + } + + private HttpGatewayClient( + GatewayClientSettings settings, + GatewayClientCodec codec, + LoopResources loopResources, + boolean ownsLoopResources) { + + this.codec = codec; + this.loopResources = loopResources; + this.ownsLoopResources = ownsLoopResources; + + HttpClient httpClient = + HttpClient.create(ConnectionProvider.create("http-gateway-client")) + .headers(headers -> settings.headers().forEach(headers::add)) + .followRedirect(settings.followRedirect()) + .wiretap(settings.wiretap()) + .runOn(loopResources) + .host(settings.host()) + .port(settings.port()); + + if (settings.sslProvider() != null) { + httpClient = httpClient.secure(settings.sslProvider()); + } + + this.httpClient = httpClient; + + // Setup cleanup + close + .asMono() + .then(doClose()) + .doFinally(s -> onClose.emitEmpty(RETRY_NON_SERIALIZED)) + .doOnTerminate(() -> LOGGER.info("Closed HttpGatewayClient resources")) + .subscribe(null, ex -> LOGGER.warn("Exception occurred on HttpGatewayClient close: " + ex)); + } + + @Override + public Mono requestResponse(ServiceMessage request) { + return Mono.defer( + () -> { + BiFunction> sender = + (httpRequest, out) -> { + LOGGER.debug("Sending request {}", request); + // prepare request headers + request.headers().forEach(httpRequest::header); + // send with publisher (defer buffer cleanup to netty) + return out.sendObject(Mono.just(codec.encode(request))).then(); + }; + return httpClient + .post() + .uri("/" + request.qualifier()) + .send(sender) + .responseSingle( + (httpResponse, bbMono) -> + bbMono.map(ByteBuf::retain).map(content -> toMessage(httpResponse, content))); + }); + } + + @Override + public Flux requestStream(ServiceMessage request) { + return Flux.error( + new UnsupportedOperationException("requestStream is not supported by HTTP/1.x")); + } + + @Override + public Flux requestChannel(Flux requests) { + return Flux.error( + new UnsupportedOperationException("requestChannel is not supported by HTTP/1.x")); + } + + @Override + public void close() { + close.emitEmpty(RETRY_NON_SERIALIZED); + } + + @Override + public Mono onClose() { + return onClose.asMono(); + } + + private Mono doClose() { + return ownsLoopResources ? Mono.defer(loopResources::disposeLater) : Mono.empty(); + } + + private ServiceMessage toMessage(HttpClientResponse httpResponse, ByteBuf content) { + Builder builder = ServiceMessage.builder().qualifier(httpResponse.uri()).data(content); + + int httpCode = httpResponse.status().code(); + if (isError(httpCode)) { + builder.header(ServiceMessage.HEADER_ERROR_TYPE, String.valueOf(httpCode)); + } + + // prepare response headers + httpResponse + .responseHeaders() + .entries() + .forEach(entry -> builder.header(entry.getKey(), entry.getValue())); + ServiceMessage message = builder.build(); + + LOGGER.debug("Received response {}", message); + return message; + } + + private boolean isError(int httpCode) { + return httpCode >= 400 && httpCode <= 599; + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/http/HttpGatewayClientCodec.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/http/HttpGatewayClientCodec.java new file mode 100644 index 000000000..c0231825e --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/http/HttpGatewayClientCodec.java @@ -0,0 +1,54 @@ +package io.scalecube.services.gateway.transport.http; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.ByteBufOutputStream; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.exceptions.MessageCodecException; +import io.scalecube.services.gateway.ReferenceCountUtil; +import io.scalecube.services.gateway.transport.GatewayClientCodec; +import io.scalecube.services.transport.api.DataCodec; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class HttpGatewayClientCodec implements GatewayClientCodec { + + private static final Logger LOGGER = LoggerFactory.getLogger(HttpGatewayClientCodec.class); + + private final DataCodec dataCodec; + + /** + * Constructor for codec which encode/decode client message to/from {@link ByteBuf}. + * + * @param dataCodec data message codec. + */ + public HttpGatewayClientCodec(DataCodec dataCodec) { + this.dataCodec = dataCodec; + } + + @Override + public ByteBuf encode(ServiceMessage message) { + ByteBuf content; + + if (message.hasData(ByteBuf.class)) { + content = message.data(); + } else { + content = ByteBufAllocator.DEFAULT.buffer(); + try { + dataCodec.encode(new ByteBufOutputStream(content), message.data()); + } catch (Throwable t) { + ReferenceCountUtil.safestRelease(content); + LOGGER.error("Failed to encode data on: {}, cause: {}", message, t); + throw new MessageCodecException( + "Failed to encode data on message q=" + message.qualifier(), t); + } + } + + return content; + } + + @Override + public ServiceMessage decode(ByteBuf encodedMessage) { + return ServiceMessage.builder().data(encodedMessage).build(); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/websocket/Signal.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/websocket/Signal.java new file mode 100644 index 000000000..ccb7bd1d3 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/websocket/Signal.java @@ -0,0 +1,50 @@ +package io.scalecube.services.gateway.transport.websocket; + +public enum Signal { + COMPLETE(1), + ERROR(2), + CANCEL(3); + + private final int code; + + Signal(int code) { + this.code = code; + } + + public int code() { + return code; + } + + public String codeAsString() { + return String.valueOf(code); + } + + /** + * Return appropriate instance of {@link Signal} for given signal code. + * + * @param code signal code + * @return signal instance + */ + public static Signal from(String code) { + return from(Integer.parseInt(code)); + } + + /** + * Return appropriate instance of {@link Signal} for given signal code. + * + * @param code signal code + * @return signal instance + */ + public static Signal from(int code) { + switch (code) { + case 1: + return COMPLETE; + case 2: + return ERROR; + case 3: + return CANCEL; + default: + throw new IllegalArgumentException("Unknown signal: " + code); + } + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/websocket/WebsocketGatewayClient.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/websocket/WebsocketGatewayClient.java new file mode 100644 index 000000000..8f93d6418 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/websocket/WebsocketGatewayClient.java @@ -0,0 +1,234 @@ +package io.scalecube.services.gateway.transport.websocket; + +import static io.scalecube.reactor.RetryNonSerializedEmitFailureHandler.RETRY_NON_SERIALIZED; + +import io.netty.buffer.ByteBuf; +import io.netty.handler.codec.http.websocketx.PingWebSocketFrame; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.gateway.transport.GatewayClient; +import io.scalecube.services.gateway.transport.GatewayClientCodec; +import io.scalecube.services.gateway.transport.GatewayClientSettings; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.netty.Connection; +import reactor.netty.http.client.HttpClient; +import reactor.netty.resources.ConnectionProvider; +import reactor.netty.resources.LoopResources; + +public final class WebsocketGatewayClient implements GatewayClient { + + private static final Logger LOGGER = LoggerFactory.getLogger(WebsocketGatewayClient.class); + + private static final String STREAM_ID = "sid"; + + @SuppressWarnings("rawtypes") + private static final AtomicReferenceFieldUpdater + websocketMonoUpdater = + AtomicReferenceFieldUpdater.newUpdater( + WebsocketGatewayClient.class, Mono.class, "websocketMono"); + + private final AtomicLong sidCounter = new AtomicLong(); + + private final GatewayClientCodec codec; + private final GatewayClientSettings settings; + private final HttpClient httpClient; + private final LoopResources loopResources; + private final boolean ownsLoopResources; + + private final Sinks.One close = Sinks.one(); + private final Sinks.One onClose = Sinks.one(); + + @SuppressWarnings("unused") + private volatile Mono websocketMono; + + /** + * Creates instance of websocket client transport. + * + * @param settings client settings + * @param codec client codec. + */ + public WebsocketGatewayClient(GatewayClientSettings settings, GatewayClientCodec codec) { + this(settings, codec, LoopResources.create("websocket-gateway-client"), true); + } + + /** + * Creates instance of websocket client transport. + * + * @param settings client settings + * @param codec client codec. + * @param loopResources loopResources. + */ + public WebsocketGatewayClient( + GatewayClientSettings settings, + GatewayClientCodec codec, + LoopResources loopResources) { + this(settings, codec, loopResources, false); + } + + private WebsocketGatewayClient( + GatewayClientSettings settings, + GatewayClientCodec codec, + LoopResources loopResources, + boolean ownsLoopResources) { + + this.settings = settings; + this.codec = codec; + this.loopResources = loopResources; + this.ownsLoopResources = ownsLoopResources; + + HttpClient httpClient = + HttpClient.create(ConnectionProvider.newConnection()) + .headers(headers -> settings.headers().forEach(headers::add)) + .followRedirect(settings.followRedirect()) + .wiretap(settings.wiretap()) + .runOn(loopResources) + .host(settings.host()) + .port(settings.port()); + + if (settings.sslProvider() != null) { + httpClient = httpClient.secure(settings.sslProvider()); + } + + this.httpClient = httpClient; + + // Setup cleanup + close + .asMono() + .then(doClose()) + .doFinally(s -> onClose.emitEmpty(RETRY_NON_SERIALIZED)) + .doOnTerminate(() -> LOGGER.info("Closed client")) + .subscribe(null, ex -> LOGGER.warn("Failed to close client, cause: " + ex)); + } + + @Override + public Mono requestResponse(ServiceMessage request) { + return getOrConnect() + .flatMap( + session -> { + long sid = sidCounter.incrementAndGet(); + return session + .send(encodeRequest(request, sid)) + .doOnSubscribe(s -> LOGGER.debug("Sending request {}", request)) + .then(session.newMonoProcessor(sid).asMono()) + .doOnCancel(() -> session.cancel(sid, request.qualifier())) + .doFinally(s -> session.removeProcessor(sid)); + }); + } + + @Override + public Flux requestStream(ServiceMessage request) { + return getOrConnect() + .flatMapMany( + session -> { + long sid = sidCounter.incrementAndGet(); + return session + .send(encodeRequest(request, sid)) + .doOnSubscribe(s -> LOGGER.debug("Sending request {}", request)) + .thenMany(session.newUnicastProcessor(sid).asFlux()) + .doOnCancel(() -> session.cancel(sid, request.qualifier())) + .doFinally(s -> session.removeProcessor(sid)); + }); + } + + @Override + public Flux requestChannel(Flux requests) { + return Flux.error(new UnsupportedOperationException("requestChannel is not supported")); + } + + @Override + public void close() { + close.emitEmpty(RETRY_NON_SERIALIZED); + } + + @Override + public Mono onClose() { + return onClose.asMono(); + } + + private Mono doClose() { + return ownsLoopResources ? Mono.defer(loopResources::disposeLater) : Mono.empty(); + } + + private Mono getOrConnect() { + // noinspection unchecked + return websocketMonoUpdater.updateAndGet(this, this::getOrConnect0); + } + + private Mono getOrConnect0( + Mono prev) { + if (prev != null) { + return prev; + } + + Duration keepAliveInterval = settings.keepAliveInterval(); + + return httpClient + .websocket() + .uri("/") + .connect() + .map( + connection -> + keepAliveInterval != Duration.ZERO + ? connection + .onReadIdle(keepAliveInterval.toMillis(), () -> onReadIdle(connection)) + .onWriteIdle(keepAliveInterval.toMillis(), () -> onWriteIdle(connection)) + : connection) + .map( + connection -> { + WebsocketGatewayClientSession session = + new WebsocketGatewayClientSession(codec, connection); + LOGGER.info("Created session: {}", session); + // setup shutdown hook + session + .onClose() + .doOnTerminate( + () -> { + websocketMonoUpdater.getAndSet(this, null); // clear reference + LOGGER.info("Closed session: {}", session); + }) + .subscribe( + null, + th -> + LOGGER.warn( + "Exception on closing session: {}, cause: {}", + session, + th.toString())); + return session; + }) + .doOnError( + ex -> { + LOGGER.warn( + "Failed to connect on {}:{}, cause: {}", settings.host(), settings.port(), ex); + websocketMonoUpdater.getAndSet(this, null); // clear reference + }) + .cache(); + } + + private void onWriteIdle(Connection connection) { + LOGGER.debug("Sending keepalive on writeIdle"); + connection + .outbound() + .sendObject(new PingWebSocketFrame()) + .then() + .subscribe(null, ex -> LOGGER.warn("Can't send keepalive on writeIdle: " + ex)); + } + + private void onReadIdle(Connection connection) { + LOGGER.debug("Sending keepalive on readIdle"); + connection + .outbound() + .sendObject(new PingWebSocketFrame()) + .then() + .subscribe(null, ex -> LOGGER.warn("Can't send keepalive on readIdle: " + ex)); + } + + private ByteBuf encodeRequest(ServiceMessage message, long sid) { + return codec.encode(ServiceMessage.from(message).header(STREAM_ID, sid).build()); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/websocket/WebsocketGatewayClientCodec.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/websocket/WebsocketGatewayClientCodec.java new file mode 100644 index 000000000..087edab14 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/websocket/WebsocketGatewayClientCodec.java @@ -0,0 +1,170 @@ +package io.scalecube.services.gateway.transport.websocket; + +import static com.fasterxml.jackson.core.JsonToken.VALUE_NULL; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.core.JsonEncoding; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MappingJsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.ByteBufInputStream; +import io.netty.buffer.ByteBufOutputStream; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.exceptions.MessageCodecException; +import io.scalecube.services.gateway.ReferenceCountUtil; +import io.scalecube.services.gateway.transport.GatewayClientCodec; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Map.Entry; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class WebsocketGatewayClientCodec implements GatewayClientCodec { + + private static final Logger LOGGER = LoggerFactory.getLogger(WebsocketGatewayClientCodec.class); + + private static final MappingJsonFactory jsonFactory = new MappingJsonFactory(objectMapper()); + + // special numeric fields + private static final String STREAM_ID_FIELD = "sid"; + private static final String SIGNAL_FIELD = "sig"; + private static final String INACTIVITY_FIELD = "i"; + private static final String RATE_LIMIT_FIELD = "rlimit"; + // data field + private static final String DATA_FIELD = "d"; + + private final boolean releaseDataOnEncode; + + public WebsocketGatewayClientCodec() { + this(true /*always release by default*/); + } + + public WebsocketGatewayClientCodec(boolean releaseDataOnEncode) { + this.releaseDataOnEncode = releaseDataOnEncode; + } + + @Override + public ByteBuf encode(ServiceMessage message) { + ByteBuf byteBuf = ByteBufAllocator.DEFAULT.buffer(); + + try (JsonGenerator generator = + jsonFactory.createGenerator( + (OutputStream) new ByteBufOutputStream(byteBuf), JsonEncoding.UTF8)) { + generator.writeStartObject(); + + // headers + for (Entry header : message.headers().entrySet()) { + String fieldName = header.getKey(); + String value = header.getValue(); + switch (fieldName) { + case STREAM_ID_FIELD: + case SIGNAL_FIELD: + case INACTIVITY_FIELD: + case RATE_LIMIT_FIELD: + generator.writeNumberField(fieldName, Long.parseLong(value)); + break; + default: + generator.writeStringField(fieldName, value); + } + } + + // data + Object data = message.data(); + if (data != null) { + if (data instanceof ByteBuf) { + ByteBuf dataBin = (ByteBuf) data; + if (dataBin.isReadable()) { + try { + generator.writeFieldName(DATA_FIELD); + generator.writeRaw(":"); + generator.flush(); + byteBuf.writeBytes(dataBin); + } finally { + if (releaseDataOnEncode) { + ReferenceCountUtil.safestRelease(dataBin); + } + } + } + } else { + generator.writeObjectField(DATA_FIELD, data); + } + } + + generator.writeEndObject(); + } catch (Throwable ex) { + ReferenceCountUtil.safestRelease(byteBuf); + Optional.ofNullable(message.data()).ifPresent(ReferenceCountUtil::safestRelease); + LOGGER.error("Failed to encode message: {}", message, ex); + throw new MessageCodecException("Failed to encode message", ex); + } + return byteBuf; + } + + @Override + public ServiceMessage decode(ByteBuf encodedMessage) { + try (InputStream stream = new ByteBufInputStream(encodedMessage, true)) { + JsonParser jp = jsonFactory.createParser(stream); + ServiceMessage.Builder result = ServiceMessage.builder(); + + JsonToken current = jp.nextToken(); + if (current != JsonToken.START_OBJECT) { + throw new MessageCodecException("Root should be object", null); + } + long dataStart = 0; + long dataEnd = 0; + while ((jp.nextToken()) != JsonToken.END_OBJECT) { + String fieldName = jp.getCurrentName(); + current = jp.nextToken(); + if (current == VALUE_NULL) { + continue; + } + + if (DATA_FIELD.equals(fieldName)) { + dataStart = jp.getTokenLocation().getByteOffset(); + if (current.isScalarValue()) { + if (!current.isNumeric() && !current.isBoolean()) { + jp.getValueAsString(); + } + } else if (current.isStructStart()) { + jp.skipChildren(); + } + dataEnd = jp.getCurrentLocation().getByteOffset(); + } else { + // headers + result.header(fieldName, jp.getValueAsString()); + } + } + // data + if (dataEnd > dataStart) { + result.data(encodedMessage.copy((int) dataStart, (int) (dataEnd - dataStart))); + } + return result.build(); + } catch (Throwable ex) { + throw new MessageCodecException("Failed to decode message", ex); + } + } + + private static ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + mapper.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true); + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + mapper.configure(SerializationFeature.WRITE_ENUMS_USING_TO_STRING, true); + mapper.registerModule(new JavaTimeModule()); + mapper.findAndRegisterModules(); + return mapper; + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/transport/websocket/WebsocketGatewayClientSession.java b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/websocket/WebsocketGatewayClientSession.java new file mode 100644 index 000000000..eb0da1dd3 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/transport/websocket/WebsocketGatewayClientSession.java @@ -0,0 +1,222 @@ +package io.scalecube.services.gateway.transport.websocket; + +import static io.scalecube.reactor.RetryNonSerializedEmitFailureHandler.RETRY_NON_SERIALIZED; + +import io.netty.buffer.ByteBuf; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.scalecube.services.api.ErrorData; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.gateway.ReferenceCountUtil; +import io.scalecube.services.gateway.transport.GatewayClientCodec; +import java.nio.channels.ClosedChannelException; +import java.util.Map; +import java.util.StringJoiner; +import org.jctools.maps.NonBlockingHashMapLong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.publisher.Sinks.Many; +import reactor.core.publisher.Sinks.One; +import reactor.netty.Connection; +import reactor.netty.http.websocket.WebsocketInbound; +import reactor.netty.http.websocket.WebsocketOutbound; + +public final class WebsocketGatewayClientSession { + + private static final Logger LOGGER = LoggerFactory.getLogger(WebsocketGatewayClientSession.class); + + private static final ClosedChannelException CLOSED_CHANNEL_EXCEPTION = + new ClosedChannelException(); + + private static final String STREAM_ID = "sid"; + private static final String SIGNAL = "sig"; + + private final String id; // keep id for tracing + private final GatewayClientCodec codec; + private final Connection connection; + + // processor by sid mapping + private final Map inboundProcessors = new NonBlockingHashMapLong<>(1024); + + WebsocketGatewayClientSession(GatewayClientCodec codec, Connection connection) { + this.id = Integer.toHexString(System.identityHashCode(this)); + this.codec = codec; + this.connection = connection; + + WebsocketInbound inbound = (WebsocketInbound) connection.inbound(); + inbound + .receive() + .retain() + .subscribe( + byteBuf -> { + if (!byteBuf.isReadable()) { + ReferenceCountUtil.safestRelease(byteBuf); + return; + } + + // decode message + ServiceMessage message; + try { + message = codec.decode(byteBuf); + } catch (Exception ex) { + LOGGER.error("Response decoder failed:", ex); + return; + } + + // ignore messages w/o sid + if (!message.headers().containsKey(STREAM_ID)) { + LOGGER.error("Ignore response: {} with null sid, session={}", message, id); + if (message.data() != null) { + ReferenceCountUtil.safestRelease(message.data()); + } + return; + } + + // processor? + long sid = Long.parseLong(message.header(STREAM_ID)); + Object processor = inboundProcessors.get(sid); + if (processor == null) { + if (message.data() != null) { + ReferenceCountUtil.safestRelease(message.data()); + } + return; + } + + // handle response message + handleResponse(message, processor); + }); + + connection.onDispose( + () -> inboundProcessors.forEach((k, o) -> emitError(o, CLOSED_CHANNEL_EXCEPTION))); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + One newMonoProcessor(long sid) { + return (One) inboundProcessors.computeIfAbsent(sid, this::newMonoProcessor0); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + Many newUnicastProcessor(long sid) { + return (Many) inboundProcessors.computeIfAbsent(sid, this::newUnicastProcessor0); + } + + private One newMonoProcessor0(long sid) { + LOGGER.debug("Put sid={}, session={}", sid, id); + return Sinks.one(); + } + + private Many newUnicastProcessor0(long sid) { + LOGGER.debug("Put sid={}, session={}", sid, id); + return Sinks.many().unicast().onBackpressureBuffer(); + } + + void removeProcessor(long sid) { + if (inboundProcessors.remove(sid) != null) { + LOGGER.debug("Removed sid={}, session={}", sid, id); + } + } + + Mono send(ByteBuf byteBuf) { + return connection.outbound().sendObject(new TextWebSocketFrame(byteBuf)).then(); + } + + void cancel(long sid, String qualifier) { + ByteBuf byteBuf = + codec.encode( + ServiceMessage.builder() + .qualifier(qualifier) + .header(STREAM_ID, sid) + .header(SIGNAL, Signal.CANCEL.codeAsString()) + .build()); + + send(byteBuf) + .subscribe( + null, + th -> + LOGGER.error("Exception occurred on sending CANCEL signal for session={}", id, th)); + } + + /** + * Close the websocket session with normal status. Defined Status Codes: 1000 + * indicates a normal closure, meaning that the purpose for which the connection was established + * has been fulfilled. + * + * @return mono void + */ + public Mono close() { + return ((WebsocketOutbound) connection.outbound()).sendClose().then(); + } + + public Mono onClose() { + return connection.onDispose(); + } + + private void handleResponse(ServiceMessage response, Object processor) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Handle response: {}, session={}", response, id); + } + + try { + Signal signal = null; + final String header = response.header(SIGNAL); + + if (header != null) { + signal = Signal.from(header); + } + + if (signal == null) { + // handle normal response + emitNext(processor, response); + } else { + // handle completion signal + if (signal == Signal.COMPLETE) { + emitComplete(processor); + } + if (signal == Signal.ERROR) { + // decode error data to retrieve real error cause + emitNext(processor, codec.decodeData(response, ErrorData.class)); + } + } + } catch (Exception e) { + emitError(processor, e); + } + } + + private static void emitNext(Object processor, ServiceMessage message) { + if (processor instanceof One) { + //noinspection unchecked + ((One) processor).emitValue(message, RETRY_NON_SERIALIZED); + } + if (processor instanceof Many) { + //noinspection unchecked + ((Many) processor).emitNext(message, RETRY_NON_SERIALIZED); + } + } + + private static void emitComplete(Object processor) { + if (processor instanceof One) { + ((One) processor).emitEmpty(RETRY_NON_SERIALIZED); + } + if (processor instanceof Many) { + ((Many) processor).emitComplete(RETRY_NON_SERIALIZED); + } + } + + private static void emitError(Object processor, Exception e) { + if (processor instanceof One) { + ((One) processor).emitError(e, RETRY_NON_SERIALIZED); + } + if (processor instanceof Many) { + ((Many) processor).emitError(e, RETRY_NON_SERIALIZED); + } + } + + @Override + public String toString() { + return new StringJoiner(", ", WebsocketGatewayClientSession.class.getSimpleName() + "[", "]") + .add("id=" + id) + .toString(); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/ws/GatewayMessages.java b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/GatewayMessages.java new file mode 100644 index 000000000..0aef0b0e8 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/GatewayMessages.java @@ -0,0 +1,157 @@ +package io.scalecube.services.gateway.ws; + +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.exceptions.ServiceProviderErrorMapper; + +public final class GatewayMessages { + + static final String QUALIFIER_FIELD = "q"; + static final String STREAM_ID_FIELD = "sid"; + static final String DATA_FIELD = "d"; + static final String SIGNAL_FIELD = "sig"; + static final String INACTIVITY_FIELD = "i"; + static final String RATE_LIMIT_FIELD = "rlimit"; + + private GatewayMessages() { + // Do not instantiate + } + + /** + * Returns cancel message by given arguments. + * + * @param sid sid + * @param qualifier qualifier + * @return {@link ServiceMessage} instance as the cancel signal + */ + public static ServiceMessage newCancelMessage(long sid, String qualifier) { + return ServiceMessage.builder() + .qualifier(qualifier) + .header(STREAM_ID_FIELD, sid) + .header(SIGNAL_FIELD, Signal.CANCEL.code()) + .build(); + } + + /** + * Returns error message by given arguments. + * + * @param errorMapper error mapper + * @param request request + * @param th cause + * @return {@link ServiceMessage} instance as the error signal + */ + public static ServiceMessage toErrorResponse( + ServiceProviderErrorMapper errorMapper, ServiceMessage request, Throwable th) { + + final String qualifier = request.qualifier() != null ? request.qualifier() : "scalecube/error"; + final String sid = request.header(STREAM_ID_FIELD); + final ServiceMessage errorMessage = errorMapper.toMessage(qualifier, th); + + if (sid == null) { + return ServiceMessage.from(errorMessage).header(SIGNAL_FIELD, Signal.ERROR.code()).build(); + } + + return ServiceMessage.from(errorMessage) + .header(SIGNAL_FIELD, Signal.ERROR.code()) + .header(STREAM_ID_FIELD, sid) + .build(); + } + + /** + * Returns complete message by given arguments. + * + * @param sid sid + * @param qualifier qualifier + * @return {@link ServiceMessage} instance as the complete signal + */ + public static ServiceMessage newCompleteMessage(long sid, String qualifier) { + return ServiceMessage.builder() + .qualifier(qualifier) + .header(STREAM_ID_FIELD, sid) + .header(SIGNAL_FIELD, Signal.COMPLETE.code()) + .build(); + } + + /** + * Returns response message by given arguments. + * + * @param sid sid + * @param message request + * @param isErrorResponse should the message be marked as an error? + * @return {@link ServiceMessage} instance as the response + */ + public static ServiceMessage newResponseMessage( + long sid, ServiceMessage message, boolean isErrorResponse) { + if (isErrorResponse) { + return ServiceMessage.from(message) + .header(STREAM_ID_FIELD, sid) + .header(SIGNAL_FIELD, Signal.ERROR.code()) + .build(); + } + return ServiceMessage.from(message).header(STREAM_ID_FIELD, sid).build(); + } + + /** + * Verifies the sid existence in a given message. + * + * @param message message + * @return incoming message + */ + public static ServiceMessage validateSid(ServiceMessage message) { + if (message.header(STREAM_ID_FIELD) == null) { + throw WebsocketContextException.badRequest("sid is missing", message); + } else { + return message; + } + } + + /** + * Verifies the sid is not used in a given session. + * + * @param session session + * @param message message + * @return incoming message + */ + public static ServiceMessage validateSidOnSession( + WebsocketGatewaySession session, ServiceMessage message) { + long sid = getSid(message); + if (session.containsSid(sid)) { + throw WebsocketContextException.badRequest("sid=" + sid + " is already registered", message); + } else { + return message; + } + } + + /** + * Verifies the qualifier existence in a given message. + * + * @param message message + * @return incoming message + */ + public static ServiceMessage validateQualifier(ServiceMessage message) { + if (message.qualifier() == null) { + throw WebsocketContextException.badRequest("qualifier is missing", message); + } + return message; + } + + /** + * Returns sid from a given message. + * + * @param message message + * @return sid + */ + public static long getSid(ServiceMessage message) { + return Long.parseLong(message.header(STREAM_ID_FIELD)); + } + + /** + * Returns signal from a given message. + * + * @param message message + * @return signal + */ + public static Signal getSignal(ServiceMessage message) { + String header = message.header(SIGNAL_FIELD); + return header != null ? Signal.from(Integer.parseInt(header)) : null; + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/ws/Signal.java b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/Signal.java new file mode 100644 index 000000000..5584fd025 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/Signal.java @@ -0,0 +1,36 @@ +package io.scalecube.services.gateway.ws; + +public enum Signal { + COMPLETE(1), + ERROR(2), + CANCEL(3); + + private final int code; + + Signal(int code) { + this.code = code; + } + + public int code() { + return code; + } + + /** + * Return appropriate instance of {@link Signal} for given signal code. + * + * @param code signal code + * @return signal instance + */ + public static Signal from(int code) { + switch (code) { + case 1: + return COMPLETE; + case 2: + return ERROR; + case 3: + return CANCEL; + default: + throw new IllegalArgumentException("Unknown signal: " + code); + } + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketContextException.java b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketContextException.java new file mode 100644 index 000000000..5f2928767 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketContextException.java @@ -0,0 +1,42 @@ +package io.scalecube.services.gateway.ws; + +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.gateway.ReferenceCountUtil; + +public class WebsocketContextException extends RuntimeException { + + private final ServiceMessage request; + private final ServiceMessage response; + + private WebsocketContextException( + Throwable cause, ServiceMessage request, ServiceMessage response) { + super(cause); + this.request = request; + this.response = response; + } + + public static WebsocketContextException badRequest(String errorMessage, ServiceMessage request) { + return new WebsocketContextException( + new io.scalecube.services.exceptions.BadRequestException(errorMessage), request, null); + } + + public ServiceMessage request() { + return request; + } + + public ServiceMessage response() { + return response; + } + + /** + * Releases request data if any. + * + * @return self + */ + public WebsocketContextException releaseRequest() { + if (request != null) { + ReferenceCountUtil.safestRelease(request.data()); + } + return this; + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketGateway.java b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketGateway.java new file mode 100644 index 000000000..6d2b322b5 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketGateway.java @@ -0,0 +1,163 @@ +package io.scalecube.services.gateway.ws; + +import io.netty.handler.codec.http.websocketx.PingWebSocketFrame; +import io.scalecube.net.Address; +import io.scalecube.services.exceptions.DefaultErrorMapper; +import io.scalecube.services.exceptions.ServiceProviderErrorMapper; +import io.scalecube.services.gateway.Gateway; +import io.scalecube.services.gateway.GatewayOptions; +import io.scalecube.services.gateway.GatewaySessionHandler; +import io.scalecube.services.gateway.GatewayTemplate; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.StringJoiner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.Connection; +import reactor.netty.DisposableServer; +import reactor.netty.resources.LoopResources; + +public class WebsocketGateway extends GatewayTemplate { + + private static final Logger LOGGER = LoggerFactory.getLogger(WebsocketGateway.class); + + private final GatewaySessionHandler gatewayHandler; + private final Duration keepAliveInterval; + private final ServiceProviderErrorMapper errorMapper; + + private DisposableServer server; + private LoopResources loopResources; + + /** + * Constructor. + * + * @param options gateway options + */ + public WebsocketGateway(GatewayOptions options) { + this( + options, + Duration.ZERO, + GatewaySessionHandler.DEFAULT_INSTANCE, + DefaultErrorMapper.INSTANCE); + } + + /** + * Constructor. + * + * @param options gateway options + * @param keepAliveInterval keep alive interval + */ + public WebsocketGateway(GatewayOptions options, Duration keepAliveInterval) { + this( + options, + keepAliveInterval, + GatewaySessionHandler.DEFAULT_INSTANCE, + DefaultErrorMapper.INSTANCE); + } + + /** + * Constructor. + * + * @param options gateway options + * @param gatewayHandler gateway handler + */ + public WebsocketGateway(GatewayOptions options, GatewaySessionHandler gatewayHandler) { + this(options, Duration.ZERO, gatewayHandler, DefaultErrorMapper.INSTANCE); + } + + /** + * Constructor. + * + * @param options gateway options + * @param errorMapper error mapper + */ + public WebsocketGateway(GatewayOptions options, ServiceProviderErrorMapper errorMapper) { + this(options, Duration.ZERO, GatewaySessionHandler.DEFAULT_INSTANCE, errorMapper); + } + + /** + * Constructor. + * + * @param options gateway options + * @param keepAliveInterval keep alive interval + * @param gatewayHandler gateway handler + * @param errorMapper error mapper + */ + public WebsocketGateway( + GatewayOptions options, + Duration keepAliveInterval, + GatewaySessionHandler gatewayHandler, + ServiceProviderErrorMapper errorMapper) { + super(options); + this.keepAliveInterval = keepAliveInterval; + this.gatewayHandler = gatewayHandler; + this.errorMapper = errorMapper; + } + + @Override + public Mono start() { + return Mono.defer( + () -> { + WebsocketGatewayAcceptor acceptor = + new WebsocketGatewayAcceptor(options.call(), gatewayHandler, errorMapper); + + loopResources = LoopResources.create("websocket-gateway"); + + return prepareHttpServer(loopResources, options.port()) + .doOnConnection(this::setupKeepAlive) + .handle(acceptor) + .bind() + .doOnSuccess(server -> this.server = server) + .thenReturn(this); + }); + } + + @Override + public Address address() { + InetSocketAddress address = (InetSocketAddress) server.address(); + return Address.create(address.getHostString(), address.getPort()); + } + + @Override + public Mono stop() { + return Flux.concatDelayError(shutdownServer(server), shutdownLoopResources(loopResources)) + .then(); + } + + @Override + public String toString() { + return new StringJoiner(", ", WebsocketGateway.class.getSimpleName() + "[", "]") + .add("server=" + server) + .add("loopResources=" + loopResources) + .add("options=" + options) + .toString(); + } + + private void setupKeepAlive(Connection connection) { + if (keepAliveInterval != Duration.ZERO) { + connection + .onReadIdle(keepAliveInterval.toMillis(), () -> onReadIdle(connection)) + .onWriteIdle(keepAliveInterval.toMillis(), () -> onWriteIdle(connection)); + } + } + + private void onWriteIdle(Connection connection) { + LOGGER.debug("Sending keepalive on writeIdle"); + connection + .outbound() + .sendObject(new PingWebSocketFrame()) + .then() + .subscribe(null, ex -> LOGGER.warn("Can't send keepalive on writeIdle: " + ex)); + } + + private void onReadIdle(Connection connection) { + LOGGER.debug("Sending keepalive on readIdle"); + connection + .outbound() + .sendObject(new PingWebSocketFrame()) + .then() + .subscribe(null, ex -> LOGGER.warn("Can't send keepalive on readIdle: " + ex)); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketGatewayAcceptor.java b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketGatewayAcceptor.java new file mode 100644 index 000000000..910d0c8d0 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketGatewayAcceptor.java @@ -0,0 +1,245 @@ +package io.scalecube.services.gateway.ws; + +import static io.scalecube.services.gateway.ws.GatewayMessages.RATE_LIMIT_FIELD; +import static io.scalecube.services.gateway.ws.GatewayMessages.getSid; +import static io.scalecube.services.gateway.ws.GatewayMessages.getSignal; +import static io.scalecube.services.gateway.ws.GatewayMessages.newCancelMessage; +import static io.scalecube.services.gateway.ws.GatewayMessages.newCompleteMessage; +import static io.scalecube.services.gateway.ws.GatewayMessages.newResponseMessage; +import static io.scalecube.services.gateway.ws.GatewayMessages.toErrorResponse; +import static io.scalecube.services.gateway.ws.GatewayMessages.validateSidOnSession; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.HttpHeaders; +import io.scalecube.services.ServiceCall; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.exceptions.BadRequestException; +import io.scalecube.services.exceptions.ForbiddenException; +import io.scalecube.services.exceptions.InternalServiceException; +import io.scalecube.services.exceptions.ServiceException; +import io.scalecube.services.exceptions.ServiceProviderErrorMapper; +import io.scalecube.services.exceptions.ServiceUnavailableException; +import io.scalecube.services.exceptions.UnauthorizedException; +import io.scalecube.services.gateway.GatewaySessionHandler; +import io.scalecube.services.gateway.ReferenceCountUtil; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BiFunction; +import java.util.stream.Collectors; +import org.reactivestreams.Publisher; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.DisposableChannel; +import reactor.netty.channel.AbortedException; +import reactor.netty.http.server.HttpServerRequest; +import reactor.netty.http.server.HttpServerResponse; +import reactor.netty.http.websocket.WebsocketInbound; +import reactor.netty.http.websocket.WebsocketOutbound; +import reactor.util.context.Context; + +public class WebsocketGatewayAcceptor + implements BiFunction> { + + private static final int DEFAULT_ERROR_CODE = 500; + + private static final AtomicLong SESSION_ID_GENERATOR = new AtomicLong(System.currentTimeMillis()); + + private final WebsocketServiceMessageCodec messageCodec = new WebsocketServiceMessageCodec(); + private final ServiceCall serviceCall; + private final GatewaySessionHandler gatewayHandler; + private final ServiceProviderErrorMapper errorMapper; + + /** + * Constructor for websocket acceptor. + * + * @param serviceCall service call + * @param gatewayHandler gateway handler + * @param errorMapper error mapper + */ + public WebsocketGatewayAcceptor( + ServiceCall serviceCall, + GatewaySessionHandler gatewayHandler, + ServiceProviderErrorMapper errorMapper) { + this.serviceCall = Objects.requireNonNull(serviceCall, "serviceCall"); + this.gatewayHandler = Objects.requireNonNull(gatewayHandler, "gatewayHandler"); + this.errorMapper = Objects.requireNonNull(errorMapper, "errorMapper"); + } + + @Override + public Publisher apply(HttpServerRequest httpRequest, HttpServerResponse httpResponse) { + final Map headers = computeHeaders(httpRequest.requestHeaders()); + final long sessionId = SESSION_ID_GENERATOR.incrementAndGet(); + + return gatewayHandler + .onConnectionOpen(sessionId, headers) + .doOnError( + ex -> + httpResponse + .status(toStatusCode(ex)) + .send() + .doFinally(s -> httpResponse.withConnection(DisposableChannel::dispose)) + .subscribe()) + .then( + Mono.defer( + () -> + httpResponse.sendWebsocket( + (WebsocketInbound inbound, WebsocketOutbound outbound) -> + onConnect( + new WebsocketGatewaySession( + sessionId, + messageCodec, + headers, + inbound, + outbound, + gatewayHandler))))) + .onErrorResume(throwable -> Mono.empty()); + } + + private static Map computeHeaders(HttpHeaders httpHeaders) { + // exception will be thrown on duplicate + return httpHeaders.entries().stream().collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + } + + private static int toStatusCode(Throwable throwable) { + int status = DEFAULT_ERROR_CODE; + if (throwable instanceof ServiceException) { + if (throwable instanceof BadRequestException) { + status = BadRequestException.ERROR_TYPE; + } else if (throwable instanceof UnauthorizedException) { + status = UnauthorizedException.ERROR_TYPE; + } else if (throwable instanceof ForbiddenException) { + status = ForbiddenException.ERROR_TYPE; + } else if (throwable instanceof ServiceUnavailableException) { + status = ServiceUnavailableException.ERROR_TYPE; + } else if (throwable instanceof InternalServiceException) { + status = InternalServiceException.ERROR_TYPE; + } + } + return status; + } + + private Mono onConnect(WebsocketGatewaySession session) { + gatewayHandler.onSessionOpen(session); + + session + .receive() + .subscribe( + byteBuf -> { + if (byteBuf == Unpooled.EMPTY_BUFFER) { + return; + } + + if (!byteBuf.isReadable()) { + ReferenceCountUtil.safestRelease(byteBuf); + return; + } + + Mono.deferContextual(context -> onRequest(session, byteBuf, (Context) context)) + .contextWrite(context -> gatewayHandler.onRequest(session, byteBuf, context)) + .subscribe(); + }, + th -> { + if (!(th instanceof AbortedException)) { + gatewayHandler.onSessionError(session, th); + } + }); + + return session.onClose(() -> gatewayHandler.onSessionClose(session)); + } + + private Mono onRequest( + WebsocketGatewaySession session, ByteBuf byteBuf, Context context) { + + return Mono.fromCallable(() -> messageCodec.decode(byteBuf)) + .map(GatewayMessages::validateSid) + .flatMap(message -> onCancel(session, message)) + .map(message -> validateSidOnSession(session, (ServiceMessage) message)) + .map(GatewayMessages::validateQualifier) + .map(message -> gatewayHandler.mapMessage(session, message, context)) + .doOnNext(request -> onRequest(session, request, context)) + .doOnError( + th -> { + if (!(th instanceof WebsocketContextException)) { + // decode failed at this point + gatewayHandler.onError(session, th, context); + return; + } + + WebsocketContextException wex = (WebsocketContextException) th; + wex.releaseRequest(); // release + + session + .send(toErrorResponse(errorMapper, wex.request(), wex.getCause())) + .contextWrite(context) + .subscribe(); + }); + } + + private void onRequest(WebsocketGatewaySession session, ServiceMessage request, Context context) { + final long sid = getSid(request); + final AtomicBoolean receivedError = new AtomicBoolean(false); + + Flux serviceStream = serviceCall.requestMany(request); + final String limitRate = request.header(RATE_LIMIT_FIELD); + serviceStream = + limitRate != null ? serviceStream.limitRate(Integer.parseInt(limitRate)) : serviceStream; + + Disposable disposable = + session + .send( + serviceStream.map( + response -> { + boolean isErrorResponse = response.isError(); + if (isErrorResponse) { + receivedError.set(true); + } + return newResponseMessage(sid, response, isErrorResponse); + })) + .doOnError( + th -> { + ReferenceCountUtil.safestRelease(request.data()); + receivedError.set(true); + session + .send(toErrorResponse(errorMapper, request, th)) + .contextWrite(context) + .subscribe(); + }) + .doOnTerminate( + () -> { + if (!receivedError.get()) { + session + .send(newCompleteMessage(sid, request.qualifier())) + .contextWrite(context) + .subscribe(); + } + }) + .doFinally(signalType -> session.dispose(sid)) + .contextWrite(context) + .subscribe(); + + session.register(sid, disposable); + } + + private Mono onCancel(WebsocketGatewaySession session, ServiceMessage message) { + if (getSignal(message) != Signal.CANCEL) { + return Mono.just(message); + } + + // release data if CANCEL contains data (it shouldn't normally) + if (message.data() != null) { + ReferenceCountUtil.safestRelease(message.data()); + } + + // dispose by sid (if anything to dispose) + long sid = getSid(message); + session.dispose(sid); + + // no need to subscribe here since flatMap will do + return session.send(newCancelMessage(sid, message.qualifier())); + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketGatewaySession.java b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketGatewaySession.java new file mode 100644 index 000000000..33967ebf0 --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketGatewaySession.java @@ -0,0 +1,227 @@ +package io.scalecube.services.gateway.ws; + +import io.netty.buffer.ByteBuf; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.gateway.GatewaySession; +import io.scalecube.services.gateway.GatewaySessionHandler; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Predicate; +import org.jctools.maps.NonBlockingHashMapLong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.http.websocket.WebsocketInbound; +import reactor.netty.http.websocket.WebsocketOutbound; +import reactor.util.context.Context; + +public final class WebsocketGatewaySession implements GatewaySession { + + private static final Logger LOGGER = LoggerFactory.getLogger(WebsocketGatewaySession.class); + + private static final Predicate SEND_PREDICATE = f -> true; + + private final Map subscriptions = new NonBlockingHashMapLong<>(1024); + + private final GatewaySessionHandler gatewayHandler; + + private final WebsocketInbound inbound; + private final WebsocketOutbound outbound; + private final WebsocketServiceMessageCodec codec; + + private final long sessionId; + private final Map headers; + + /** + * Create a new websocket session with given handshake, inbound and outbound channels. + * + * @param sessionId - session id + * @param codec - msg codec + * @param headers - headers + * @param inbound - Websocket inbound + * @param outbound - Websocket outbound + * @param gatewayHandler - gateway handler + */ + public WebsocketGatewaySession( + long sessionId, + WebsocketServiceMessageCodec codec, + Map headers, + WebsocketInbound inbound, + WebsocketOutbound outbound, + GatewaySessionHandler gatewayHandler) { + this.sessionId = sessionId; + this.codec = codec; + + this.headers = Collections.unmodifiableMap(new HashMap<>(headers)); + this.inbound = + (WebsocketInbound) inbound.withConnection(c -> c.onDispose(this::clearSubscriptions)); + this.outbound = outbound; + this.gatewayHandler = gatewayHandler; + } + + @Override + public long sessionId() { + return sessionId; + } + + @Override + public Map headers() { + return headers; + } + + /** + * Method for receiving request messages coming a form of websocket frames. + * + * @return flux websocket {@link ByteBuf} + */ + public Flux receive() { + return inbound.receive().retain(); + } + + /** + * Method to send normal response. + * + * @param response response + * @return mono void + */ + public Mono send(ServiceMessage response) { + return Mono.deferContextual( + context -> { + final TextWebSocketFrame frame = new TextWebSocketFrame(codec.encode(response)); + gatewayHandler.onResponse(this, frame.content(), response, (Context) context); + // send with publisher (defer buffer cleanup to netty) + return outbound + .sendObject(frame) + .then() + .doOnError(th -> gatewayHandler.onError(this, th, (Context) context)); + }); + } + + /** + * Method to send normal response. + * + * @param messages messages + * @return mono void + */ + public Mono send(Flux messages) { + return Mono.deferContextual( + context -> { + // send with publisher (defer buffer cleanup to netty) + return outbound + .sendObject( + messages.map( + response -> { + final TextWebSocketFrame frame = + new TextWebSocketFrame(codec.encode(response)); + gatewayHandler.onResponse( + this, frame.content(), response, (Context) context); + return frame; + }), + SEND_PREDICATE) + .then() + .doOnError(th -> gatewayHandler.onError(this, th, (Context) context)); + }); + } + + /** + * Close the websocket session. + * + * @return mono void + */ + public Mono close() { + return outbound.sendClose().then(); + } + + /** + * Closes websocket session with normal status. + * + * @param reason close reason + * @return mono void + */ + public Mono close(String reason) { + return outbound.sendClose(1000, reason).then(); + } + + /** + * Lambda setter for reacting on channel close occurrence. + * + * @param disposable function to run when disposable would take place + */ + public Mono onClose(Disposable disposable) { + return Mono.create( + sink -> + inbound.withConnection( + connection -> + connection + .onDispose(disposable) + .onTerminate() + .subscribe(sink::success, sink::error, sink::success))); + } + + /** + * Disposing stored subscription by given stream id. + * + * @param streamId stream id + * @return true of subscription was disposed + */ + public boolean dispose(Long streamId) { + boolean result = false; + if (streamId != null) { + Disposable disposable = subscriptions.remove(streamId); + result = disposable != null; + if (result) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Dispose subscription by sid={}, session={}", streamId, sessionId); + } + disposable.dispose(); + } + } + return result; + } + + public boolean containsSid(Long streamId) { + return streamId != null && subscriptions.containsKey(streamId); + } + + /** + * Saves (if not already saved) by stream id a subscription of service call coming in form of + * {@link Disposable} reference. + * + * @param streamId stream id + * @param disposable service subscription + */ + public void register(Long streamId, Disposable disposable) { + boolean result = false; + if (!disposable.isDisposed()) { + result = subscriptions.putIfAbsent(streamId, disposable) == null; + } + if (result) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Registered subscription with sid={}, session={}", streamId, sessionId); + } + } + } + + private void clearSubscriptions() { + if (subscriptions.size() > 1) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Clear all {} subscriptions on session={}", subscriptions.size(), sessionId); + } + } else if (subscriptions.size() == 1) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Clear 1 subscription on session={}", sessionId); + } + } + subscriptions.forEach((sid, disposable) -> disposable.dispose()); + subscriptions.clear(); + } + + @Override + public String toString() { + return "WebsocketGatewaySession[" + sessionId + ']'; + } +} diff --git a/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketServiceMessageCodec.java b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketServiceMessageCodec.java new file mode 100644 index 000000000..0ebbbcdbb --- /dev/null +++ b/services-gateway/src/main/java/io/scalecube/services/gateway/ws/WebsocketServiceMessageCodec.java @@ -0,0 +1,174 @@ +package io.scalecube.services.gateway.ws; + +import static com.fasterxml.jackson.core.JsonToken.VALUE_NULL; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.core.JsonEncoding; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MappingJsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.ByteBufInputStream; +import io.netty.buffer.ByteBufOutputStream; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.exceptions.MessageCodecException; +import io.scalecube.services.gateway.ReferenceCountUtil; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Map.Entry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class WebsocketServiceMessageCodec { + + private static final Logger LOGGER = LoggerFactory.getLogger(WebsocketServiceMessageCodec.class); + + private static final ObjectMapper objectMapper = objectMapper(); + + private static final MappingJsonFactory jsonFactory = new MappingJsonFactory(objectMapper); + + private final boolean releaseDataOnEncode; + + public WebsocketServiceMessageCodec() { + this(true /*always release by default*/); + } + + public WebsocketServiceMessageCodec(boolean releaseDataOnEncode) { + this.releaseDataOnEncode = releaseDataOnEncode; + } + + /** + * Encodes {@link ServiceMessage} to {@link ByteBuf}. + * + * @param message - message to encode + * @throws MessageCodecException in case of error during encoding + */ + public ByteBuf encode(ServiceMessage message) throws MessageCodecException { + ByteBuf byteBuf = ByteBufAllocator.DEFAULT.buffer(); + try (JsonGenerator generator = + jsonFactory.createGenerator( + (OutputStream) new ByteBufOutputStream(byteBuf), JsonEncoding.UTF8)) { + generator.writeStartObject(); + + // headers + for (Entry header : message.headers().entrySet()) { + String fieldName = header.getKey(); + String value = header.getValue(); + switch (fieldName) { + case GatewayMessages.STREAM_ID_FIELD: + case GatewayMessages.SIGNAL_FIELD: + case GatewayMessages.INACTIVITY_FIELD: + case GatewayMessages.RATE_LIMIT_FIELD: + generator.writeNumberField(fieldName, Long.parseLong(value)); + break; + default: + generator.writeStringField(fieldName, value); + } + } + + // data + Object data = message.data(); + if (data != null) { + if (data instanceof ByteBuf) { + ByteBuf dataBin = (ByteBuf) data; + if (dataBin.isReadable()) { + try { + generator.writeFieldName(GatewayMessages.DATA_FIELD); + generator.writeRaw(":"); + generator.flush(); + byteBuf.writeBytes(dataBin); + } finally { + if (releaseDataOnEncode) { + ReferenceCountUtil.safestRelease(dataBin); + } + } + } + } else { + generator.writeObjectField(GatewayMessages.DATA_FIELD, data); + } + } + + generator.writeEndObject(); + } catch (Throwable ex) { + ReferenceCountUtil.safestRelease(byteBuf); + if (message.data() != null) { + ReferenceCountUtil.safestRelease(message.data()); + } + LOGGER.error("Failed to encode gateway service message: {}", message, ex); + throw new MessageCodecException("Failed to encode gateway service message", ex); + } + return byteBuf; + } + + /** + * Decodes {@link ByteBuf} into {@link ServiceMessage}. + * + * @param byteBuf - buffer with gateway service message to be decoded + * @return decoded {@code ServiceMessage} instance + * @throws MessageCodecException - in case of issues during decoding + */ + public ServiceMessage decode(ByteBuf byteBuf) throws MessageCodecException { + try (InputStream stream = new ByteBufInputStream(byteBuf, true)) { + JsonParser jp = jsonFactory.createParser(stream); + ServiceMessage.Builder result = ServiceMessage.builder(); + + JsonToken current = jp.nextToken(); + if (current != JsonToken.START_OBJECT) { + throw new MessageCodecException("Root should be object", null); + } + long dataStart = 0; + long dataEnd = 0; + while ((jp.nextToken()) != JsonToken.END_OBJECT) { + String fieldName = jp.getCurrentName(); + current = jp.nextToken(); + if (current == VALUE_NULL) { + continue; + } + + if (fieldName.equals(GatewayMessages.DATA_FIELD)) { + dataStart = jp.getTokenLocation().getByteOffset(); + if (current.isScalarValue()) { + if (!current.isNumeric() && !current.isBoolean()) { + jp.getValueAsString(); + } + } else if (current.isStructStart()) { + jp.skipChildren(); + } + dataEnd = jp.getCurrentLocation().getByteOffset(); + } else { + // headers + result.header(fieldName, jp.getValueAsString()); + } + } + // data + if (dataEnd > dataStart) { + result.data(byteBuf.copy((int) dataStart, (int) (dataEnd - dataStart))); + } + return result.build(); + } catch (Throwable ex) { + throw new MessageCodecException("Failed to decode gateway service message", ex); + } + } + + private static ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + mapper.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true); + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + mapper.configure(SerializationFeature.WRITE_ENUMS_USING_TO_STRING, true); + mapper.registerModule(new JavaTimeModule()); + mapper.findAndRegisterModules(); + return mapper; + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/AbstractGatewayExtension.java b/services-gateway/src/test/java/io/scalecube/services/gateway/AbstractGatewayExtension.java new file mode 100644 index 000000000..835a0e4af --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/AbstractGatewayExtension.java @@ -0,0 +1,139 @@ +package io.scalecube.services.gateway; + +import io.scalecube.net.Address; +import io.scalecube.services.Microservices; +import io.scalecube.services.ServiceCall; +import io.scalecube.services.ServiceEndpoint; +import io.scalecube.services.ServiceInfo; +import io.scalecube.services.discovery.ScalecubeServiceDiscovery; +import io.scalecube.services.discovery.api.ServiceDiscovery; +import io.scalecube.services.gateway.transport.GatewayClientSettings; +import io.scalecube.services.gateway.transport.StaticAddressRouter; +import io.scalecube.services.transport.api.ClientTransport; +import io.scalecube.services.transport.rsocket.RSocketServiceTransport; +import io.scalecube.transport.netty.websocket.WebsocketTransportFactory; +import java.util.function.Function; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class AbstractGatewayExtension + implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback { + + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractGatewayExtension.class); + + private final ServiceInfo serviceInfo; + private final Function gatewaySupplier; + private final Function clientSupplier; + + private String gatewayId; + private Microservices gateway; + private Microservices services; + private ServiceCall clientServiceCall; + + protected AbstractGatewayExtension( + ServiceInfo serviceInfo, + Function gatewaySupplier, + Function clientSupplier) { + this.serviceInfo = serviceInfo; + this.gatewaySupplier = gatewaySupplier; + this.clientSupplier = clientSupplier; + } + + @Override + public final void beforeAll(ExtensionContext context) { + gateway = + Microservices.builder() + .discovery( + serviceEndpoint -> + new ScalecubeServiceDiscovery() + .transport(cfg -> cfg.transportFactory(new WebsocketTransportFactory())) + .options(opts -> opts.metadata(serviceEndpoint))) + .transport(RSocketServiceTransport::new) + .gateway( + options -> { + Gateway gateway = gatewaySupplier.apply(options); + gatewayId = gateway.id(); + return gateway; + }) + .startAwait(); + startServices(); + } + + @Override + public final void beforeEach(ExtensionContext context) { + // if services was shutdown in test need to start them again + if (services == null) { + startServices(); + } + Address gatewayAddress = gateway.gateway(gatewayId).address(); + GatewayClientSettings clintSettings = + GatewayClientSettings.builder().address(gatewayAddress).build(); + clientServiceCall = + new ServiceCall() + .transport(clientSupplier.apply(clintSettings)) + .router(new StaticAddressRouter(gatewayAddress)); + } + + @Override + public final void afterEach(ExtensionContext context) { + // no-op + } + + @Override + public final void afterAll(ExtensionContext context) { + shutdownServices(); + shutdownGateway(); + } + + public ServiceCall client() { + return clientServiceCall; + } + + public void startServices() { + services = + Microservices.builder() + .discovery(this::serviceDiscovery) + .transport(RSocketServiceTransport::new) + .services(serviceInfo) + .startAwait(); + LOGGER.info("Started services {} on {}", services, services.serviceAddress()); + } + + private ServiceDiscovery serviceDiscovery(ServiceEndpoint serviceEndpoint) { + return new ScalecubeServiceDiscovery() + .transport(cfg -> cfg.transportFactory(new WebsocketTransportFactory())) + .options(opts -> opts.metadata(serviceEndpoint)) + .membership(opts -> opts.seedMembers(gateway.discoveryAddress())); + } + + public void shutdownServices() { + if (services != null) { + try { + services.shutdown().block(); + } catch (Throwable ignore) { + // ignore + } + LOGGER.info("Shutdown services {}", services); + + // if this method is called in particular test need to indicate that services are stopped to + // start them again before another test + services = null; + } + } + + private void shutdownGateway() { + if (gateway != null) { + try { + gateway.shutdown().block(); + } catch (Throwable ignore) { + // ignore + } + LOGGER.info("Shutdown gateway {}", gateway); + } + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/AbstractLocalGatewayExtension.java b/services-gateway/src/test/java/io/scalecube/services/gateway/AbstractLocalGatewayExtension.java new file mode 100644 index 000000000..83c02b3c6 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/AbstractLocalGatewayExtension.java @@ -0,0 +1,92 @@ +package io.scalecube.services.gateway; + +import io.scalecube.net.Address; +import io.scalecube.services.Microservices; +import io.scalecube.services.ServiceCall; +import io.scalecube.services.ServiceInfo; +import io.scalecube.services.gateway.transport.GatewayClientSettings; +import io.scalecube.services.gateway.transport.StaticAddressRouter; +import io.scalecube.services.transport.api.ClientTransport; +import java.util.Optional; +import java.util.function.Function; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.netty.resources.LoopResources; + +public abstract class AbstractLocalGatewayExtension + implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback { + + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractLocalGatewayExtension.class); + + private final ServiceInfo serviceInfo; + private final Function gatewaySupplier; + private final Function clientSupplier; + + private Microservices gateway; + private LoopResources clientLoopResources; + private ServiceCall clientServiceCall; + private String gatewayId; + + protected AbstractLocalGatewayExtension( + ServiceInfo serviceInfo, + Function gatewaySupplier, + Function clientSupplier) { + this.serviceInfo = serviceInfo; + this.gatewaySupplier = gatewaySupplier; + this.clientSupplier = clientSupplier; + } + + @Override + public final void beforeAll(ExtensionContext context) { + + gateway = + Microservices.builder() + .services(serviceInfo) + .gateway( + options -> { + Gateway gateway = gatewaySupplier.apply(options); + gatewayId = gateway.id(); + return gateway; + }) + .startAwait(); + + clientLoopResources = LoopResources.create("gateway-client-transport-worker"); + } + + @Override + public final void beforeEach(ExtensionContext context) { + Address address = gateway.gateway(gatewayId).address(); + + GatewayClientSettings settings = GatewayClientSettings.builder().address(address).build(); + + clientServiceCall = + new ServiceCall() + .transport(clientSupplier.apply(settings)) + .router(new StaticAddressRouter(address)); + } + + @Override + public final void afterAll(ExtensionContext context) { + Optional.ofNullable(clientLoopResources).ifPresent(LoopResources::dispose); + shutdownGateway(); + } + + public ServiceCall client() { + return clientServiceCall; + } + + private void shutdownGateway() { + if (gateway != null) { + try { + gateway.shutdown().block(); + } catch (Throwable ignore) { + // ignore + } + LOGGER.info("Shutdown gateway {}", gateway); + } + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/AuthRegistry.java b/services-gateway/src/test/java/io/scalecube/services/gateway/AuthRegistry.java new file mode 100644 index 000000000..dbf7c163f --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/AuthRegistry.java @@ -0,0 +1,58 @@ +package io.scalecube.services.gateway; + +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** So called "guess username" authentication. All preconfigured users can be authenticated. */ +public class AuthRegistry { + + static final String SESSION_ID = "SESSION_ID"; + + /** Preconfigured userName-s that are allowed to be authenticated. */ + private final Set allowedUsers; + + private ConcurrentMap loggedInUsers = new ConcurrentHashMap<>(); + + public AuthRegistry(Set allowedUsers) { + this.allowedUsers = allowedUsers; + } + + /** + * Get session's auth data if exists. + * + * @param sessionId session id to get auth info for + * @return auth info for given session if exists + */ + public Optional getAuth(long sessionId) { + return Optional.ofNullable(loggedInUsers.get(sessionId)); + } + + /** + * Add session with auth t registry. + * + * @param sessionId session id to add auth info for + * @param auth auth info for given session id + * @return auth info added for session id or empty if auth info is invalid + */ + public Optional addAuth(long sessionId, String auth) { + if (allowedUsers.contains(auth)) { + loggedInUsers.putIfAbsent(sessionId, auth); + return Optional.of(auth); + } else { + System.err.println("User not in list of ALLOWED: " + auth); + } + return Optional.empty(); + } + + /** + * Remove session from registry. + * + * @param sessionId session id to be removed from registry + * @return true if session had auth info, false - otherwise + */ + public String removeAuth(long sessionId) { + return loggedInUsers.remove(sessionId); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/BaseTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/BaseTest.java new file mode 100644 index 000000000..bdc5db3d8 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/BaseTest.java @@ -0,0 +1,33 @@ +package io.scalecube.services.gateway; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class BaseTest { + + protected static final Logger LOGGER = LoggerFactory.getLogger(BaseTest.class); + + @BeforeEach + public final void baseSetUp(TestInfo testInfo) { + LOGGER.info( + "***** Test started : " + + getClass().getSimpleName() + + "." + + testInfo.getDisplayName() + + " *****"); + } + + @AfterEach + public final void baseTearDown(TestInfo testInfo) { + System.gc(); + LOGGER.info( + "***** Test finished : " + + getClass().getSimpleName() + + "." + + testInfo.getDisplayName() + + " *****"); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/GatewaySessionHandlerImpl.java b/services-gateway/src/test/java/io/scalecube/services/gateway/GatewaySessionHandlerImpl.java new file mode 100644 index 000000000..f036f1049 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/GatewaySessionHandlerImpl.java @@ -0,0 +1,41 @@ +package io.scalecube.services.gateway; + +import io.netty.buffer.ByteBuf; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.auth.Authenticator; +import java.util.Optional; +import reactor.util.context.Context; + +public class GatewaySessionHandlerImpl implements GatewaySessionHandler { + + private final AuthRegistry authRegistry; + + public GatewaySessionHandlerImpl(AuthRegistry authRegistry) { + this.authRegistry = authRegistry; + } + + @Override + public Context onRequest(GatewaySession session, ByteBuf byteBuf, Context context) { + Optional authData = authRegistry.getAuth(session.sessionId()); + return authData.map(s -> context.put(Authenticator.AUTH_CONTEXT_KEY, s)).orElse(context); + } + + @Override + public ServiceMessage mapMessage( + GatewaySession session, ServiceMessage message, Context context) { + return ServiceMessage.from(message) + .header(AuthRegistry.SESSION_ID, session.sessionId()) + .build(); + } + + @Override + public void onSessionOpen(GatewaySession s) { + LOGGER.info("Session opened: {}", s); + } + + @Override + public void onSessionClose(GatewaySession session) { + LOGGER.info("Session removed: {}", session); + authRegistry.removeAuth(session.sessionId()); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/SecuredService.java b/services-gateway/src/test/java/io/scalecube/services/gateway/SecuredService.java new file mode 100644 index 000000000..8a82038d4 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/SecuredService.java @@ -0,0 +1,31 @@ +package io.scalecube.services.gateway; + +import static io.scalecube.services.gateway.SecuredService.NS; + +import io.scalecube.services.annotations.RequestType; +import io.scalecube.services.annotations.Service; +import io.scalecube.services.annotations.ServiceMethod; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.auth.Secured; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** Authentication service and the service body itself in one class. */ +@Service(NS) +public interface SecuredService { + String NS = "gw.auth"; + + @ServiceMethod + @RequestType(String.class) + Mono createSession(ServiceMessage request); + + @ServiceMethod + @RequestType(String.class) + @Secured + Mono requestOne(String req); + + @ServiceMethod + @RequestType(Integer.class) + @Secured + Flux requestN(Integer req); +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/SecuredServiceImpl.java b/services-gateway/src/test/java/io/scalecube/services/gateway/SecuredServiceImpl.java new file mode 100644 index 000000000..89059a948 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/SecuredServiceImpl.java @@ -0,0 +1,74 @@ +package io.scalecube.services.gateway; + +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.auth.Authenticator; +import io.scalecube.services.exceptions.BadRequestException; +import io.scalecube.services.exceptions.ForbiddenException; +import java.util.Optional; +import java.util.stream.IntStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class SecuredServiceImpl implements SecuredService { + private static final Logger LOGGER = LoggerFactory.getLogger(SecuredServiceImpl.class); + + private static final String ALLOWED_USER = "VASYA_PUPKIN"; + + private final AuthRegistry authRegistry; + + public SecuredServiceImpl(AuthRegistry authRegistry) { + this.authRegistry = authRegistry; + } + + @Override + public Mono createSession(ServiceMessage request) { + String sessionId = request.header(AuthRegistry.SESSION_ID); + if (sessionId == null) { + return Mono.error(new BadRequestException("session Id is not present in request") {}); + } + String req = request.data(); + Optional authResult = authRegistry.addAuth(Long.parseLong(sessionId), req); + if (authResult.isPresent()) { + return Mono.just(req); + } else { + return Mono.error(new ForbiddenException("User not allowed to use this service: " + req)); + } + } + + @Override + public Mono requestOne(String req) { + return Mono.deferContextual(context -> Mono.just(context.get(Authenticator.AUTH_CONTEXT_KEY))) + .doOnNext(this::checkPermissions) + .cast(String.class) + .flatMap( + auth -> { + LOGGER.info("User {} has accessed secured call", auth); + return Mono.just(auth + "@" + req); + }); + } + + @Override + public Flux requestN(Integer times) { + return Mono.deferContextual(context -> Mono.just(context.get(Authenticator.AUTH_CONTEXT_KEY))) + .doOnNext(this::checkPermissions) + .cast(String.class) + .flatMapMany( + auth -> { + if (times <= 0) { + return Flux.empty(); + } + return Flux.fromStream(IntStream.range(0, times).mapToObj(String::valueOf)); + }); + } + + private void checkPermissions(Object authData) { + if (authData == null) { + throw new ForbiddenException("Not allowed (authData is null)"); + } + if (!authData.equals(ALLOWED_USER)) { + throw new ForbiddenException("Not allowed (wrong user)"); + } + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/TestGatewaySessionHandler.java b/services-gateway/src/test/java/io/scalecube/services/gateway/TestGatewaySessionHandler.java new file mode 100644 index 000000000..e4562add8 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/TestGatewaySessionHandler.java @@ -0,0 +1,35 @@ +package io.scalecube.services.gateway; + +import io.scalecube.services.api.ServiceMessage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import reactor.util.context.Context; + +public class TestGatewaySessionHandler implements GatewaySessionHandler { + + public final CountDownLatch msgLatch = new CountDownLatch(1); + public final CountDownLatch connLatch = new CountDownLatch(1); + public final CountDownLatch disconnLatch = new CountDownLatch(1); + private final AtomicReference lastSession = new AtomicReference<>(); + + @Override + public ServiceMessage mapMessage(GatewaySession s, ServiceMessage req, Context context) { + msgLatch.countDown(); + return req; + } + + @Override + public void onSessionOpen(GatewaySession s) { + connLatch.countDown(); + lastSession.set(s); + } + + @Override + public void onSessionClose(GatewaySession s) { + disconnLatch.countDown(); + } + + public GatewaySession lastSession() { + return lastSession.get(); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/TestService.java b/services-gateway/src/test/java/io/scalecube/services/gateway/TestService.java new file mode 100644 index 000000000..de3f1526c --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/TestService.java @@ -0,0 +1,19 @@ +package io.scalecube.services.gateway; + +import io.scalecube.services.annotations.Service; +import io.scalecube.services.annotations.ServiceMethod; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Service +public interface TestService { + + @ServiceMethod("manyNever") + Flux manyNever(); + + @ServiceMethod + Mono one(String one); + + @ServiceMethod + Mono oneErr(String one); +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/TestServiceImpl.java b/services-gateway/src/test/java/io/scalecube/services/gateway/TestServiceImpl.java new file mode 100644 index 000000000..1983b16be --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/TestServiceImpl.java @@ -0,0 +1,29 @@ +package io.scalecube.services.gateway; + +import io.scalecube.services.exceptions.ForbiddenException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class TestServiceImpl implements TestService { + + private final Runnable onClose; + + public TestServiceImpl(Runnable onClose) { + this.onClose = onClose; + } + + @Override + public Flux manyNever() { + return Flux.never().log(">>> ").doOnCancel(onClose); + } + + @Override + public Mono one(String one) { + return Mono.just(one); + } + + @Override + public Mono oneErr(String one) { + throw new ForbiddenException("forbidden"); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/TestUtils.java b/services-gateway/src/test/java/io/scalecube/services/gateway/TestUtils.java new file mode 100644 index 000000000..0742fc4d8 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/TestUtils.java @@ -0,0 +1,22 @@ +package io.scalecube.services.gateway; + +import java.time.Duration; +import java.util.function.BooleanSupplier; +import reactor.core.publisher.Mono; + +public final class TestUtils { + + public static final Duration TIMEOUT = Duration.ofSeconds(10); + + private TestUtils() {} + + /** + * Waits until the given condition is done + * + * @param condition condition + * @return operation's result + */ + public static Mono await(BooleanSupplier condition) { + return Mono.delay(Duration.ofMillis(100)).repeat(() -> !condition.getAsBoolean()).then(); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/exceptions/ErrorService.java b/services-gateway/src/test/java/io/scalecube/services/gateway/exceptions/ErrorService.java new file mode 100644 index 000000000..2ad2d183c --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/exceptions/ErrorService.java @@ -0,0 +1,16 @@ +package io.scalecube.services.gateway.exceptions; + +import io.scalecube.services.annotations.Service; +import io.scalecube.services.annotations.ServiceMethod; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Service +public interface ErrorService { + + @ServiceMethod + Flux manyError(); + + @ServiceMethod + Mono oneError(); +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/exceptions/ErrorServiceImpl.java b/services-gateway/src/test/java/io/scalecube/services/gateway/exceptions/ErrorServiceImpl.java new file mode 100644 index 000000000..ba5042ea5 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/exceptions/ErrorServiceImpl.java @@ -0,0 +1,17 @@ +package io.scalecube.services.gateway.exceptions; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class ErrorServiceImpl implements ErrorService { + + @Override + public Flux manyError() { + return Flux.error(new SomeException()); + } + + @Override + public Mono oneError() { + return Mono.error(new SomeException()); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/exceptions/GatewayErrorMapperImpl.java b/services-gateway/src/test/java/io/scalecube/services/gateway/exceptions/GatewayErrorMapperImpl.java new file mode 100644 index 000000000..792a1e457 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/exceptions/GatewayErrorMapperImpl.java @@ -0,0 +1,36 @@ +package io.scalecube.services.gateway.exceptions; + +import io.scalecube.services.api.ErrorData; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.exceptions.DefaultErrorMapper; +import io.scalecube.services.exceptions.ServiceClientErrorMapper; +import io.scalecube.services.exceptions.ServiceProviderErrorMapper; + +public class GatewayErrorMapperImpl + implements ServiceProviderErrorMapper, ServiceClientErrorMapper { + + public static final GatewayErrorMapperImpl ERROR_MAPPER = new GatewayErrorMapperImpl(); + + @Override + public Throwable toError(ServiceMessage message) { + if (SomeException.ERROR_TYPE == message.errorType()) { + final ErrorData data = message.data(); + if (SomeException.ERROR_CODE == data.getErrorCode()) { + return new SomeException(); + } + } + return DefaultErrorMapper.INSTANCE.toError(message); + } + + @Override + public ServiceMessage toMessage(String qualifier, Throwable throwable) { + if (throwable instanceof SomeException) { + final int errorCode = ((SomeException) throwable).errorCode(); + final int errorType = SomeException.ERROR_TYPE; + final String errorMessage = throwable.getMessage(); + return ServiceMessage.error(qualifier, errorType, errorCode, errorMessage); + } + + return DefaultErrorMapper.INSTANCE.toMessage(qualifier, throwable); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/exceptions/SomeException.java b/services-gateway/src/test/java/io/scalecube/services/gateway/exceptions/SomeException.java new file mode 100644 index 000000000..49bc79431 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/exceptions/SomeException.java @@ -0,0 +1,14 @@ +package io.scalecube.services.gateway.exceptions; + +import io.scalecube.services.exceptions.ServiceException; + +public class SomeException extends ServiceException { + + public static final int ERROR_TYPE = 4020; + public static final int ERROR_CODE = 42; + public static final String ERROR_MESSAGE = "smth happened"; + + public SomeException() { + super(ERROR_CODE, ERROR_MESSAGE); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/http/CorsTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/http/CorsTest.java new file mode 100644 index 000000000..d7b2ca0b0 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/http/CorsTest.java @@ -0,0 +1,139 @@ +package io.scalecube.services.gateway.http; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.scalecube.services.Microservices; +import io.scalecube.services.discovery.ScalecubeServiceDiscovery; +import io.scalecube.services.examples.GreetingService; +import io.scalecube.services.examples.GreetingServiceImpl; +import io.scalecube.services.gateway.BaseTest; +import io.scalecube.services.transport.rsocket.RSocketServiceTransport; +import io.scalecube.transport.netty.websocket.WebsocketTransportFactory; +import java.time.Duration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.netty.ByteBufFlux; +import reactor.netty.http.client.HttpClient; +import reactor.netty.http.client.HttpClientResponse; +import reactor.netty.resources.ConnectionProvider; + +public class CorsTest extends BaseTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(3); + public static final int HTTP_PORT = 8999; + + private Microservices gateway; + + private final Microservices.Builder gatewayBuilder = + Microservices.builder() + .discovery( + serviceEndpoint -> + new ScalecubeServiceDiscovery() + .transport(cfg -> cfg.transportFactory(new WebsocketTransportFactory())) + .options(opts -> opts.metadata(serviceEndpoint))) + .transport(RSocketServiceTransport::new) + .services(new GreetingServiceImpl()); + private HttpClient client; + + @BeforeEach + void beforeEach() { + client = HttpClient.create(ConnectionProvider.newConnection()).port(HTTP_PORT).wiretap(true); + } + + @AfterEach + void afterEach() { + if (gateway != null) { + gateway.shutdown().block(); + } + } + + @Test + void testCrossOriginRequest() { + gateway = + gatewayBuilder + .gateway( + opts -> + new HttpGateway(opts.id("http").port(HTTP_PORT)) + .corsEnabled(true) + .corsConfig( + config -> + config.allowedRequestHeaders("Content-Type", "X-Correlation-ID"))) + .start() + .block(TIMEOUT); + + HttpClientResponse response = + client + .headers( + headers -> + headers + .add("Origin", "test.com") + .add("Access-Control-Request-Method", "POST") + .add("Access-Control-Request-Headers", "Content-Type,X-Correlation-ID")) + .options() + .response() + .block(TIMEOUT); + + HttpHeaders responseHeaders = response.responseHeaders(); + + assertEquals(HttpResponseStatus.OK, response.status()); + assertEquals("*", responseHeaders.get("Access-Control-Allow-Origin")); + assertEquals("POST", responseHeaders.get("Access-Control-Allow-Methods")); + assertThat(responseHeaders.get("Access-Control-Allow-Headers"), containsString("Content-Type")); + assertThat( + responseHeaders.get("Access-Control-Allow-Headers"), containsString("X-Correlation-ID")); + + response = + client + .headers( + headers -> + headers + .add("Origin", "test.com") + .add("X-Correlation-ID", "xxxxxx") + .add("Content-Type", "application/json")) + .post() + .uri("/" + GreetingService.NAMESPACE + "/one") + .send(ByteBufFlux.fromString(Mono.just("\"Hello\""))) + .response() + .block(TIMEOUT); + + responseHeaders = response.responseHeaders(); + + assertEquals(HttpResponseStatus.OK, response.status()); + assertEquals("*", responseHeaders.get("Access-Control-Allow-Origin")); + } + + @Test + void testOptionRequestCorsDisabled() { + gateway = + gatewayBuilder + .gateway(opts -> new HttpGateway(opts.id("http").port(HTTP_PORT)).corsEnabled(false)) + .start() + .block(TIMEOUT); + + HttpClientResponse response = + client + .headers( + headers -> + headers + .add("Origin", "test.com") + .add("Access-Control-Request-Method", "POST") + .add("Access-Control-Request-Headers", "Content-Type,X-Correlation-ID")) + .options() + .response() + .block(TIMEOUT); + + HttpHeaders responseHeaders = response.responseHeaders(); + + assertEquals(HttpResponseStatus.METHOD_NOT_ALLOWED, response.status()); + assertNull(responseHeaders.get("Access-Control-Allow-Origin")); + assertNull(responseHeaders.get("Access-Control-Allow-Methods")); + assertNull(responseHeaders.get("Access-Control-Allow-Headers")); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpClientConnectionTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpClientConnectionTest.java new file mode 100644 index 000000000..52c0ec3b2 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpClientConnectionTest.java @@ -0,0 +1,136 @@ +package io.scalecube.services.gateway.http; + +import io.netty.buffer.ByteBuf; +import io.scalecube.net.Address; +import io.scalecube.services.Microservices; +import io.scalecube.services.ServiceCall; +import io.scalecube.services.annotations.Service; +import io.scalecube.services.annotations.ServiceMethod; +import io.scalecube.services.discovery.ScalecubeServiceDiscovery; +import io.scalecube.services.gateway.BaseTest; +import io.scalecube.services.gateway.transport.GatewayClient; +import io.scalecube.services.gateway.transport.GatewayClientCodec; +import io.scalecube.services.gateway.transport.GatewayClientSettings; +import io.scalecube.services.gateway.transport.GatewayClientTransport; +import io.scalecube.services.gateway.transport.GatewayClientTransports; +import io.scalecube.services.gateway.transport.StaticAddressRouter; +import io.scalecube.services.gateway.transport.http.HttpGatewayClient; +import io.scalecube.services.transport.rsocket.RSocketServiceTransport; +import io.scalecube.transport.netty.websocket.WebsocketTransportFactory; +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +class HttpClientConnectionTest extends BaseTest { + + public static final GatewayClientCodec CLIENT_CODEC = + GatewayClientTransports.HTTP_CLIENT_CODEC; + + private Microservices gateway; + private Address gatewayAddress; + private Microservices service; + + private static final AtomicInteger onCloseCounter = new AtomicInteger(); + private GatewayClient client; + + @BeforeEach + void beforEach() { + gateway = + Microservices.builder() + .discovery( + serviceEndpoint -> + new ScalecubeServiceDiscovery() + .transport(cfg -> cfg.transportFactory(new WebsocketTransportFactory())) + .options(opts -> opts.metadata(serviceEndpoint))) + .transport(RSocketServiceTransport::new) + .gateway(options -> new HttpGateway(options.id("HTTP"))) + .startAwait(); + + gatewayAddress = gateway.gateway("HTTP").address(); + + service = + Microservices.builder() + .discovery( + serviceEndpoint -> + new ScalecubeServiceDiscovery() + .transport(cfg -> cfg.transportFactory(new WebsocketTransportFactory())) + .options(opts -> opts.metadata(serviceEndpoint)) + .membership(opts -> opts.seedMembers(gateway.discoveryAddress()))) + .transport(RSocketServiceTransport::new) + .services(new TestServiceImpl()) + .startAwait(); + + onCloseCounter.set(0); + } + + @AfterEach + void afterEach() { + Flux.concat( + Mono.justOrEmpty(client).doOnNext(GatewayClient::close).flatMap(GatewayClient::onClose), + Mono.justOrEmpty(gateway).map(Microservices::shutdown), + Mono.justOrEmpty(service).map(Microservices::shutdown)) + .then() + .block(); + } + + @Test + void testCloseServiceStreamAfterLostConnection() { + client = + new HttpGatewayClient( + GatewayClientSettings.builder().address(gatewayAddress).build(), CLIENT_CODEC); + + ServiceCall serviceCall = + new ServiceCall() + .transport(new GatewayClientTransport(client)) + .router(new StaticAddressRouter(gatewayAddress)); + + StepVerifier.create(serviceCall.api(TestService.class).oneNever("body").log("<<< ")) + .thenAwait(Duration.ofSeconds(5)) + .then(() -> client.close()) + .then(() -> client.onClose().block()) + .expectError(IOException.class) + .verify(Duration.ofSeconds(1)); + } + + @Test + public void testCallRepeatedlyByInvalidAddress() { + Address invalidAddress = Address.create("localhost", 5050); + + client = + new HttpGatewayClient( + GatewayClientSettings.builder().address(invalidAddress).build(), CLIENT_CODEC); + + ServiceCall serviceCall = + new ServiceCall() + .transport(new GatewayClientTransport(client)) + .router(new StaticAddressRouter(invalidAddress)); + + for (int i = 0; i < 100; i++) { + StepVerifier.create(serviceCall.api(TestService.class).oneNever("body").log("<<< ")) + .thenAwait(Duration.ofSeconds(1)) + .expectError(IOException.class) + .verify(Duration.ofSeconds(10)); + } + } + + @Service + public interface TestService { + + @ServiceMethod("oneNever") + Mono oneNever(String name); + } + + private static class TestServiceImpl implements TestService { + + @Override + public Mono oneNever(String name) { + return Mono.never().log(">>> ").doOnCancel(onCloseCounter::incrementAndGet); + } + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpClientErrorMapperTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpClientErrorMapperTest.java new file mode 100644 index 000000000..88299caf9 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpClientErrorMapperTest.java @@ -0,0 +1,38 @@ +package io.scalecube.services.gateway.http; + +import static io.scalecube.services.gateway.TestUtils.TIMEOUT; +import static io.scalecube.services.gateway.exceptions.GatewayErrorMapperImpl.ERROR_MAPPER; + +import io.scalecube.services.ServiceInfo; +import io.scalecube.services.gateway.BaseTest; +import io.scalecube.services.gateway.exceptions.ErrorService; +import io.scalecube.services.gateway.exceptions.ErrorServiceImpl; +import io.scalecube.services.gateway.exceptions.SomeException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import reactor.test.StepVerifier; + +@Disabled("Cannot deserialize instance of `java.lang.String` out of START_OBJECT token") +class HttpClientErrorMapperTest extends BaseTest { + + @RegisterExtension + static HttpGatewayExtension extension = + new HttpGatewayExtension( + ServiceInfo.fromServiceInstance(new ErrorServiceImpl()) + .errorMapper(ERROR_MAPPER) + .build()); + + private ErrorService service; + + @BeforeEach + void initService() { + service = extension.client().errorMapper(ERROR_MAPPER).api(ErrorService.class); + } + + @Test + void shouldReturnSomeExceptionOnMono() { + StepVerifier.create(service.oneError()).expectError(SomeException.class).verify(TIMEOUT); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpGatewayExtension.java b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpGatewayExtension.java new file mode 100644 index 000000000..cb0c4de5a --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpGatewayExtension.java @@ -0,0 +1,21 @@ +package io.scalecube.services.gateway.http; + +import io.scalecube.services.ServiceInfo; +import io.scalecube.services.gateway.AbstractGatewayExtension; +import io.scalecube.services.gateway.transport.GatewayClientTransports; + +class HttpGatewayExtension extends AbstractGatewayExtension { + + private static final String GATEWAY_ALIAS_NAME = "http"; + + HttpGatewayExtension(Object serviceInstance) { + this(ServiceInfo.fromServiceInstance(serviceInstance).build()); + } + + HttpGatewayExtension(ServiceInfo serviceInfo) { + super( + serviceInfo, + opts -> new HttpGateway(opts.id(GATEWAY_ALIAS_NAME)), + GatewayClientTransports::httpGatewayClientTransport); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpGatewayTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpGatewayTest.java new file mode 100644 index 000000000..4ece45976 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpGatewayTest.java @@ -0,0 +1,142 @@ +package io.scalecube.services.gateway.http; + +import static org.hamcrest.CoreMatchers.startsWith; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.scalecube.services.api.Qualifier; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.examples.EmptyGreetingRequest; +import io.scalecube.services.examples.EmptyGreetingResponse; +import io.scalecube.services.examples.GreetingRequest; +import io.scalecube.services.examples.GreetingService; +import io.scalecube.services.examples.GreetingServiceImpl; +import io.scalecube.services.exceptions.InternalServiceException; +import io.scalecube.services.exceptions.ServiceUnavailableException; +import io.scalecube.services.gateway.BaseTest; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import reactor.test.StepVerifier; + +class HttpGatewayTest extends BaseTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(3); + + @RegisterExtension + static HttpGatewayExtension extension = new HttpGatewayExtension(new GreetingServiceImpl()); + + private GreetingService service; + + @BeforeEach + void initService() { + service = extension.client().api(GreetingService.class); + } + + @Test + void shouldReturnSingleResponseWithSimpleRequest() { + StepVerifier.create(service.one("hello")) + .expectNext("Echo:hello") + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnSingleResponseWithSimpleLongDataRequest() { + String data = new String(new char[500]); + StepVerifier.create(service.one(data)) + .expectNext("Echo:" + data) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnSingleResponseWithPojoRequest() { + StepVerifier.create(service.pojoOne(new GreetingRequest("hello"))) + .expectNextMatches(response -> "Echo:hello".equals(response.getText())) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnListResponseWithPojoRequest() { + StepVerifier.create(service.pojoList(new GreetingRequest("hello"))) + .expectNextMatches(response -> "Echo:hello".equals(response.get(0).getText())) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnNoContentWhenResponseIsEmpty() { + StepVerifier.create(service.emptyOne("hello")).expectComplete().verify(TIMEOUT); + } + + @Test + void shouldReturnServiceUnavailableWhenServiceIsDown() { + extension.shutdownServices(); + + StepVerifier.create(service.one("hello")) + .expectErrorSatisfies( + throwable -> { + assertEquals(ServiceUnavailableException.class, throwable.getClass()); + assertThat( + throwable.getMessage(), startsWith("No reachable member with such service:")); + }) + .verify(TIMEOUT); + } + + @Test + void shouldReturnInternalServerErrorWhenServiceFails() { + StepVerifier.create(service.failingOne("hello")) + .expectErrorSatisfies( + throwable -> { + assertEquals(InternalServiceException.class, throwable.getClass()); + assertEquals("hello", throwable.getMessage()); + }) + .verify(TIMEOUT); + } + + @Test + void shouldSuccessfullyReuseServiceProxy() { + StepVerifier.create(service.one("hello")) + .expectNext("Echo:hello") + .expectComplete() + .verify(TIMEOUT); + + StepVerifier.create(service.one("hello")) + .expectNext("Echo:hello") + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnNoEventOnNeverService() { + StepVerifier.create(service.neverOne("hi")) + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(1)) + .thenCancel() + .verify(); + } + + @Test + void shouldReturnOnEmptyGreeting() { + StepVerifier.create(service.emptyGreeting(new EmptyGreetingRequest())) + .expectSubscription() + .expectNextMatches(resp -> resp instanceof EmptyGreetingResponse) + .thenCancel() + .verify(); + } + + @Test + void shouldReturnOnEmptyMessageGreeting() { + String qualifier = Qualifier.asString(GreetingService.NAMESPACE, "empty/wrappedPojo"); + ServiceMessage request = + ServiceMessage.builder().qualifier(qualifier).data(new EmptyGreetingRequest()).build(); + StepVerifier.create(extension.client().requestOne(request, EmptyGreetingResponse.class)) + .expectSubscription() + .expectNextMatches(resp -> resp.data() instanceof EmptyGreetingResponse) + .thenCancel() + .verify(); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpLocalGatewayErrorMapperTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpLocalGatewayErrorMapperTest.java new file mode 100644 index 000000000..068631242 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpLocalGatewayErrorMapperTest.java @@ -0,0 +1,37 @@ +package io.scalecube.services.gateway.http; + +import static io.scalecube.services.gateway.TestUtils.TIMEOUT; +import static io.scalecube.services.gateway.exceptions.GatewayErrorMapperImpl.ERROR_MAPPER; + +import io.scalecube.services.ServiceInfo; +import io.scalecube.services.gateway.BaseTest; +import io.scalecube.services.gateway.exceptions.ErrorService; +import io.scalecube.services.gateway.exceptions.ErrorServiceImpl; +import io.scalecube.services.gateway.exceptions.SomeException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import reactor.test.StepVerifier; + +@Disabled("Cannot deserialize instance of `java.lang.String` out of START_OBJECT token") +class HttpLocalGatewayErrorMapperTest extends BaseTest { + + @RegisterExtension + static HttpLocalGatewayExtension extension = + new HttpLocalGatewayExtension( + ServiceInfo.fromServiceInstance(new ErrorServiceImpl()).errorMapper(ERROR_MAPPER).build(), + opts -> new HttpGateway(opts.call(opts.call().errorMapper(ERROR_MAPPER)), ERROR_MAPPER)); + + private ErrorService service; + + @BeforeEach + void initService() { + service = extension.client().errorMapper(ERROR_MAPPER).api(ErrorService.class); + } + + @Test + void shouldReturnSomeExceptionOnMono() { + StepVerifier.create(service.oneError()).expectError(SomeException.class).verify(TIMEOUT); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpLocalGatewayExtension.java b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpLocalGatewayExtension.java new file mode 100644 index 000000000..098051647 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpLocalGatewayExtension.java @@ -0,0 +1,28 @@ +package io.scalecube.services.gateway.http; + +import io.scalecube.services.ServiceInfo; +import io.scalecube.services.gateway.AbstractLocalGatewayExtension; +import io.scalecube.services.gateway.GatewayOptions; +import io.scalecube.services.gateway.transport.GatewayClientTransports; +import java.util.function.Function; + +class HttpLocalGatewayExtension extends AbstractLocalGatewayExtension { + + private static final String GATEWAY_ALIAS_NAME = "http"; + + HttpLocalGatewayExtension(Object serviceInstance) { + this(ServiceInfo.fromServiceInstance(serviceInstance).build()); + } + + HttpLocalGatewayExtension(ServiceInfo serviceInfo) { + this(serviceInfo, HttpGateway::new); + } + + HttpLocalGatewayExtension( + ServiceInfo serviceInfo, Function gatewaySupplier) { + super( + serviceInfo, + opts -> gatewaySupplier.apply(opts.id(GATEWAY_ALIAS_NAME)), + GatewayClientTransports::httpGatewayClientTransport); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpLocalGatewayTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpLocalGatewayTest.java new file mode 100644 index 000000000..715711b3f --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/http/HttpLocalGatewayTest.java @@ -0,0 +1,126 @@ +package io.scalecube.services.gateway.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.scalecube.services.api.Qualifier; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.examples.EmptyGreetingRequest; +import io.scalecube.services.examples.EmptyGreetingResponse; +import io.scalecube.services.examples.GreetingRequest; +import io.scalecube.services.examples.GreetingService; +import io.scalecube.services.examples.GreetingServiceImpl; +import io.scalecube.services.exceptions.InternalServiceException; +import io.scalecube.services.gateway.BaseTest; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import reactor.test.StepVerifier; + +class HttpLocalGatewayTest extends BaseTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(3); + + @RegisterExtension + static HttpLocalGatewayExtension extension = + new HttpLocalGatewayExtension(new GreetingServiceImpl()); + + private GreetingService service; + + @BeforeEach + void initService() { + service = extension.client().api(GreetingService.class); + } + + @Test + void shouldReturnSingleResponseWithSimpleRequest() { + StepVerifier.create(service.one("hello")) + .expectNext("Echo:hello") + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnSingleResponseWithSimpleLongDataRequest() { + String data = new String(new char[500]); + StepVerifier.create(service.one(data)) + .expectNext("Echo:" + data) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnSingleResponseWithPojoRequest() { + StepVerifier.create(service.pojoOne(new GreetingRequest("hello"))) + .expectNextMatches(response -> "Echo:hello".equals(response.getText())) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnListResponseWithPojoRequest() { + StepVerifier.create(service.pojoList(new GreetingRequest("hello"))) + .expectNextMatches(response -> "Echo:hello".equals(response.get(0).getText())) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnNoContentWhenResponseIsEmpty() { + StepVerifier.create(service.emptyOne("hello")).expectComplete().verify(TIMEOUT); + } + + @Test + void shouldReturnInternalServerErrorWhenServiceFails() { + StepVerifier.create(service.failingOne("hello")) + .expectErrorSatisfies( + throwable -> { + assertEquals(InternalServiceException.class, throwable.getClass()); + assertEquals("hello", throwable.getMessage()); + }) + .verify(TIMEOUT); + } + + @Test + void shouldSuccessfullyReuseServiceProxy() { + StepVerifier.create(service.one("hello")) + .expectNext("Echo:hello") + .expectComplete() + .verify(TIMEOUT); + + StepVerifier.create(service.one("hello")) + .expectNext("Echo:hello") + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnNoEventOnNeverService() { + StepVerifier.create(service.neverOne("hi")) + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(1)) + .thenCancel() + .verify(); + } + + @Test + void shouldReturnOnEmptyGreeting() { + StepVerifier.create(service.emptyGreeting(new EmptyGreetingRequest())) + .expectSubscription() + .expectNextMatches(resp -> resp instanceof EmptyGreetingResponse) + .thenCancel() + .verify(); + } + + @Test + void shouldReturnOnEmptyMessageGreeting() { + String qualifier = Qualifier.asString(GreetingService.NAMESPACE, "empty/wrappedPojo"); + ServiceMessage request = + ServiceMessage.builder().qualifier(qualifier).data(new EmptyGreetingRequest()).build(); + StepVerifier.create(extension.client().requestOne(request, EmptyGreetingResponse.class)) + .expectSubscription() + .expectNextMatches(resp -> resp.data() instanceof EmptyGreetingResponse) + .thenCancel() + .verify(); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/CancelledSubscriber.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/CancelledSubscriber.java new file mode 100644 index 000000000..2e7cc6b5f --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/CancelledSubscriber.java @@ -0,0 +1,36 @@ +package io.scalecube.services.gateway.websocket; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.CoreSubscriber; + +public class CancelledSubscriber implements CoreSubscriber { + + private static final Logger LOGGER = LoggerFactory.getLogger(CancelledSubscriber.class); + + public static final CancelledSubscriber INSTANCE = new CancelledSubscriber(); + + private CancelledSubscriber() { + // Do not instantiate + } + + @Override + public void onSubscribe(org.reactivestreams.Subscription s) { + // no-op + } + + @Override + public void onNext(Object o) { + LOGGER.warn("Received ({}) which will be dropped immediately due cancelled aeron inbound", o); + } + + @Override + public void onError(Throwable t) { + // no-op + } + + @Override + public void onComplete() { + // no-op + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/ReactiveAdapter.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/ReactiveAdapter.java new file mode 100644 index 000000000..738a81a7d --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/ReactiveAdapter.java @@ -0,0 +1,176 @@ +package io.scalecube.services.gateway.websocket; + +import java.util.concurrent.atomic.AtomicLongFieldUpdater; +import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; +import org.reactivestreams.Subscription; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.CoreSubscriber; +import reactor.core.Exceptions; +import reactor.core.publisher.BaseSubscriber; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Operators; + +public final class ReactiveAdapter extends BaseSubscriber implements ReactiveOperator { + + private static final Logger LOGGER = LoggerFactory.getLogger(ReactiveAdapter.class); + + private static final AtomicLongFieldUpdater REQUESTED = + AtomicLongFieldUpdater.newUpdater(ReactiveAdapter.class, "requested"); + + @SuppressWarnings("rawtypes") + private static final AtomicReferenceFieldUpdater + DESTINATION_SUBSCRIBER = + AtomicReferenceFieldUpdater.newUpdater( + ReactiveAdapter.class, CoreSubscriber.class, "destinationSubscriber"); + + private final FluxReceive inbound = new FluxReceive(); + + private volatile long requested; + private volatile boolean fastPath; + private long produced; + private volatile CoreSubscriber destinationSubscriber; + private Throwable lastError; + + @Override + public boolean isDisposed() { + return destinationSubscriber == CancelledSubscriber.INSTANCE; + } + + @Override + public void dispose(Throwable throwable) { + Subscription upstream = upstream(); + if (upstream != null) { + upstream.cancel(); + } + CoreSubscriber destination = + DESTINATION_SUBSCRIBER.getAndSet(this, CancelledSubscriber.INSTANCE); + if (destination != null) { + destination.onError(throwable); + } + } + + @Override + public void dispose() { + inbound.cancel(); + } + + public Flux receive() { + return inbound; + } + + @Override + public void lastError(Throwable throwable) { + lastError = throwable; + } + + @Override + public Throwable lastError() { + return lastError; + } + + @Override + public void tryNext(Object Object) { + if (!isDisposed()) { + destinationSubscriber.onNext(Object); + } else { + LOGGER.warn("[tryNext] reactiveAdapter is disposed, dropping : " + Object); + } + } + + @Override + public boolean isFastPath() { + return fastPath; + } + + @Override + public void commitProduced() { + if (produced > 0) { + Operators.produced(REQUESTED, this, produced); + produced = 0; + } + } + + @Override + public long incrementProduced() { + return ++produced; + } + + @Override + public long requested(long limit) { + return Math.min(requested, limit); + } + + @Override + protected void hookOnSubscribe(Subscription subscription) { + subscription.request(requested); + } + + @Override + protected void hookOnNext(Object Object) { + tryNext(Object); + } + + @Override + protected void hookOnComplete() { + dispose(); + } + + @Override + protected void hookOnError(Throwable throwable) { + dispose(throwable); + } + + @Override + protected void hookOnCancel() { + dispose(); + } + + class FluxReceive extends Flux implements Subscription { + + @Override + public void request(long n) { + Subscription upstream = upstream(); + if (upstream != null) { + upstream.request(n); + } + if (fastPath) { + return; + } + if (n == Long.MAX_VALUE) { + fastPath = true; + requested = Long.MAX_VALUE; + return; + } + Operators.addCap(REQUESTED, ReactiveAdapter.this, n); + } + + @Override + public void cancel() { + Subscription upstream = upstream(); + if (upstream != null) { + upstream.cancel(); + } + CoreSubscriber destination = + DESTINATION_SUBSCRIBER.getAndSet(ReactiveAdapter.this, CancelledSubscriber.INSTANCE); + if (destination != null) { + destination.onComplete(); + } + } + + @Override + public void subscribe(CoreSubscriber destinationSubscriber) { + boolean result = + DESTINATION_SUBSCRIBER.compareAndSet(ReactiveAdapter.this, null, destinationSubscriber); + if (result) { + destinationSubscriber.onSubscribe(this); + } else { + Operators.error( + destinationSubscriber, + isDisposed() + ? Exceptions.failWithCancel() + : Exceptions.duplicateOnSubscribeException()); + } + } + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/ReactiveOperator.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/ReactiveOperator.java new file mode 100644 index 000000000..5007a539b --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/ReactiveOperator.java @@ -0,0 +1,22 @@ +package io.scalecube.services.gateway.websocket; + +import reactor.core.Disposable; + +public interface ReactiveOperator extends Disposable { + + void dispose(Throwable throwable); + + void lastError(Throwable throwable); + + Throwable lastError(); + + void tryNext(Object fragment); + + boolean isFastPath(); + + void commitProduced(); + + long incrementProduced(); + + long requested(long limit); +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketClientConnectionTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketClientConnectionTest.java new file mode 100644 index 000000000..2732ebbc6 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketClientConnectionTest.java @@ -0,0 +1,238 @@ +package io.scalecube.services.gateway.websocket; + +import static io.scalecube.services.gateway.TestUtils.TIMEOUT; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.handler.codec.http.websocketx.PongWebSocketFrame; +import io.scalecube.net.Address; +import io.scalecube.services.Microservices; +import io.scalecube.services.ServiceCall; +import io.scalecube.services.discovery.ScalecubeServiceDiscovery; +import io.scalecube.services.gateway.BaseTest; +import io.scalecube.services.gateway.TestGatewaySessionHandler; +import io.scalecube.services.gateway.TestService; +import io.scalecube.services.gateway.TestServiceImpl; +import io.scalecube.services.gateway.TestUtils; +import io.scalecube.services.gateway.transport.GatewayClient; +import io.scalecube.services.gateway.transport.GatewayClientCodec; +import io.scalecube.services.gateway.transport.GatewayClientSettings; +import io.scalecube.services.gateway.transport.GatewayClientTransport; +import io.scalecube.services.gateway.transport.GatewayClientTransports; +import io.scalecube.services.gateway.transport.StaticAddressRouter; +import io.scalecube.services.gateway.transport.websocket.WebsocketGatewayClient; +import io.scalecube.services.gateway.transport.websocket.WebsocketGatewayClientSession; +import io.scalecube.services.gateway.ws.WebsocketGateway; +import io.scalecube.services.transport.rsocket.RSocketServiceTransport; +import io.scalecube.transport.netty.websocket.WebsocketTransportFactory; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.Connection; +import reactor.test.StepVerifier; + +class WebsocketClientConnectionTest extends BaseTest { + + public static final GatewayClientCodec CLIENT_CODEC = + GatewayClientTransports.WEBSOCKET_CLIENT_CODEC; + private static final AtomicInteger onCloseCounter = new AtomicInteger(); + private Microservices gateway; + private Address gatewayAddress; + private Microservices service; + private GatewayClient client; + private TestGatewaySessionHandler sessionEventHandler; + + @BeforeEach + void beforEach() { + this.sessionEventHandler = new TestGatewaySessionHandler(); + gateway = + Microservices.builder() + .discovery( + serviceEndpoint -> + new ScalecubeServiceDiscovery() + .transport(cfg -> cfg.transportFactory(new WebsocketTransportFactory())) + .options(opts -> opts.metadata(serviceEndpoint))) + .transport(RSocketServiceTransport::new) + .gateway(options -> new WebsocketGateway(options.id("WS"), sessionEventHandler)) + .startAwait(); + + gatewayAddress = gateway.gateway("WS").address(); + + service = + Microservices.builder() + .discovery( + serviceEndpoint -> + new ScalecubeServiceDiscovery() + .transport(cfg -> cfg.transportFactory(new WebsocketTransportFactory())) + .options(opts -> opts.metadata(serviceEndpoint)) + .membership(opts -> opts.seedMembers(gateway.discoveryAddress()))) + .transport(RSocketServiceTransport::new) + .services(new TestServiceImpl(onCloseCounter::incrementAndGet)) + .startAwait(); + + onCloseCounter.set(0); + } + + @AfterEach + void afterEach() { + Flux.concat( + Mono.justOrEmpty(client).doOnNext(GatewayClient::close).flatMap(GatewayClient::onClose), + Mono.justOrEmpty(gateway).map(Microservices::shutdown), + Mono.justOrEmpty(service).map(Microservices::shutdown)) + .then() + .block(); + } + + @Test + void testCloseServiceStreamAfterLostConnection() { + client = + new WebsocketGatewayClient( + GatewayClientSettings.builder().address(gatewayAddress).build(), CLIENT_CODEC); + + ServiceCall serviceCall = + new ServiceCall() + .transport(new GatewayClientTransport(client)) + .router(new StaticAddressRouter(gatewayAddress)); + + StepVerifier.create(serviceCall.api(TestService.class).manyNever().log("<<< ")) + .thenAwait(Duration.ofSeconds(5)) + .then(() -> client.close()) + .then(() -> client.onClose().block()) + .expectError(IOException.class) + .verify(Duration.ofSeconds(10)); + + TestUtils.await(() -> onCloseCounter.get() == 1).block(TIMEOUT); + assertEquals(1, onCloseCounter.get()); + } + + @Test + public void testCallRepeatedlyByInvalidAddress() { + Address invalidAddress = Address.create("localhost", 5050); + + client = + new WebsocketGatewayClient( + GatewayClientSettings.builder().address(invalidAddress).build(), CLIENT_CODEC); + + ServiceCall serviceCall = + new ServiceCall() + .transport(new GatewayClientTransport(client)) + .router(new StaticAddressRouter(invalidAddress)); + + for (int i = 0; i < 100; i++) { + StepVerifier.create(serviceCall.api(TestService.class).manyNever().log("<<< ")) + .thenAwait(Duration.ofSeconds(1)) + .expectError(IOException.class) + .verify(Duration.ofSeconds(10)); + } + } + + @Test + public void testHandlerEvents() throws InterruptedException { + // Test Connect + client = + new WebsocketGatewayClient( + GatewayClientSettings.builder().address(gatewayAddress).build(), CLIENT_CODEC); + + TestService service = + new ServiceCall() + .transport(new GatewayClientTransport(client)) + .router(new StaticAddressRouter(gatewayAddress)) + .api(TestService.class); + + service.one("one").block(TIMEOUT); + sessionEventHandler.connLatch.await(3, TimeUnit.SECONDS); + Assertions.assertEquals(0, sessionEventHandler.connLatch.getCount()); + + sessionEventHandler.msgLatch.await(3, TimeUnit.SECONDS); + Assertions.assertEquals(0, sessionEventHandler.msgLatch.getCount()); + + client.close(); + sessionEventHandler.disconnLatch.await(3, TimeUnit.SECONDS); + Assertions.assertEquals(0, sessionEventHandler.disconnLatch.getCount()); + } + + @Test + void testKeepalive() + throws InterruptedException, + NoSuchFieldException, + IllegalAccessException, + NoSuchMethodException, + InvocationTargetException { + + int expectedKeepalives = 3; + Duration keepAliveInterval = Duration.ofSeconds(1); + CountDownLatch keepaliveLatch = new CountDownLatch(expectedKeepalives); + client = + new WebsocketGatewayClient( + GatewayClientSettings.builder() + .address(gatewayAddress) + .keepAliveInterval(keepAliveInterval) + .build(), + CLIENT_CODEC); + + Method getOrConnect = WebsocketGatewayClient.class.getDeclaredMethod("getOrConnect"); + getOrConnect.setAccessible(true); + //noinspection unchecked + WebsocketGatewayClientSession session = + ((Mono) getOrConnect.invoke(client)).block(TIMEOUT); + Field connectionField = WebsocketGatewayClientSession.class.getDeclaredField("connection"); + connectionField.setAccessible(true); + Connection connection = (Connection) connectionField.get(session); + connection.addHandler( + new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + if (msg instanceof PongWebSocketFrame) { + ((PongWebSocketFrame) msg).release(); + keepaliveLatch.countDown(); + } else { + super.channelRead(ctx, msg); + } + } + }); + + keepaliveLatch.await( + keepAliveInterval.toMillis() * (expectedKeepalives + 1), TimeUnit.MILLISECONDS); + + assertEquals(0, keepaliveLatch.getCount()); + } + + @Test + void testClientSettingsHeaders() { + String headerKey = "secret-token"; + String headerValue = UUID.randomUUID().toString(); + client = + new WebsocketGatewayClient( + GatewayClientSettings.builder() + .address(gatewayAddress) + .headers(Collections.singletonMap(headerKey, headerValue)) + .build(), + CLIENT_CODEC); + TestService service = + new ServiceCall() + .transport(new GatewayClientTransport(client)) + .router(new StaticAddressRouter(gatewayAddress)) + .api(TestService.class); + + StepVerifier.create( + service.one("one").then(Mono.fromCallable(() -> sessionEventHandler.lastSession()))) + .assertNext(session -> assertEquals(headerValue, session.headers().get(headerKey))) + .expectComplete() + .verify(TIMEOUT); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketClientErrorMapperTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketClientErrorMapperTest.java new file mode 100644 index 000000000..6121f8d42 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketClientErrorMapperTest.java @@ -0,0 +1,41 @@ +package io.scalecube.services.gateway.websocket; + +import static io.scalecube.services.gateway.TestUtils.TIMEOUT; +import static io.scalecube.services.gateway.exceptions.GatewayErrorMapperImpl.ERROR_MAPPER; + +import io.scalecube.services.ServiceInfo; +import io.scalecube.services.gateway.BaseTest; +import io.scalecube.services.gateway.exceptions.ErrorService; +import io.scalecube.services.gateway.exceptions.ErrorServiceImpl; +import io.scalecube.services.gateway.exceptions.SomeException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import reactor.test.StepVerifier; + +class WebsocketClientErrorMapperTest extends BaseTest { + + @RegisterExtension + static WebsocketGatewayExtension extension = + new WebsocketGatewayExtension( + ServiceInfo.fromServiceInstance(new ErrorServiceImpl()) + .errorMapper(ERROR_MAPPER) + .build()); + + private ErrorService service; + + @BeforeEach + void initService() { + service = extension.client().errorMapper(ERROR_MAPPER).api(ErrorService.class); + } + + @Test + void shouldReturnSomeExceptionOnFlux() { + StepVerifier.create(service.manyError()).expectError(SomeException.class).verify(TIMEOUT); + } + + @Test + void shouldReturnSomeExceptionOnMono() { + StepVerifier.create(service.oneError()).expectError(SomeException.class).verify(TIMEOUT); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketClientTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketClientTest.java new file mode 100644 index 000000000..a6815da56 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketClientTest.java @@ -0,0 +1,178 @@ +package io.scalecube.services.gateway.websocket; + +import io.netty.buffer.ByteBuf; +import io.scalecube.net.Address; +import io.scalecube.services.Microservices; +import io.scalecube.services.ServiceCall; +import io.scalecube.services.annotations.Service; +import io.scalecube.services.annotations.ServiceMethod; +import io.scalecube.services.discovery.ScalecubeServiceDiscovery; +import io.scalecube.services.gateway.BaseTest; +import io.scalecube.services.gateway.TestGatewaySessionHandler; +import io.scalecube.services.gateway.transport.GatewayClient; +import io.scalecube.services.gateway.transport.GatewayClientCodec; +import io.scalecube.services.gateway.transport.GatewayClientSettings; +import io.scalecube.services.gateway.transport.GatewayClientTransport; +import io.scalecube.services.gateway.transport.GatewayClientTransports; +import io.scalecube.services.gateway.transport.StaticAddressRouter; +import io.scalecube.services.gateway.transport.websocket.WebsocketGatewayClient; +import io.scalecube.services.gateway.ws.WebsocketGateway; +import io.scalecube.services.transport.rsocket.RSocketServiceTransport; +import io.scalecube.transport.netty.websocket.WebsocketTransportFactory; +import java.time.Duration; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.resources.LoopResources; +import reactor.test.StepVerifier; + +class WebsocketClientTest extends BaseTest { + + public static final GatewayClientCodec CLIENT_CODEC = + GatewayClientTransports.WEBSOCKET_CLIENT_CODEC; + + private static Microservices gateway; + private static Address gatewayAddress; + private static Microservices service; + private static GatewayClient client; + private static LoopResources loopResources; + + @BeforeAll + static void beforeAll() { + loopResources = LoopResources.create("websocket-gateway-client"); + + gateway = + Microservices.builder() + .discovery( + serviceEndpoint -> + new ScalecubeServiceDiscovery() + .transport(cfg -> cfg.transportFactory(new WebsocketTransportFactory())) + .options(opts -> opts.metadata(serviceEndpoint))) + .transport(RSocketServiceTransport::new) + .gateway( + options -> new WebsocketGateway(options.id("WS"), new TestGatewaySessionHandler())) + .startAwait(); + gatewayAddress = gateway.gateway("WS").address(); + + service = + Microservices.builder() + .discovery( + serviceEndpoint -> + new ScalecubeServiceDiscovery() + .transport(cfg -> cfg.transportFactory(new WebsocketTransportFactory())) + .options(opts -> opts.metadata(serviceEndpoint)) + .membership(opts -> opts.seedMembers(gateway.discoveryAddress()))) + .transport(RSocketServiceTransport::new) + .services(new TestServiceImpl()) + .startAwait(); + } + + @AfterEach + void afterEach() { + final GatewayClient client = WebsocketClientTest.client; + if (client != null) { + client.close(); + } + } + + @AfterAll + static void afterAll() { + final GatewayClient client = WebsocketClientTest.client; + if (client != null) { + client.close(); + } + + Flux.concat( + Mono.justOrEmpty(gateway).map(Microservices::shutdown), + Mono.justOrEmpty(service).map(Microservices::shutdown)) + .then() + .block(); + + if (loopResources != null) { + loopResources.disposeLater().block(); + } + } + + @Test + void testMessageSequence() { + client = + new WebsocketGatewayClient( + GatewayClientSettings.builder().address(gatewayAddress).build(), + CLIENT_CODEC, + loopResources); + + ServiceCall serviceCall = + new ServiceCall() + .transport(new GatewayClientTransport(client)) + .router(new StaticAddressRouter(gatewayAddress)); + + int count = 1000; + + StepVerifier.create(serviceCall.api(TestService.class).many(count) /*.log("<<< ")*/) + .expectNextSequence(IntStream.range(0, count).boxed().collect(Collectors.toList())) + .expectComplete() + .verify(Duration.ofSeconds(10)); + } + + @Service + public interface TestService { + + @ServiceMethod("many") + Flux many(int count); + } + + private static class TestServiceImpl implements TestService { + + @Override + public Flux many(int count) { + return Flux.using( + ReactiveAdapter::new, + reactiveAdapter -> + reactiveAdapter + .receive() + .take(count) + .cast(Integer.class) + .doOnSubscribe( + s -> + new Thread( + () -> { + for (int i = 0; ; ) { + int r = (int) reactiveAdapter.requested(100); + + if (reactiveAdapter.isFastPath()) { + try { + if (reactiveAdapter.isDisposed()) { + return; + } + reactiveAdapter.tryNext(i++); + reactiveAdapter.incrementProduced(); + } catch (Throwable e) { + reactiveAdapter.lastError(e); + return; + } + } else if (r > 0) { + try { + if (reactiveAdapter.isDisposed()) { + return; + } + reactiveAdapter.tryNext(i++); + reactiveAdapter.incrementProduced(); + } catch (Throwable e) { + reactiveAdapter.lastError(e); + return; + } + + reactiveAdapter.commitProduced(); + } + } + }) + .start()), + ReactiveAdapter::dispose); + } + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketGatewayExtension.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketGatewayExtension.java new file mode 100644 index 000000000..04ccf1b5d --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketGatewayExtension.java @@ -0,0 +1,22 @@ +package io.scalecube.services.gateway.websocket; + +import io.scalecube.services.ServiceInfo; +import io.scalecube.services.gateway.AbstractGatewayExtension; +import io.scalecube.services.gateway.transport.GatewayClientTransports; +import io.scalecube.services.gateway.ws.WebsocketGateway; + +class WebsocketGatewayExtension extends AbstractGatewayExtension { + + private static final String GATEWAY_ALIAS_NAME = "ws"; + + WebsocketGatewayExtension(Object serviceInstance) { + this(ServiceInfo.fromServiceInstance(serviceInstance).build()); + } + + WebsocketGatewayExtension(ServiceInfo serviceInfo) { + super( + serviceInfo, + opts -> new WebsocketGateway(opts.id(GATEWAY_ALIAS_NAME)), + GatewayClientTransports::websocketGatewayClientTransport); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketGatewayTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketGatewayTest.java new file mode 100644 index 000000000..acf5b2a76 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketGatewayTest.java @@ -0,0 +1,169 @@ +package io.scalecube.services.gateway.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.scalecube.services.api.Qualifier; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.examples.EmptyGreetingRequest; +import io.scalecube.services.examples.EmptyGreetingResponse; +import io.scalecube.services.examples.GreetingRequest; +import io.scalecube.services.examples.GreetingResponse; +import io.scalecube.services.examples.GreetingService; +import io.scalecube.services.examples.GreetingServiceImpl; +import io.scalecube.services.exceptions.InternalServiceException; +import io.scalecube.services.exceptions.ServiceUnavailableException; +import io.scalecube.services.gateway.BaseTest; +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import reactor.test.StepVerifier; + +class WebsocketGatewayTest extends BaseTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(3); + + @RegisterExtension + static WebsocketGatewayExtension extension = + new WebsocketGatewayExtension(new GreetingServiceImpl()); + + private GreetingService service; + + @BeforeEach + void initService() { + service = extension.client().api(GreetingService.class); + } + + @Test + void shouldReturnSingleResponseWithSimpleRequest() { + StepVerifier.create(service.one("hello")) + .expectNext("Echo:hello") + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnSingleResponseWithSimpleLongDataRequest() { + String data = new String(new char[500]); + StepVerifier.create(service.one(data)) + .expectNext("Echo:" + data) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnSingleResponseWithPojoRequest() { + StepVerifier.create(service.pojoOne(new GreetingRequest("hello"))) + .expectNextMatches(response -> "Echo:hello".equals(response.getText())) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnListResponseWithPojoRequest() { + StepVerifier.create(service.pojoList(new GreetingRequest("hello"))) + .expectNextMatches(response -> "Echo:hello".equals(response.get(0).getText())) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnManyResponsesWithSimpleRequest() { + int expectedResponseNum = 3; + List expected = + IntStream.range(0, expectedResponseNum) + .mapToObj(i -> "Greeting (" + i + ") to: hello") + .collect(Collectors.toList()); + + StepVerifier.create(service.many("hello").take(expectedResponseNum)) + .expectNextSequence(expected) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnManyResponsesWithPojoRequest() { + int expectedResponseNum = 3; + List expected = + IntStream.range(0, expectedResponseNum) + .mapToObj(i -> new GreetingResponse("Greeting (" + i + ") to: hello")) + .collect(Collectors.toList()); + + StepVerifier.create(service.pojoMany(new GreetingRequest("hello")).take(expectedResponseNum)) + .expectNextSequence(expected) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnExceptionWhenServiceIsDown() { + extension.shutdownServices(); + + StepVerifier.create(service.one("hello")) + .expectErrorMatches( + throwable -> + throwable instanceof ServiceUnavailableException + && throwable.getMessage().startsWith("No reachable member with such service")) + .verify(TIMEOUT); + } + + @Test + void shouldReturnErrorDataWhenServiceFails() { + StepVerifier.create(service.failingOne("hello")) + .expectErrorMatches(throwable -> throwable instanceof InternalServiceException) + .verify(TIMEOUT); + } + + @Test + void shouldReturnErrorDataWhenRequestDataIsEmpty() { + StepVerifier.create(service.one(null)) + .expectErrorMatches( + throwable -> + "Expected service request data of type: class java.lang.String, but received: null" + .equals(throwable.getMessage())) + .verify(TIMEOUT); + } + + @Test + void shouldReturnNoEventOnNeverService() { + StepVerifier.create(service.neverOne("hi")) + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(1)) + .thenCancel() + .verify(); + } + + @Test + void shouldReturnOnEmptyGreeting() { + StepVerifier.create(service.emptyGreeting(new EmptyGreetingRequest())) + .expectSubscription() + .expectNextMatches(resp -> resp instanceof EmptyGreetingResponse) + .thenCancel() + .verify(); + } + + @Test + void shouldReturnOnEmptyMessageGreeting() { + String qualifier = Qualifier.asString(GreetingService.NAMESPACE, "empty/wrappedPojo"); + ServiceMessage request = + ServiceMessage.builder().qualifier(qualifier).data(new EmptyGreetingRequest()).build(); + StepVerifier.create(extension.client().requestOne(request, EmptyGreetingResponse.class)) + .expectSubscription() + .expectNextMatches(resp -> resp.data() instanceof EmptyGreetingResponse) + .thenCancel() + .verify(); + } + + @Disabled("https://github.com/scalecube/scalecube-services/issues/742") + public void testManyStreamBlockFirst() { + for (int i = 0; i < 100; i++) { + //noinspection ConstantConditions + long first = service.manyStream(30L).filter(k -> k != 0).take(1).blockFirst(); + assertEquals(1, first); + } + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalGatewayAuthTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalGatewayAuthTest.java new file mode 100644 index 000000000..a7b61a5b2 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalGatewayAuthTest.java @@ -0,0 +1,136 @@ +package io.scalecube.services.gateway.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.exceptions.ForbiddenException; +import io.scalecube.services.exceptions.UnauthorizedException; +import io.scalecube.services.gateway.AuthRegistry; +import io.scalecube.services.gateway.SecuredService; +import io.scalecube.services.gateway.SecuredServiceImpl; +import java.time.Duration; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import reactor.test.StepVerifier; + +public class WebsocketLocalGatewayAuthTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(3); + + private static final String ALLOWED_USER = "VASYA_PUPKIN"; + private static final Set ALLOWED_USERS = + new HashSet<>(Collections.singletonList(ALLOWED_USER)); + + private static final AuthRegistry AUTH_REG = new AuthRegistry(ALLOWED_USERS); + + @RegisterExtension + static WebsocketLocalWithAuthExtension extension = + new WebsocketLocalWithAuthExtension(new SecuredServiceImpl(AUTH_REG), AUTH_REG); + + private SecuredService clientService; + + private static ServiceMessage createSessionReq(String username) { + return ServiceMessage.builder() + .qualifier("/" + SecuredService.NS + "/createSession") + .data(username) + .build(); + } + + @BeforeEach + void initService() { + clientService = extension.client().api(SecuredService.class); + } + + @Test + void testCreateSession_succ() { + StepVerifier.create(extension.client().requestOne(createSessionReq(ALLOWED_USER), String.class)) + .expectNextCount(1) + .expectComplete() + .verify(); + } + + @Test + void testCreateSession_forbiddenUser() { + StepVerifier.create( + extension.client().requestOne(createSessionReq("fake" + ALLOWED_USER), String.class)) + .expectErrorSatisfies( + th -> { + ForbiddenException e = (ForbiddenException) th; + assertEquals(403, e.errorCode(), "error code"); + assertTrue(e.getMessage().contains("User not allowed to use this service")); + }) + .verify(); + } + + @Test + void testCallSecuredMethod_notAuthenticated() { + StepVerifier.create(clientService.requestOne("echo")) + .expectErrorSatisfies( + th -> { + UnauthorizedException e = (UnauthorizedException) th; + assertEquals(401, e.errorCode(), "Authentication failed"); + assertTrue(e.getMessage().contains("Authentication failed")); + }) + .verify(); + } + + @Test + void testCallSecuredMethod_authenticated() { + // authenticate session + extension.client().requestOne(createSessionReq(ALLOWED_USER), String.class).block(TIMEOUT); + // call secured service + final String req = "echo"; + StepVerifier.create(clientService.requestOne(req)) + .expectNextMatches(resp -> resp.equals(ALLOWED_USER + "@" + req)) + .expectComplete() + .verify(); + } + + @Test + void testCallSecuredMethod_authenticatedInvalidUser() { + // authenticate session + StepVerifier.create( + extension.client().requestOne(createSessionReq("fake" + ALLOWED_USER), String.class)) + .expectErrorSatisfies(th -> assertTrue(th instanceof ForbiddenException)) + .verify(); + // call secured service + final String req = "echo"; + StepVerifier.create(clientService.requestOne(req)) + .expectErrorSatisfies( + th -> { + UnauthorizedException e = (UnauthorizedException) th; + assertEquals(401, e.errorCode(), "Authentication failed"); + assertTrue(e.getMessage().contains("Authentication failed")); + }) + .verify(); + } + + @Test + void testCallSecuredMethod_notAuthenticatedRequestStream() { + StepVerifier.create(clientService.requestN(10)) + .expectErrorSatisfies( + th -> { + UnauthorizedException e = (UnauthorizedException) th; + assertEquals(401, e.errorCode(), "Authentication failed"); + assertTrue(e.getMessage().contains("Authentication failed")); + }) + .verify(); + } + + @Test + void testCallSecuredMethod_authenticatedReqStream() { + // authenticate session + extension.client().requestOne(createSessionReq(ALLOWED_USER), String.class).block(TIMEOUT); + // call secured service + Integer times = 10; + StepVerifier.create(clientService.requestN(times)) + .expectNextCount(10) + .expectComplete() + .verify(); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalGatewayErrorMapperTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalGatewayErrorMapperTest.java new file mode 100644 index 000000000..8a5de923b --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalGatewayErrorMapperTest.java @@ -0,0 +1,42 @@ +package io.scalecube.services.gateway.websocket; + +import static io.scalecube.services.gateway.TestUtils.TIMEOUT; +import static io.scalecube.services.gateway.exceptions.GatewayErrorMapperImpl.ERROR_MAPPER; + +import io.scalecube.services.ServiceInfo; +import io.scalecube.services.gateway.BaseTest; +import io.scalecube.services.gateway.exceptions.ErrorService; +import io.scalecube.services.gateway.exceptions.ErrorServiceImpl; +import io.scalecube.services.gateway.exceptions.SomeException; +import io.scalecube.services.gateway.ws.WebsocketGateway; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import reactor.test.StepVerifier; + +class WebsocketLocalGatewayErrorMapperTest extends BaseTest { + + @RegisterExtension + static WebsocketLocalGatewayExtension extension = + new WebsocketLocalGatewayExtension( + ServiceInfo.fromServiceInstance(new ErrorServiceImpl()).errorMapper(ERROR_MAPPER).build(), + opts -> + new WebsocketGateway(opts.call(opts.call().errorMapper(ERROR_MAPPER)), ERROR_MAPPER)); + + private ErrorService service; + + @BeforeEach + void initService() { + service = extension.client().errorMapper(ERROR_MAPPER).api(ErrorService.class); + } + + @Test + void shouldReturnSomeExceptionOnFlux() { + StepVerifier.create(service.manyError()).expectError(SomeException.class).verify(TIMEOUT); + } + + @Test + void shouldReturnSomeExceptionOnMono() { + StepVerifier.create(service.oneError()).expectError(SomeException.class).verify(TIMEOUT); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalGatewayExtension.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalGatewayExtension.java new file mode 100644 index 000000000..77ebc8c8b --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalGatewayExtension.java @@ -0,0 +1,29 @@ +package io.scalecube.services.gateway.websocket; + +import io.scalecube.services.ServiceInfo; +import io.scalecube.services.gateway.AbstractLocalGatewayExtension; +import io.scalecube.services.gateway.GatewayOptions; +import io.scalecube.services.gateway.transport.GatewayClientTransports; +import io.scalecube.services.gateway.ws.WebsocketGateway; +import java.util.function.Function; + +class WebsocketLocalGatewayExtension extends AbstractLocalGatewayExtension { + + private static final String GATEWAY_ALIAS_NAME = "ws"; + + WebsocketLocalGatewayExtension(Object serviceInstance) { + this(ServiceInfo.fromServiceInstance(serviceInstance).build()); + } + + WebsocketLocalGatewayExtension(ServiceInfo serviceInfo) { + this(serviceInfo, WebsocketGateway::new); + } + + WebsocketLocalGatewayExtension( + ServiceInfo serviceInfo, Function gatewaySupplier) { + super( + serviceInfo, + opts -> gatewaySupplier.apply(opts.id(GATEWAY_ALIAS_NAME)), + GatewayClientTransports::websocketGatewayClientTransport); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalGatewayTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalGatewayTest.java new file mode 100644 index 000000000..12955cf7e --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalGatewayTest.java @@ -0,0 +1,155 @@ +package io.scalecube.services.gateway.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.scalecube.services.api.Qualifier; +import io.scalecube.services.api.ServiceMessage; +import io.scalecube.services.examples.EmptyGreetingRequest; +import io.scalecube.services.examples.EmptyGreetingResponse; +import io.scalecube.services.examples.GreetingRequest; +import io.scalecube.services.examples.GreetingResponse; +import io.scalecube.services.examples.GreetingService; +import io.scalecube.services.examples.GreetingServiceImpl; +import io.scalecube.services.exceptions.InternalServiceException; +import io.scalecube.services.gateway.BaseTest; +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import reactor.test.StepVerifier; + +class WebsocketLocalGatewayTest extends BaseTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(3); + + @RegisterExtension + static WebsocketLocalGatewayExtension extension = + new WebsocketLocalGatewayExtension(new GreetingServiceImpl()); + + private GreetingService service; + + @BeforeEach + void initService() { + service = extension.client().api(GreetingService.class); + } + + @Test + void shouldReturnSingleResponseWithSimpleRequest() { + StepVerifier.create(service.one("hello")) + .expectNext("Echo:hello") + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnSingleResponseWithSimpleLongDataRequest() { + String data = new String(new char[500]); + StepVerifier.create(service.one(data)) + .expectNext("Echo:" + data) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnSingleResponseWithPojoRequest() { + StepVerifier.create(service.pojoOne(new GreetingRequest("hello"))) + .expectNextMatches(response -> "Echo:hello".equals(response.getText())) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnListResponseWithPojoRequest() { + StepVerifier.create(service.pojoList(new GreetingRequest("hello"))) + .expectNextMatches(response -> "Echo:hello".equals(response.get(0).getText())) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnManyResponsesWithSimpleRequest() { + int expectedResponseNum = 3; + List expected = + IntStream.range(0, expectedResponseNum) + .mapToObj(i -> "Greeting (" + i + ") to: hello") + .collect(Collectors.toList()); + + StepVerifier.create(service.many("hello").take(expectedResponseNum)) + .expectNextSequence(expected) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnManyResponsesWithPojoRequest() { + int expectedResponseNum = 3; + List expected = + IntStream.range(0, expectedResponseNum) + .mapToObj(i -> new GreetingResponse("Greeting (" + i + ") to: hello")) + .collect(Collectors.toList()); + + StepVerifier.create(service.pojoMany(new GreetingRequest("hello")).take(expectedResponseNum)) + .expectNextSequence(expected) + .expectComplete() + .verify(TIMEOUT); + } + + @Test + void shouldReturnErrorDataWhenServiceFails() { + StepVerifier.create(service.failingOne("hello")) + .expectErrorMatches(throwable -> throwable instanceof InternalServiceException) + .verify(TIMEOUT); + } + + @Test + void shouldReturnErrorDataWhenRequestDataIsEmpty() { + StepVerifier.create(service.one(null)) + .expectErrorMatches( + throwable -> + "Expected service request data of type: class java.lang.String, but received: null" + .equals(throwable.getMessage())) + .verify(TIMEOUT); + } + + @Test + void shouldReturnNoEventOnNeverService() { + StepVerifier.create(service.neverOne("hi")) + .expectSubscription() + .expectNoEvent(Duration.ofSeconds(1)) + .thenCancel() + .verify(); + } + + @Test + void shouldReturnOnEmptyGreeting() { + StepVerifier.create(service.emptyGreeting(new EmptyGreetingRequest())) + .expectSubscription() + .expectNextMatches(resp -> resp instanceof EmptyGreetingResponse) + .thenCancel() + .verify(); + } + + @Test + void shouldReturnOnEmptyMessageGreeting() { + String qualifier = Qualifier.asString(GreetingService.NAMESPACE, "empty/wrappedPojo"); + ServiceMessage request = + ServiceMessage.builder().qualifier(qualifier).data(new EmptyGreetingRequest()).build(); + StepVerifier.create(extension.client().requestOne(request, EmptyGreetingResponse.class)) + .expectSubscription() + .expectNextMatches(resp -> resp.data() instanceof EmptyGreetingResponse) + .thenCancel() + .verify(); + } + + @Test + public void testManyStreamBlockFirst() { + for (int i = 0; i < 100; i++) { + //noinspection ConstantConditions + long first = service.manyStream(30L).filter(k -> k != 0).take(1).blockFirst(); + assertEquals(1, first); + } + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalWithAuthExtension.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalWithAuthExtension.java new file mode 100644 index 000000000..a81688e69 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketLocalWithAuthExtension.java @@ -0,0 +1,26 @@ +package io.scalecube.services.gateway.websocket; + +import io.scalecube.services.ServiceInfo; +import io.scalecube.services.gateway.AbstractLocalGatewayExtension; +import io.scalecube.services.gateway.AuthRegistry; +import io.scalecube.services.gateway.GatewaySessionHandlerImpl; +import io.scalecube.services.gateway.transport.GatewayClientTransports; +import io.scalecube.services.gateway.ws.WebsocketGateway; + +public class WebsocketLocalWithAuthExtension extends AbstractLocalGatewayExtension { + + private static final String GATEWAY_ALIAS_NAME = "ws"; + + WebsocketLocalWithAuthExtension(Object serviceInstance, AuthRegistry authReg) { + this(ServiceInfo.fromServiceInstance(serviceInstance).build(), authReg); + } + + WebsocketLocalWithAuthExtension(ServiceInfo serviceInfo, AuthRegistry authReg) { + super( + serviceInfo, + opts -> + new WebsocketGateway( + opts.id(GATEWAY_ALIAS_NAME), new GatewaySessionHandlerImpl(authReg)), + GatewayClientTransports::websocketGatewayClientTransport); + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketServerTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketServerTest.java new file mode 100644 index 000000000..27f1699d8 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/websocket/WebsocketServerTest.java @@ -0,0 +1,163 @@ +package io.scalecube.services.gateway.websocket; + +import io.netty.buffer.ByteBuf; +import io.scalecube.net.Address; +import io.scalecube.services.Microservices; +import io.scalecube.services.ServiceCall; +import io.scalecube.services.annotations.Service; +import io.scalecube.services.annotations.ServiceMethod; +import io.scalecube.services.discovery.ScalecubeServiceDiscovery; +import io.scalecube.services.gateway.BaseTest; +import io.scalecube.services.gateway.TestGatewaySessionHandler; +import io.scalecube.services.gateway.transport.GatewayClient; +import io.scalecube.services.gateway.transport.GatewayClientCodec; +import io.scalecube.services.gateway.transport.GatewayClientSettings; +import io.scalecube.services.gateway.transport.GatewayClientTransport; +import io.scalecube.services.gateway.transport.GatewayClientTransports; +import io.scalecube.services.gateway.transport.StaticAddressRouter; +import io.scalecube.services.gateway.transport.websocket.WebsocketGatewayClient; +import io.scalecube.services.gateway.ws.WebsocketGateway; +import io.scalecube.services.transport.rsocket.RSocketServiceTransport; +import io.scalecube.transport.netty.websocket.WebsocketTransportFactory; +import java.time.Duration; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.resources.LoopResources; +import reactor.test.StepVerifier; + +class WebsocketServerTest extends BaseTest { + + public static final GatewayClientCodec CLIENT_CODEC = + GatewayClientTransports.WEBSOCKET_CLIENT_CODEC; + + private static Microservices gateway; + private static Address gatewayAddress; + private static GatewayClient client; + private static LoopResources loopResources; + + @BeforeAll + static void beforeAll() { + loopResources = LoopResources.create("websocket-gateway-client"); + + gateway = + Microservices.builder() + .discovery( + serviceEndpoint -> + new ScalecubeServiceDiscovery() + .transport(cfg -> cfg.transportFactory(new WebsocketTransportFactory())) + .options(opts -> opts.metadata(serviceEndpoint))) + .transport(RSocketServiceTransport::new) + .gateway( + options -> new WebsocketGateway(options.id("WS"), new TestGatewaySessionHandler())) + .transport(RSocketServiceTransport::new) + .services(new TestServiceImpl()) + .startAwait(); + gatewayAddress = gateway.gateway("WS").address(); + } + + @AfterEach + void afterEach() { + final GatewayClient client = WebsocketServerTest.client; + if (client != null) { + client.close(); + } + } + + @AfterAll + static void afterAll() { + final GatewayClient client = WebsocketServerTest.client; + if (client != null) { + client.close(); + } + + Mono.justOrEmpty(gateway).map(Microservices::shutdown).then().block(); + + if (loopResources != null) { + loopResources.disposeLater().block(); + } + } + + @Test + void testMessageSequence() { + client = + new WebsocketGatewayClient( + GatewayClientSettings.builder().address(gatewayAddress).build(), + CLIENT_CODEC, + loopResources); + + ServiceCall serviceCall = + new ServiceCall() + .transport(new GatewayClientTransport(client)) + .router(new StaticAddressRouter(gatewayAddress)); + + int count = 1000; + + StepVerifier.create(serviceCall.api(TestService.class).many(count) /*.log("<<< ")*/) + .expectNextSequence(IntStream.range(0, count).boxed().collect(Collectors.toList())) + .expectComplete() + .verify(Duration.ofSeconds(10)); + } + + @Service + public interface TestService { + + @ServiceMethod("many") + Flux many(int count); + } + + private static class TestServiceImpl implements TestService { + + @Override + public Flux many(int count) { + return Flux.using( + ReactiveAdapter::new, + reactiveAdapter -> + reactiveAdapter + .receive() + .take(count) + .cast(Integer.class) + .doOnSubscribe( + s -> + new Thread( + () -> { + for (int i = 0; ; ) { + int r = (int) reactiveAdapter.requested(100); + + if (reactiveAdapter.isFastPath()) { + try { + if (reactiveAdapter.isDisposed()) { + return; + } + reactiveAdapter.tryNext(i++); + reactiveAdapter.incrementProduced(); + } catch (Throwable e) { + reactiveAdapter.lastError(e); + return; + } + } else if (r > 0) { + try { + if (reactiveAdapter.isDisposed()) { + return; + } + reactiveAdapter.tryNext(i++); + reactiveAdapter.incrementProduced(); + } catch (Throwable e) { + reactiveAdapter.lastError(e); + return; + } + + reactiveAdapter.commitProduced(); + } + } + }) + .start()), + ReactiveAdapter::dispose); + } + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/ws/TestInputs.java b/services-gateway/src/test/java/io/scalecube/services/gateway/ws/TestInputs.java new file mode 100644 index 000000000..44b7365cd --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/ws/TestInputs.java @@ -0,0 +1,84 @@ +package io.scalecube.services.gateway.ws; + +public interface TestInputs { + + long SID = 42L; + int I = 423; + int SIG = 422; + String Q = "/test/test"; + + String NO_DATA = + "{" + + " \"q\":\"" + + Q + + "\"," + + " \"sid\":" + + SID + + "," + + " \"sig\":" + + SIG + + "," + + " \"i\":" + + I + + "}"; + + String STRING_DATA_PATTERN_Q_SIG_SID_D = + "{" + "\"q\":\"%s\"," + "\"sig\":%d," + "\"sid\":%d," + "\"d\":%s" + "}"; + + String STRING_DATA_PATTERN_D_SIG_SID_Q = + "{" + "\"d\": %s," + "\"sig\":%d," + "\"sid\": %d," + "\"q\":\"%s\"" + "}"; + + class Entity { + private String text; + private Integer number; + private Boolean check; + + Entity() {} + + public Entity(String text, Integer number, Boolean check) { + this.text = text; + this.number = number; + this.check = check; + } + + public String text() { + return text; + } + + public Integer number() { + return number; + } + + public Boolean check() { + return check; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + Entity entity = (Entity) o; + + if (text != null ? !text.equals(entity.text) : entity.text != null) { + return false; + } + if (number != null ? !number.equals(entity.number) : entity.number != null) { + return false; + } + return check != null ? check.equals(entity.check) : entity.check == null; + } + + @Override + public int hashCode() { + int result = text != null ? text.hashCode() : 0; + result = 31 * result + (number != null ? number.hashCode() : 0); + result = 31 * result + (check != null ? check.hashCode() : 0); + return result; + } + } +} diff --git a/services-gateway/src/test/java/io/scalecube/services/gateway/ws/WebsocketServiceMessageCodecTest.java b/services-gateway/src/test/java/io/scalecube/services/gateway/ws/WebsocketServiceMessageCodecTest.java new file mode 100644 index 000000000..a8aeb56c3 --- /dev/null +++ b/services-gateway/src/test/java/io/scalecube/services/gateway/ws/WebsocketServiceMessageCodecTest.java @@ -0,0 +1,320 @@ +package io.scalecube.services.gateway.ws; + +import static io.scalecube.services.gateway.ws.GatewayMessages.DATA_FIELD; +import static io.scalecube.services.gateway.ws.GatewayMessages.INACTIVITY_FIELD; +import static io.scalecube.services.gateway.ws.GatewayMessages.QUALIFIER_FIELD; +import static io.scalecube.services.gateway.ws.GatewayMessages.SIGNAL_FIELD; +import static io.scalecube.services.gateway.ws.GatewayMessages.STREAM_ID_FIELD; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.ByteBufInputStream; +import io.netty.buffer.ByteBufOutputStream; +import io.scalecube.services.api.ServiceMessage; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +public class WebsocketServiceMessageCodecTest { + + private final WebsocketServiceMessageCodec codec = new WebsocketServiceMessageCodec(); + private final ObjectMapper objectMapper = objectMapper(); + + @Test + public void testDecodeNoData() { + ByteBuf input = toByteBuf(TestInputs.NO_DATA); + + ServiceMessage message = codec.decode(input); + + assertEquals(TestInputs.Q, message.qualifier()); + assertEquals(TestInputs.SIG, Long.parseLong(message.header(SIGNAL_FIELD))); + assertEquals(TestInputs.SID, Long.parseLong(message.header(STREAM_ID_FIELD))); + assertEquals(TestInputs.I, Long.parseLong(message.header(INACTIVITY_FIELD))); + } + + @Test + public void testDecodeNullData() { + Object nullData = "null"; + String stringData = + String.format( + TestInputs.STRING_DATA_PATTERN_Q_SIG_SID_D, + TestInputs.Q, + TestInputs.SIG, + TestInputs.SID, + nullData); + + ByteBuf input = toByteBuf(stringData); + System.out.println("Parsing JSON:" + stringData); + + ServiceMessage message = codec.decode(input); + + assertEquals(TestInputs.Q, message.qualifier()); + assertEquals(TestInputs.SIG, Long.parseLong(message.header(SIGNAL_FIELD))); + assertEquals(TestInputs.SID, Long.parseLong(message.header(STREAM_ID_FIELD))); + assertNull(message.data()); + } + + @Test + public void testDecodeNumberData() { + Integer expectedData = 123; + String stringData = + String.format( + TestInputs.STRING_DATA_PATTERN_Q_SIG_SID_D, + TestInputs.Q, + TestInputs.SIG, + TestInputs.SID, + expectedData); + + ByteBuf input = toByteBuf(stringData); + System.out.println("Parsing JSON:" + stringData); + + ServiceMessage message = codec.decode(input); + + assertEquals(TestInputs.Q, message.qualifier()); + assertEquals(TestInputs.SIG, Long.parseLong(message.header(SIGNAL_FIELD))); + assertEquals(TestInputs.SID, Long.parseLong(message.header(STREAM_ID_FIELD))); + assertTrue(message.data() instanceof ByteBuf); + assertEquals( + expectedData, Integer.valueOf(((ByteBuf) message.data()).toString(StandardCharsets.UTF_8))); + } + + @Test + public void testDecodeNumberDataFirst() { + Integer expectedData = 123; + String stringData = + String.format( + TestInputs.STRING_DATA_PATTERN_D_SIG_SID_Q, + expectedData, + TestInputs.SIG, + TestInputs.SID, + TestInputs.Q); + + ByteBuf input = toByteBuf(stringData); + System.out.println("Parsing JSON:" + stringData); + + ServiceMessage message = codec.decode(input); + + assertEquals(TestInputs.Q, message.qualifier()); + assertEquals(TestInputs.SIG, Long.parseLong(message.header(SIGNAL_FIELD))); + assertEquals(TestInputs.SID, Long.parseLong(message.header(STREAM_ID_FIELD))); + assertTrue(message.data() instanceof ByteBuf); + assertEquals( + expectedData, Integer.valueOf(((ByteBuf) message.data()).toString(StandardCharsets.UTF_8))); + } + + @Test + public void testDecodeStringData() { + String expectedData = "\"test\""; + String stringData = + String.format( + TestInputs.STRING_DATA_PATTERN_Q_SIG_SID_D, + TestInputs.Q, + TestInputs.SIG, + TestInputs.SID, + expectedData); + + ByteBuf input = toByteBuf(stringData); + System.out.println("Parsing JSON:" + stringData); + + ServiceMessage message = codec.decode(input); + + assertEquals(TestInputs.Q, message.qualifier()); + assertEquals(TestInputs.SIG, Long.parseLong(message.header(SIGNAL_FIELD))); + assertEquals(TestInputs.SID, Long.parseLong(message.header(STREAM_ID_FIELD))); + assertTrue(message.data() instanceof ByteBuf); + assertEquals(expectedData, ((ByteBuf) message.data()).toString(StandardCharsets.UTF_8)); + } + + @Test + public void testDecodeBooleanData() { + Boolean expectedData = Boolean.FALSE; + String stringData = + String.format( + TestInputs.STRING_DATA_PATTERN_Q_SIG_SID_D, + TestInputs.Q, + TestInputs.SIG, + TestInputs.SID, + expectedData); + + ByteBuf input = toByteBuf(stringData); + System.out.println("Parsing JSON:" + stringData); + + ServiceMessage message = codec.decode(input); + + assertEquals(TestInputs.Q, message.qualifier()); + assertEquals(TestInputs.SIG, Long.parseLong(message.header(SIGNAL_FIELD))); + assertEquals(TestInputs.SID, Long.parseLong(message.header(STREAM_ID_FIELD))); + assertTrue(message.data() instanceof ByteBuf); + assertEquals( + expectedData, Boolean.valueOf(((ByteBuf) message.data()).toString(StandardCharsets.UTF_8))); + } + + @Test + public void testDecodePojoData() { + String expectedData = + "{\"text\":\"someValue\", \"id\":12345, \"empty\":null, \"embedded\":{\"id\":123}}"; + String stringData = + String.format( + TestInputs.STRING_DATA_PATTERN_Q_SIG_SID_D, + TestInputs.Q, + TestInputs.SIG, + TestInputs.SID, + expectedData); + + ByteBuf input = toByteBuf(stringData); + System.out.println("Parsing JSON:" + stringData); + + ServiceMessage message = codec.decode(input); + + assertEquals(TestInputs.Q, message.qualifier()); + assertEquals(TestInputs.SIG, Long.parseLong(message.header(SIGNAL_FIELD))); + assertEquals(TestInputs.SID, Long.parseLong(message.header(STREAM_ID_FIELD))); + assertTrue(message.data() instanceof ByteBuf); + assertEquals(expectedData, ((ByteBuf) message.data()).toString(StandardCharsets.UTF_8)); + } + + @Test + public void testDecodeArrayData() { + String expectedData = "[{\"id\":1}, {\"id\":2}, {\"id\":3}]"; + String stringData = + String.format( + TestInputs.STRING_DATA_PATTERN_Q_SIG_SID_D, + TestInputs.Q, + TestInputs.SIG, + TestInputs.SID, + expectedData); + + ByteBuf input = toByteBuf(stringData); + System.out.println("Parsing JSON:" + stringData); + + ServiceMessage message = codec.decode(input); + + assertEquals(TestInputs.Q, message.qualifier()); + assertEquals(TestInputs.SIG, Long.parseLong(message.header(SIGNAL_FIELD))); + assertEquals(TestInputs.SID, Long.parseLong(message.header(STREAM_ID_FIELD))); + assertTrue(message.data() instanceof ByteBuf); + assertEquals(expectedData, ((ByteBuf) message.data()).toString(StandardCharsets.UTF_8)); + } + + @Test + public void testEncodePojoData() throws Exception { + TestInputs.Entity data = new TestInputs.Entity("test", 123, true); + ServiceMessage expected = + ServiceMessage.builder() + .qualifier(TestInputs.Q) + .header(STREAM_ID_FIELD, TestInputs.SID) + .header(SIGNAL_FIELD, TestInputs.SIG) + .data(toByteBuf(data)) + .build(); + ByteBuf bb = codec.encode(expected); + + ServiceMessage actual = fromByteBuf(bb, TestInputs.Entity.class); + + assertEquals(expected.qualifier(), actual.qualifier()); + assertEquals(expected.header(SIGNAL_FIELD), actual.header(SIGNAL_FIELD)); + assertEquals(expected.header(STREAM_ID_FIELD), actual.header(STREAM_ID_FIELD)); + assertEquals(expected.header(INACTIVITY_FIELD), actual.header(INACTIVITY_FIELD)); + assertEquals(data, actual.data()); + } + + @Test + public void testEncodeNumberData() throws Exception { + Integer data = -213; + ServiceMessage expected = + ServiceMessage.builder() + .qualifier(TestInputs.Q) + .header(STREAM_ID_FIELD, TestInputs.SID) + .header(SIGNAL_FIELD, TestInputs.SIG) + .data(toByteBuf(data)) + .build(); + ByteBuf bb = codec.encode(expected); + ServiceMessage actual = fromByteBuf(bb, Integer.class); + + assertEquals(expected.qualifier(), actual.qualifier()); + assertEquals(expected.header(SIGNAL_FIELD), actual.header(SIGNAL_FIELD)); + assertEquals(expected.header(STREAM_ID_FIELD), actual.header(STREAM_ID_FIELD)); + assertEquals(expected.header(INACTIVITY_FIELD), actual.header(INACTIVITY_FIELD)); + assertEquals(data, actual.data()); + } + + @Test + public void testEncodeBooleanData() throws Exception { + Boolean data = true; + ServiceMessage expected = + ServiceMessage.builder() + .qualifier(TestInputs.Q) + .header(STREAM_ID_FIELD, TestInputs.SID) + .header(SIGNAL_FIELD, TestInputs.SIG) + .data(toByteBuf(data)) + .build(); + ByteBuf bb = codec.encode(expected); + + ServiceMessage actual = fromByteBuf(bb, Boolean.class); + + assertEquals(expected.qualifier(), actual.qualifier()); + assertEquals(expected.header(SIGNAL_FIELD), actual.header(SIGNAL_FIELD)); + assertEquals(expected.header(STREAM_ID_FIELD), actual.header(STREAM_ID_FIELD)); + assertEquals(expected.header(INACTIVITY_FIELD), actual.header(INACTIVITY_FIELD)); + assertEquals(data, actual.data()); + } + + private ByteBuf toByteBuf(String data) { + ByteBuf bb = ByteBufAllocator.DEFAULT.buffer(); + bb.writeBytes(data.getBytes()); + return bb; + } + + private ByteBuf toByteBuf(Object object) throws IOException { + ByteBuf bb = ByteBufAllocator.DEFAULT.buffer(); + objectMapper.writeValue((OutputStream) new ByteBufOutputStream(bb), object); + return bb; + } + + private ServiceMessage fromByteBuf(ByteBuf bb, Class dataClass) throws IOException { + // noinspection unchecked + + Map map = + objectMapper.readValue((InputStream) new ByteBufInputStream(bb.slice()), HashMap.class); + ServiceMessage.Builder builder = ServiceMessage.builder(); + + Optional.ofNullable(map.get(QUALIFIER_FIELD)).ifPresent(o -> builder.header("q", o)); + + Optional.ofNullable(map.get(STREAM_ID_FIELD)) + .ifPresent(o -> builder.header(STREAM_ID_FIELD, o)); + + Optional.ofNullable(map.get(SIGNAL_FIELD)).ifPresent(o -> builder.header(SIGNAL_FIELD, o)); + + Optional.ofNullable(map.get(INACTIVITY_FIELD)) + .ifPresent(o -> builder.header(INACTIVITY_FIELD, o)); + + return builder.data(objectMapper.convertValue(map.get(DATA_FIELD), dataClass)).build(); + } + + private ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + mapper.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true); + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + mapper.configure(SerializationFeature.WRITE_ENUMS_USING_TO_STRING, true); + mapper.registerModule(new JavaTimeModule()); + return mapper; + } +} diff --git a/services-gateway/src/test/resources/log4j2-test.xml b/services-gateway/src/test/resources/log4j2-test.xml new file mode 100644 index 000000000..f1fc8a261 --- /dev/null +++ b/services-gateway/src/test/resources/log4j2-test.xml @@ -0,0 +1,33 @@ + + + + + + + %level{length=1} %date{MMdd-HHmm:ss,SSS} %logger{1.} %message [%thread]%n + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services-security/pom.xml b/services-security/pom.xml index 1ba36e9f5..1d968e5ee 100644 --- a/services-security/pom.xml +++ b/services-security/pom.xml @@ -1,5 +1,7 @@ - + 4.0.0 diff --git a/services-transport-parent/pom.xml b/services-transport-parent/pom.xml index 85fc8fc55..71b8a2c57 100644 --- a/services-transport-parent/pom.xml +++ b/services-transport-parent/pom.xml @@ -1,5 +1,7 @@ - + 4.0.0 diff --git a/services-transport-parent/services-transport-jackson/pom.xml b/services-transport-parent/services-transport-jackson/pom.xml index 3a34ac373..0d98fc4a7 100644 --- a/services-transport-parent/services-transport-jackson/pom.xml +++ b/services-transport-parent/services-transport-jackson/pom.xml @@ -1,4 +1,6 @@ - + 4.0.0 diff --git a/services/pom.xml b/services/pom.xml index e5f961157..f941b5f49 100644 --- a/services/pom.xml +++ b/services/pom.xml @@ -1,5 +1,7 @@ - + 4.0.0 diff --git a/services/src/test/java/io/scalecube/services/StreamingServiceTest.java b/services/src/test/java/io/scalecube/services/StreamingServiceTest.java index 6336a839a..890927d4c 100644 --- a/services/src/test/java/io/scalecube/services/StreamingServiceTest.java +++ b/services/src/test/java/io/scalecube/services/StreamingServiceTest.java @@ -10,8 +10,8 @@ import io.scalecube.services.discovery.ScalecubeServiceDiscovery; import io.scalecube.services.sut.QuoteService; import io.scalecube.services.sut.SimpleQuoteService; -import io.scalecube.services.transport.rsocket.ServiceMessageCodec; import io.scalecube.services.transport.rsocket.RSocketServiceTransport; +import io.scalecube.services.transport.rsocket.ServiceMessageCodec; import io.scalecube.transport.netty.websocket.WebsocketTransportFactory; import java.time.Duration; import java.util.List;