├── docs └── zanshang.jpg ├── gradle.properties ├── src ├── test │ ├── resources │ │ ├── dgate.jceks │ │ ├── fileForUpload1 │ │ ├── config │ │ │ ├── conf2 │ │ │ ├── conf1.conf │ │ │ └── conf3.conf │ │ └── fileForUpload2 │ └── groovy │ │ └── top │ │ └── dteam │ │ └── dgate │ │ ├── utils │ │ ├── TestUtils.groovy │ │ └── RequestUtilsSpec.groovy │ │ ├── config │ │ ├── LoginConfigSpec.groovy │ │ ├── ConfPropertySpec.groovy │ │ └── UpstreamURLSpec.groovy │ │ ├── handler │ │ ├── MockHandlerSpec.groovy │ │ ├── RequestHandlerSpec.groovy │ │ ├── RequestHeadersSpec.groovy │ │ ├── RelayHandlerSpec.groovy │ │ ├── CircuitBreakerSpec.groovy │ │ ├── JWTHandlerSpec.groovy │ │ ├── RelayHandlerWithCacheSpec.groovy │ │ ├── ProxyHandlerWithCacheSpec.groovy │ │ ├── CompositeRequestSpec.groovy │ │ └── ForwardRequestSpec.groovy │ │ ├── ApiGatewayIntegationSpec.groovy │ │ └── gateway │ │ └── ApiGatewaySpec.groovy └── main │ ├── groovy │ └── top │ │ └── dteam │ │ └── dgate │ │ ├── config │ │ ├── Publisher.groovy │ │ ├── MockUrlConfig.groovy │ │ ├── RelayUrlConfig.groovy │ │ ├── ProxyUrlConfig.groovy │ │ ├── Consumer.groovy │ │ ├── EventBusBridgeConfig.groovy │ │ ├── InvalidConfiguriationException.groovy │ │ ├── UrlConfig.groovy │ │ ├── ApiGatewayConfig.groovy │ │ ├── RelayTo.groovy │ │ ├── CorsConfig.groovy │ │ ├── LoginConfig.groovy │ │ ├── UpstreamURL.groovy │ │ └── ApiGatewayRepository.groovy │ │ ├── gateway │ │ └── SimpleResponse.groovy │ │ └── handler │ │ └── MockHandler.groovy │ ├── java │ └── top │ │ └── dteam │ │ └── dgate │ │ ├── utils │ │ ├── JWTTokenGenerator.java │ │ ├── JWTTokenRefresher.java │ │ ├── cache │ │ │ ├── CacheLocator.java │ │ │ └── ResponseHolder.java │ │ ├── Utils.java │ │ └── RequestUtils.java │ │ ├── MainVerticle.java │ │ ├── handler │ │ ├── LoginHandler.java │ │ ├── JWTTokenSniffer.java │ │ ├── GatewayRequestHandler.java │ │ ├── JWTTokenRefreshHandler.java │ │ ├── RequestHandler.java │ │ ├── RelayHandler.java │ │ └── ProxyHandler.java │ │ ├── monitor │ │ └── CircuitBreakerMonitor.java │ │ ├── Launcher.java │ │ └── gateway │ │ ├── ApiGateway.java │ │ └── RouterBuilder.java │ └── resources │ └── logback.groovy ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── roadmap.md ├── .github └── workflows │ └── ci.yml ├── README.md ├── examples └── conf.example ├── gradlew.bat ├── gradlew └── LICENSE /docs/zanshang.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DTeam-Top/dgate/HEAD/docs/zanshang.jpg -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | GROOVY_VER=2.5.8 2 | SPOCK_VER=1.3-groovy-2.5 3 | VERTX_VER=3.8.4 4 | LOGBACK_VER=1.2.3 5 | -------------------------------------------------------------------------------- /src/test/resources/dgate.jceks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DTeam-Top/dgate/HEAD/src/test/resources/dgate.jceks -------------------------------------------------------------------------------- /src/test/resources/fileForUpload1: -------------------------------------------------------------------------------- 1 | *.log 2 | *.vertx 3 | build 4 | *.tmp 5 | *.jceks 6 | .* 7 | !.gitignore 8 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DTeam-Top/dgate/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/test/resources/config/conf2: -------------------------------------------------------------------------------- 1 | gateway2 { 2 | port = 7002 3 | host = '0.0.0.0' 4 | urls { } 5 | } 6 | -------------------------------------------------------------------------------- /src/test/resources/config/conf1.conf: -------------------------------------------------------------------------------- 1 | gateway1 { 2 | port = 7001 3 | host = '0.0.0.0' 4 | urls { } 5 | } 6 | -------------------------------------------------------------------------------- /src/test/resources/config/conf3.conf: -------------------------------------------------------------------------------- 1 | gateway3 { 2 | port = 7003 3 | host = '0.0.0.0' 4 | urls { } 5 | } 6 | -------------------------------------------------------------------------------- /src/test/resources/fileForUpload2: -------------------------------------------------------------------------------- 1 | GROOVY_VER=2.4.7 2 | SPOCK_VER=1.0-groovy-2.4 3 | VERTX_VER=3.3.3 4 | LOGBACK_VER=1.2.1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.vertx 3 | build 4 | *.tmp 5 | *.jceks 6 | .* 7 | bin 8 | file-uploads 9 | !.gitignore 10 | !.github 11 | *.iml 12 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/Publisher.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | class Publisher { 4 | 5 | String target 6 | Object expected 7 | long timer 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/MockUrlConfig.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import groovy.transform.CompileStatic 4 | 5 | @CompileStatic 6 | class MockUrlConfig extends UrlConfig{ 7 | 8 | Map expected 9 | 10 | } 11 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.6-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/RelayUrlConfig.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import groovy.transform.CompileStatic 4 | 5 | @CompileStatic 6 | class RelayUrlConfig extends UrlConfig{ 7 | 8 | RelayTo relayTo 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/ProxyUrlConfig.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import groovy.transform.CompileStatic 4 | 5 | @CompileStatic 6 | class ProxyUrlConfig extends UrlConfig{ 7 | 8 | List upstreamURLs 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/Consumer.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import groovy.transform.CompileStatic 4 | 5 | @CompileStatic 6 | class Consumer { 7 | 8 | String address 9 | String target 10 | Object expected 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/EventBusBridgeConfig.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import groovy.transform.CompileStatic 4 | 5 | @CompileStatic 6 | class EventBusBridgeConfig { 7 | 8 | String urlPattern 9 | List publishers 10 | List consumers 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/utils/TestUtils.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.utils 2 | 3 | class TestUtils { 4 | 5 | static void waitResult(def result, long timeout) { 6 | int i = 0 7 | while (!result && i < timeout) { 8 | sleep(1) 9 | i++ 10 | } 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/InvalidConfiguriationException.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import groovy.transform.CompileStatic 4 | 5 | @CompileStatic 6 | class InvalidConfiguriationException extends RuntimeException{ 7 | 8 | InvalidConfiguriationException(String error) { 9 | super(error) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/UrlConfig.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import groovy.transform.CompileStatic 4 | import io.vertx.core.http.HttpMethod 5 | 6 | @CompileStatic 7 | abstract class UrlConfig { 8 | 9 | String url 10 | int expires = 0 11 | Object required 12 | List methods 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/ApiGatewayConfig.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import groovy.transform.CompileStatic 4 | 5 | @CompileStatic 6 | class ApiGatewayConfig { 7 | 8 | String name 9 | int port 10 | String host = '0.0.0.0' 11 | LoginConfig login 12 | CorsConfig cors 13 | List urlConfigs 14 | EventBusBridgeConfig eventBusBridgeConfig 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/RelayTo.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import groovy.transform.CompileStatic 4 | import io.vertx.circuitbreaker.CircuitBreakerOptions 5 | 6 | @CompileStatic 7 | class RelayTo { 8 | 9 | String host 10 | int port 11 | CircuitBreakerOptions circuitBreaker 12 | 13 | @Override 14 | String toString() { 15 | "$host-$port" 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/CorsConfig.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import groovy.transform.CompileStatic 4 | import io.vertx.core.http.HttpMethod 5 | 6 | @CompileStatic 7 | class CorsConfig { 8 | 9 | String allowedOriginPattern 10 | Set allowedHeaders 11 | Set allowedMethods 12 | Set exposedHeaders 13 | Integer maxAgeSeconds 14 | Boolean allowCredentials 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/gateway/SimpleResponse.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.gateway 2 | 3 | import groovy.transform.CompileStatic 4 | import groovy.transform.EqualsAndHashCode 5 | import io.vertx.core.json.JsonObject 6 | 7 | @EqualsAndHashCode 8 | @CompileStatic 9 | class SimpleResponse { 10 | 11 | int statusCode 12 | JsonObject payload 13 | 14 | JsonObject toJsonObject() { 15 | JsonObject jsonObject = new JsonObject() 16 | jsonObject.put("statusCode", statusCode).put("payload", payload) 17 | 18 | jsonObject 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/utils/JWTTokenGenerator.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.utils; 2 | 3 | import io.vertx.core.json.JsonObject; 4 | import io.vertx.ext.auth.jwt.JWTAuth; 5 | import io.vertx.ext.jwt.JWTOptions; 6 | 7 | import java.util.Map; 8 | 9 | public class JWTTokenGenerator { 10 | 11 | private JWTAuth jwtAuth; 12 | 13 | public JWTTokenGenerator(JWTAuth jwtAuth) { 14 | this.jwtAuth = jwtAuth; 15 | } 16 | 17 | public String token(Map payload, int expiration) { 18 | JWTOptions options = new JWTOptions(); 19 | options.setExpiresInSeconds(expiration); 20 | return jwtAuth.generateToken(new JsonObject(payload), options); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /roadmap.md: -------------------------------------------------------------------------------- 1 | # dgate路线图 2 | 3 | 注:本路线图是纯粹的乱想集,仅为记录一些想法,一旦实现或觉得不合适,就会从中剔除,故读者请勿严肃对待。 4 | 5 | ## 支持多种安全机制 6 | 7 | ~~~ 8 | security { 9 | mechanism = 'JWT' 10 | login = xxx 11 | } 12 | ~~~ 13 | 14 | 这样即可支持多种安全机制,如未来的OAuth2 15 | 16 | ## 支持多种发现机制 17 | 18 | - direct,类似目前的直接链接,用ip 19 | - cluster,vertx目前的默认方式 20 | - consul,consul集成 21 | 22 | ## 支持多类型后端 23 | 24 | 后端服务未必全部都是RESTful service,还可以是其他类型,如eventbus端点 25 | 26 | ## 黑白名单 27 | 28 | 支持黑名单和白名单 29 | 30 | ## Reactive编程模型,rxjava 31 | 32 | 考虑采用rx编程模型进一步提高性能,当然,前后需要比较一下。目前大致如下(单request,与直接发往后台service对比): 33 | - CompletableFuture.allOf + CompletableFuture.whenCompleted,+200ms 34 | - 遍历每个CompletableFuture + Atomic变量,+400ms 35 | 36 | ## 断路器支持配置 37 | 38 | 目前dgate的断路器是默认配置,未来可考虑支持配置选项 -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/MainVerticle.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate; 2 | 3 | import io.vertx.core.AbstractVerticle; 4 | import top.dteam.dgate.config.ApiGatewayRepository; 5 | import top.dteam.dgate.gateway.ApiGateway; 6 | import top.dteam.dgate.monitor.CircuitBreakerMonitor; 7 | import top.dteam.dgate.utils.cache.CacheLocator; 8 | 9 | public class MainVerticle extends AbstractVerticle { 10 | 11 | @Override 12 | public void start() { 13 | vertx.deployVerticle(new CircuitBreakerMonitor()); 14 | 15 | ApiGatewayRepository.load(); 16 | ApiGatewayRepository.getRespository().stream() 17 | .forEach(apiGatewayConfig -> vertx.deployVerticle(new ApiGateway(apiGatewayConfig))); 18 | 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/handler/LoginHandler.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler; 2 | 3 | import io.vertx.core.Vertx; 4 | import io.vertx.ext.auth.jwt.JWTAuth; 5 | import top.dteam.dgate.config.ProxyUrlConfig; 6 | import top.dteam.dgate.utils.JWTTokenGenerator; 7 | 8 | import java.util.HashMap; 9 | import java.util.Map; 10 | 11 | public class LoginHandler extends ProxyHandler { 12 | 13 | private JWTTokenGenerator tokenGenerator; 14 | 15 | public LoginHandler(Vertx vertx, ProxyUrlConfig urlConfig, JWTAuth jwtAuth) { 16 | super(vertx, urlConfig); 17 | this.tokenGenerator = new JWTTokenGenerator(jwtAuth); 18 | } 19 | 20 | @Override 21 | protected Map createAfterContext() { 22 | Map context = new HashMap<>(); 23 | context.put("tokenGenerator", tokenGenerator); 24 | return context; 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request, release] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: actions/cache@v1 11 | with: 12 | path: ~/.gradle/caches 13 | key: dgate-gradle-cache 14 | - uses: actions/setup-java@v1 15 | with: 16 | java-version: 8 17 | - uses: eskatos/gradle-command-action@v1 18 | with: 19 | arguments: -i --no-daemon shadowJar 20 | test: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v1 24 | - uses: actions/cache@v1 25 | with: 26 | path: ~/.gradle/caches 27 | key: dgate-gradle-cache 28 | - uses: actions/setup-java@v1 29 | with: 30 | java-version: 8 31 | - uses: eskatos/gradle-command-action@v1 32 | with: 33 | arguments: -i --no-daemon test --fail-fast 34 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/handler/JWTTokenSniffer.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler; 2 | 3 | import io.vertx.core.Handler; 4 | import io.vertx.ext.jwt.JWT; 5 | import io.vertx.ext.web.RoutingContext; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import top.dteam.dgate.utils.Utils; 9 | 10 | public class JWTTokenSniffer implements Handler { 11 | 12 | private static final Logger logger = LoggerFactory.getLogger(JWTTokenSniffer.class); 13 | 14 | private JWT jwt; 15 | 16 | public JWTTokenSniffer(JWT jwt) { 17 | this.jwt = jwt; 18 | } 19 | 20 | @Override 21 | public void handle(RoutingContext routingContext) { 22 | String payload = Utils.getTokenFromHeader(routingContext.request()); 23 | 24 | if (payload != null) { 25 | try { 26 | routingContext.put("token", jwt.decode(payload)); 27 | } catch (RuntimeException e) { 28 | logger.error(e.getMessage()); 29 | } 30 | } 31 | 32 | routingContext.next(); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/monitor/CircuitBreakerMonitor.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.monitor; 2 | 3 | import io.vertx.core.AbstractVerticle; 4 | import io.vertx.core.eventbus.MessageConsumer; 5 | import io.vertx.core.json.JsonObject; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | 9 | public class CircuitBreakerMonitor extends AbstractVerticle { 10 | 11 | private static final Logger logger = LoggerFactory.getLogger(CircuitBreakerMonitor.class); 12 | 13 | private MessageConsumer consumer; 14 | 15 | @Override 16 | public void start() { 17 | consumer = vertx.eventBus().consumer("vertx.circuit-breaker", message -> { 18 | JsonObject body = (JsonObject) message.body(); 19 | logger.debug("~~~~~ Circuit Breaker Status: node={}, name={}, state={}, failures={} ~~~~~\n", 20 | body.getString("node"), body.getString("name"), body.getString("state"), body.getInteger("failures")); 21 | }); 22 | } 23 | 24 | @Override 25 | public void stop() { 26 | consumer.unregister(); //otherwise, an Exception will be thrown when stopped by ctrl-c 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/utils/JWTTokenRefresher.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.utils; 2 | 3 | import io.vertx.core.Vertx; 4 | import io.vertx.core.json.JsonObject; 5 | import io.vertx.ext.jwt.JWT; 6 | 7 | public class JWTTokenRefresher { 8 | 9 | private JWT jwt; 10 | private JWTTokenGenerator tokenGenerator; 11 | private JsonObject payload; 12 | 13 | public JWTTokenRefresher(Vertx vertx) { 14 | tokenGenerator = new JWTTokenGenerator(Utils.createAuthProvider(vertx)); 15 | jwt = Utils.createJWT(vertx); 16 | } 17 | 18 | public void setPayload(String payload) { 19 | this.payload = jwt.decode(payload); 20 | } 21 | 22 | public boolean lessThan(long refreshLimit) { 23 | return ((System.currentTimeMillis() / 1000) - payload.getLong("exp")) <= refreshLimit; 24 | } 25 | 26 | public String refresh(int refreshExpire) { 27 | payload.remove("exp"); 28 | payload.remove("iat"); 29 | payload.remove("nbf"); 30 | payload.remove("aud"); 31 | payload.remove("iss"); 32 | return tokenGenerator.token(payload.getMap(), refreshExpire); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/main/resources/logback.groovy: -------------------------------------------------------------------------------- 1 | import ch.qos.logback.classic.Level 2 | import ch.qos.logback.classic.encoder.PatternLayoutEncoder 3 | import ch.qos.logback.core.ConsoleAppender 4 | import ch.qos.logback.core.rolling.RollingFileAppender 5 | import ch.qos.logback.core.rolling.FixedWindowRollingPolicy 6 | import ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy 7 | 8 | appender("Console", ConsoleAppender) { 9 | encoder(PatternLayoutEncoder) { 10 | pattern = "%d [%thread] %-5level %logger{36} - %msg%n" 11 | } 12 | } 13 | 14 | appender("R", RollingFileAppender) { 15 | file = "dgate.log" 16 | encoder(PatternLayoutEncoder) { 17 | pattern = "%d [%thread] %-5level %logger{36} - %msg%n" 18 | } 19 | rollingPolicy(FixedWindowRollingPolicy) { 20 | fileNamePattern = "dgate.log.%i" 21 | minIndex = 1 22 | maxIndex = 10 23 | } 24 | triggeringPolicy(SizeBasedTriggeringPolicy) { 25 | maxFileSize = "10MB" 26 | } 27 | } 28 | 29 | logger("io.vertx", Level.WARN) 30 | logger("io.netty", Level.WARN) 31 | logger("ch.qos.logback", Level.WARN) 32 | 33 | final String DGATE_LOG_LEVEL = System.getProperty("DGATE_LOG_LEVEL") ?: 34 | System.getenv("DGATE_LOG_LEVEL") 35 | 36 | root(Level.valueOf(DGATE_LOG_LEVEL), ["Console", "R"]) 37 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/handler/GatewayRequestHandler.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler; 2 | 3 | import io.vertx.core.Handler; 4 | import io.vertx.core.Vertx; 5 | import io.vertx.ext.auth.jwt.JWTAuth; 6 | import io.vertx.ext.web.RoutingContext; 7 | import top.dteam.dgate.config.*; 8 | 9 | public interface GatewayRequestHandler extends Handler { 10 | 11 | GatewayRequestHandler nameOfApiGateway(String nameOfApiGateway); 12 | 13 | static GatewayRequestHandler create(Vertx vertx, UrlConfig urlConfig, JWTAuth jwtAuth) { 14 | if (ProxyUrlConfig.class == urlConfig.getClass()) { 15 | if (jwtAuth == null) { 16 | return new ProxyHandler(vertx, (ProxyUrlConfig) urlConfig); 17 | } else { 18 | return new LoginHandler(vertx, (ProxyUrlConfig) urlConfig, jwtAuth); 19 | } 20 | } else if (MockUrlConfig.class == urlConfig.getClass()) { 21 | return new MockHandler(vertx, (MockUrlConfig) urlConfig); 22 | } else if (RelayUrlConfig.class == urlConfig.getClass()) { 23 | return new RelayHandler(vertx, (RelayUrlConfig) urlConfig); 24 | } else { 25 | throw new InvalidConfiguriationException(String.format("Unknown URL Config Type: %s", urlConfig.getClass())); 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/handler/MockHandler.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler 2 | 3 | import io.vertx.core.Vertx 4 | import io.vertx.core.http.HttpServerRequest 5 | import io.vertx.core.http.HttpServerResponse 6 | import io.vertx.core.json.JsonObject 7 | import top.dteam.dgate.config.MockUrlConfig 8 | import top.dteam.dgate.utils.Utils 9 | 10 | class MockHandler extends RequestHandler { 11 | 12 | private Map expectedResponse 13 | 14 | MockHandler(Vertx vertx, MockUrlConfig urlConfig) { 15 | super(vertx, urlConfig) 16 | this.expectedResponse = urlConfig.expected 17 | } 18 | 19 | @Override 20 | protected void processRequestBody(HttpServerRequest request, HttpServerResponse response, JsonObject body) { 21 | if (expectedResponse.statusCode && expectedResponse.payload) { 22 | Utils.fireJsonResponse(response, transformIfNeeded(expectedResponse.statusCode), 23 | transformIfNeeded(expectedResponse.payload)) 24 | } else { 25 | String key = request.method().toString().toLowerCase() 26 | Utils.fireJsonResponse(response, transformIfNeeded(expectedResponse[key].statusCode), 27 | transformIfNeeded(expectedResponse[key].payload)) 28 | } 29 | } 30 | 31 | private def transformIfNeeded(def initValue) { 32 | (initValue instanceof Closure) ? initValue() : initValue 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/LoginConfig.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import groovy.transform.CompileStatic 4 | 5 | @CompileStatic 6 | class LoginConfig { 7 | 8 | static long DEFAULT_REFRESH_LIMIT = 30 * 60 9 | static int DEFAULT_REFRESH_EXPIRE = 30 * 60 10 | 11 | private String url 12 | private Map config 13 | 14 | LoginConfig(def login) { 15 | if (login instanceof String) { 16 | url = login 17 | } else if (login instanceof Map) { 18 | if (login.ignore && login.only) { 19 | throw new InvalidConfiguriationException('ignore and only both can not be in login config.') 20 | } 21 | 22 | config = login as Map 23 | } else { 24 | throw new InvalidConfiguriationException('login could be a String or a Map only.') 25 | } 26 | } 27 | 28 | String login() { 29 | if (url) { 30 | url 31 | } else { 32 | config?.url 33 | } 34 | } 35 | 36 | List ignore() { 37 | (List) (config?.ignore ?: []) 38 | } 39 | 40 | List only() { 41 | (List) (config?.only ?: []) 42 | } 43 | 44 | long refreshLimit() { 45 | (long) (config?.refreshLimit ?: DEFAULT_REFRESH_LIMIT) 46 | } 47 | 48 | int refreshExpire() { 49 | (int) (config?.refreshExpire ?: DEFAULT_REFRESH_EXPIRE) 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/utils/cache/CacheLocator.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.utils.cache; 2 | 3 | import io.vertx.core.Vertx; 4 | import io.vertx.core.impl.VertxInternal; 5 | import io.vertx.core.spi.cluster.ClusterManager; 6 | import org.apache.ignite.Ignite; 7 | import org.apache.ignite.IgniteCache; 8 | import org.apache.ignite.Ignition; 9 | import org.apache.ignite.configuration.CacheConfiguration; 10 | 11 | import java.util.UUID; 12 | 13 | public class CacheLocator { 14 | private static Ignite ignite; 15 | 16 | public static void init(Vertx vertx) { 17 | // Get ignite instance from vertx instance 18 | if (ignite == null) { 19 | ClusterManager clusterManager = ((VertxInternal) vertx).getClusterManager(); 20 | String uuid = clusterManager.getNodeID(); 21 | ignite = Ignition.ignite(UUID.fromString(uuid)); 22 | } 23 | } 24 | 25 | public static void close() { 26 | if (ignite != null) { 27 | ignite.close(); 28 | ignite = null; 29 | } 30 | } 31 | 32 | static IgniteCache getCacheByName(String cacheName) { 33 | return ignite.cache(cacheName); 34 | } 35 | 36 | static boolean containsCache(String cacheName) { 37 | return ignite.cacheNames().contains(cacheName); 38 | } 39 | 40 | static IgniteCache getOrCreateCache(CacheConfiguration cacheCfg) { 41 | return ignite.getOrCreateCache(cacheCfg); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dgate:an API Gateway based on Vert.x 2 | 3 | ![badge](https://github.com/DTeam-Top/dgate/workflows/CI/badge.svg) 4 | 5 | dgate是基于Vertx的API Gateway。运行dgate的命令如下: 6 | 7 | ~~~ 8 | java -jar dgate-version-fat.jar -Dconf=conf 9 | ~~~ 10 | 11 | 其中,conf中定义了路由规则,下面是一个简单的例子: 12 | 13 | ~~~ 14 | apiGateway { 15 | port = 7000 16 | host = 'localhost' 17 | urls { 18 | "/url1" { 19 | required = ['param1', 'param2'] 20 | methods = ['GET', 'POST'] 21 | upstreamURLs = [ 22 | [host: 'localhost', port: 8080, url: '/test'] 23 | ] 24 | } 25 | "/url2" { 26 | required = ['param1', 'param2'] 27 | methods = ['GET', 'POST'] 28 | upstreamURLs = [ 29 | [host: 'localhost', port: 8080, url: '/test1'], 30 | [host: 'localhost', port: 8080, url: '/test2'] 31 | ] 32 | } 33 | } 34 | } 35 | ~~~ 36 | 37 | dgate的主要特性: 38 | - 轻量级配置,无需后端DB 39 | - DSL为groovy语法 40 | - 支持mock:HTTP和EventBusBridge 41 | - 支持url的转发和组合(即一个外部url对应后端多个url),并支持before(向后端发送请求前)和after(收到后端全部响应后)闭包。 42 | - 支持request透传:form和upload用这种模式。 43 | - 支持URL Path Parameters 44 | - 支持JWT 45 | - 支持CORS 46 | - 灵活的login配置(此时,在启动时需要先设置环境变量,请参见手册) 47 | - 支持断路器 48 | - 灵活的请求缓存策略 49 | - 支持集群 50 | 51 | 详细的用户指南请访问[这里](./docs/user_guide.md)。 52 | 53 | ## 开发指南 54 | 55 | - git clone 56 | - ./gradlew shadowJar,生成dgate的fatjar 57 | - ./gradlew test,运行测试代码 58 | 59 | 在发起Pull Request时,请同时提交测试代码,并保证现有测试代码【对于测试,我们推荐[Spock](http://spockframework.org/)】能全部通过,;)。 60 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/handler/JWTTokenRefreshHandler.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler; 2 | 3 | import io.vertx.core.Handler; 4 | import io.vertx.ext.web.RoutingContext; 5 | import top.dteam.dgate.utils.JWTTokenRefresher; 6 | import top.dteam.dgate.utils.Utils; 7 | 8 | import java.util.HashMap; 9 | 10 | public class JWTTokenRefreshHandler implements Handler { 11 | 12 | public static final String URL = "/token-refresh"; 13 | 14 | private JWTTokenRefresher jwtTokenRefresher; 15 | private long refreshLimit; 16 | private int refreshExpire; 17 | 18 | public JWTTokenRefreshHandler(JWTTokenRefresher jwtTokenRefresher, long refreshLimit, int refreshExpire) { 19 | this.jwtTokenRefresher = jwtTokenRefresher; 20 | this.refreshLimit = refreshLimit; 21 | this.refreshExpire = refreshExpire; 22 | } 23 | 24 | @Override 25 | public void handle(RoutingContext routingContext) { 26 | String payload = Utils.getTokenFromHeader(routingContext.request()); 27 | 28 | if (payload != null) { 29 | jwtTokenRefresher.setPayload(payload); 30 | if (jwtTokenRefresher.lessThan(refreshLimit)) { 31 | HashMap tokenMap = new HashMap<>(); 32 | tokenMap.put("token", jwtTokenRefresher.refresh(refreshExpire)); 33 | Utils.fireJsonResponse(routingContext.response(), 200, tokenMap); 34 | } else { 35 | Utils.fireSingleMessageResponse(routingContext.response(), 401); 36 | } 37 | } else { 38 | Utils.fireSingleMessageResponse(routingContext.response(), 401); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/config/LoginConfigSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import spock.lang.Specification 4 | import spock.lang.Unroll 5 | 6 | class LoginConfigSpec extends Specification { 7 | 8 | def "should work for String"() { 9 | when: 10 | LoginConfig loginConfig = new LoginConfig('/login') 11 | 12 | then: 13 | loginConfig.login() == '/login' 14 | loginConfig.refreshLimit() == LoginConfig.DEFAULT_REFRESH_LIMIT 15 | !loginConfig.ignore() 16 | !loginConfig.only() 17 | } 18 | 19 | @Unroll 20 | def "should work for Map: #config"() { 21 | when: 22 | LoginConfig loginConfig = new LoginConfig(config) 23 | 24 | then: 25 | loginConfig.login() == config.url 26 | loginConfig.ignore() == (config.ignore ?: []) 27 | loginConfig.only() == (config.only ?: []) 28 | loginConfig.refreshLimit() == (config.refreshLimit ?: LoginConfig.DEFAULT_REFRESH_LIMIT) 29 | loginConfig.refreshExpire() == (config.refreshExpire ?: LoginConfig.DEFAULT_REFRESH_EXPIRE) 30 | 31 | where: 32 | config << [ 33 | [url: '/login1', ignore: ['/url1', '/url2']], 34 | [url: '/login1', only: ['/url3']], 35 | [url: '/login1', refreshLimit: 3000], 36 | [url: '/login1', refreshExpire: 4000] 37 | ] 38 | } 39 | 40 | def "should throw InvalidConfiguriationException when ignore and only are both in config block"() { 41 | when: 42 | new LoginConfig([url: '/login1', ignore: ['/url1'], only: ['/url2']]) 43 | 44 | then: 45 | thrown(InvalidConfiguriationException) 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/config/ConfPropertySpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import spock.lang.Specification 4 | 5 | class ConfPropertySpec extends Specification { 6 | def "conf property should be working with single file"() { 7 | when: 8 | ApiGatewayRepository.load("src/test/resources/config/conf2") 9 | 10 | then: 11 | ApiGatewayRepository.respository.size() == 1 12 | with(ApiGatewayRepository.respository[0]) { 13 | name == 'gateway2' 14 | port == 7002 15 | host == '0.0.0.0' 16 | } 17 | } 18 | 19 | def "conf property should be working with single directory"() { 20 | when: 21 | ApiGatewayRepository.load("src/test/resources/config") 22 | 23 | then: 24 | ApiGatewayRepository.respository.size() == 2 25 | if(ApiGatewayRepository.respository[0].name == 'gateway1') { 26 | with(ApiGatewayRepository.respository[0]) { 27 | name == 'gateway1' 28 | port == 7001 29 | host == '0.0.0.0' 30 | } 31 | with(ApiGatewayRepository.respository[1]) { 32 | name == 'gateway3' 33 | port == 7003 34 | host == '0.0.0.0' 35 | } 36 | }else { 37 | with(ApiGatewayRepository.respository[0]) { 38 | name == 'gateway3' 39 | port == 7003 40 | host == '0.0.0.0' 41 | } 42 | with(ApiGatewayRepository.respository[1]) { 43 | name == 'gateway1' 44 | port == 7001 45 | host == '0.0.0.0' 46 | } 47 | } 48 | 49 | } 50 | 51 | def "conf property should throw NoSuchFileException if no such file or directory"() { 52 | when: 53 | ApiGatewayRepository.load("/no/such/file/or/directory") 54 | 55 | then: 56 | thrown(FileNotFoundException) 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/UpstreamURL.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import groovy.transform.CompileStatic 4 | import groovy.transform.EqualsAndHashCode 5 | import io.vertx.circuitbreaker.CircuitBreakerOptions 6 | import io.vertx.core.json.JsonObject 7 | import top.dteam.dgate.gateway.SimpleResponse 8 | 9 | @EqualsAndHashCode(excludes = ['before', 'after', 'circuitBreaker']) 10 | @CompileStatic 11 | class UpstreamURL { 12 | 13 | String host 14 | int port 15 | String url 16 | int expires = 0 17 | CircuitBreakerOptions circuitBreaker 18 | 19 | Closure before 20 | Closure after 21 | 22 | String resolve(JsonObject context) { 23 | String result = resolveParams(getParamsFromUrl(url), context) 24 | verifyUrl(result) ?: '/' 25 | } 26 | 27 | private static List getParamsFromUrl(String url) { 28 | Arrays.asList(url.split('/')).findAll { part -> part.startsWith(':') } 29 | } 30 | 31 | private String resolveParams(List params, JsonObject context) { 32 | String result = url 33 | 34 | params?.each { param -> 35 | String value = param.endsWith('?') ? context.getValue(param[1..-2]) : context.getValue(param[1..-1]) 36 | if (value) { 37 | result = result.replace(param, value) 38 | } 39 | } 40 | 41 | result 42 | } 43 | 44 | private static String verifyUrl(String url) { 45 | String result = url 46 | 47 | List unresolvedParams = getParamsFromUrl(result) 48 | unresolvedParams.reverseEach { unresolvedParam -> 49 | if (unresolvedParam.endsWith('?') && result.endsWith(unresolvedParam)) { 50 | result = result - "/${unresolvedParam}" 51 | } else { 52 | throw new InvalidConfiguriationException("无效的URL格式,参数值或格式不对") 53 | } 54 | } 55 | 56 | result 57 | } 58 | 59 | @Override 60 | String toString() { 61 | "$host-$port-$url" 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/Launcher.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate; 2 | 3 | import io.vertx.core.Vertx; 4 | import io.vertx.core.VertxOptions; 5 | import io.vertx.spi.cluster.ignite.IgniteClusterManager; 6 | import org.apache.ignite.configuration.IgniteConfiguration; 7 | import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; 8 | import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | import top.dteam.dgate.utils.cache.CacheLocator; 12 | 13 | import java.util.Arrays; 14 | 15 | public class Launcher extends io.vertx.core.Launcher { 16 | private static final Logger logger = LoggerFactory.getLogger(Launcher.class); 17 | 18 | public static void main(String[] args) { 19 | 20 | //Force to use slf4j 21 | System.setProperty("vertx.logger-delegate-factory-class-name", 22 | "io.vertx.core.logging.SLF4JLogDelegateFactory"); 23 | 24 | new Launcher().dispatch(args); 25 | } 26 | 27 | @Override 28 | public void beforeStartingVertx(VertxOptions options) { 29 | System.setProperty("IGNITE_NO_SHUTDOWN_HOOK", "true"); 30 | options.setClusterManager(new IgniteClusterManager(igniteConfiguration())); 31 | options.getEventBusOptions().setClustered(true); 32 | } 33 | 34 | @Override 35 | public void afterStartingVertx(Vertx vertx) { 36 | CacheLocator.init(vertx); 37 | } 38 | 39 | private static IgniteConfiguration igniteConfiguration() { 40 | // static IP Based Discovery, see: https://apacheignite.readme.io/docs/cluster-config#section-static-ip-based-discovery 41 | String clusterNodes = System.getenv("DGATE_CLUSTER_NODES") == null ? 42 | "localhost" : System.getenv("DGATE_CLUSTER_NODES"); 43 | 44 | TcpDiscoverySpi spi = new TcpDiscoverySpi(); 45 | TcpDiscoveryVmIpFinder ipFinder = new TcpDiscoveryVmIpFinder(); 46 | 47 | ipFinder.setAddresses(Arrays.asList(clusterNodes.split(","))); 48 | spi.setIpFinder(ipFinder); 49 | IgniteConfiguration cfg = new IgniteConfiguration(); 50 | 51 | // Override default discovery SPI. 52 | cfg.setDiscoverySpi(spi); 53 | 54 | logger.info("Dgate is working on ip based cluster mode. " + 55 | "Cluster ip list: {}", clusterNodes); 56 | 57 | return cfg; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/utils/RequestUtilsSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.utils 2 | 3 | import top.dteam.dgate.gateway.SimpleResponse 4 | import io.vertx.core.Vertx 5 | import io.vertx.core.http.HttpMethod 6 | import io.vertx.core.http.HttpServer 7 | import io.vertx.core.json.JsonObject 8 | import io.vertx.ext.web.Router 9 | import spock.lang.Specification 10 | import spock.lang.Unroll 11 | 12 | class RequestUtilsSpec extends Specification { 13 | 14 | Vertx vertx 15 | HttpServer httpServer 16 | Router router 17 | RequestUtils requestUtils 18 | 19 | void setup() { 20 | vertx = Vertx.vertx() 21 | httpServer = vertx.createHttpServer() 22 | router = Router.router(vertx) 23 | httpServer.requestHandler(router.&accept).listen(8081) 24 | requestUtils = new RequestUtils(vertx) 25 | } 26 | 27 | void cleanup() { 28 | httpServer.close() 29 | vertx.close() 30 | } 31 | 32 | @Unroll 33 | def "#method should work【hasBody=#hasBody】"() { 34 | setup: 35 | SimpleResponse result 36 | router.route("/test").handler(createHandler(hasBody)) 37 | 38 | when: 39 | sleep(100) 40 | requestUtils."$method"("localhost", 8081, "/test", params) { simpleResponse -> 41 | result = simpleResponse 42 | } 43 | TestUtils.waitResult(result, 1500) 44 | 45 | then: 46 | result.statusCode == 200 47 | if (hasBody) { 48 | result.payload.toString() == new JsonObject([method: httpMethod, params: params]).toString() 49 | } else { 50 | !result.payload 51 | } 52 | 53 | cleanup: 54 | router.clear() 55 | 56 | where: 57 | method | params | httpMethod | hasBody 58 | "get" | new JsonObject([method: "get"]) | HttpMethod.GET | true 59 | "get" | new JsonObject([method: "get"]) | HttpMethod.GET | false 60 | "post" | new JsonObject([method: "post"]) | HttpMethod.POST | true 61 | "post" | new JsonObject([method: "post"]) | HttpMethod.POST | false 62 | "delete" | new JsonObject([method: "delete"]) | HttpMethod.DELETE | true 63 | "delete" | new JsonObject([method: "delete"]) | HttpMethod.DELETE | false 64 | } 65 | 66 | private Closure createHandler(boolean hasBody) { 67 | { routingContext -> 68 | routingContext.request().bodyHandler({ totalBuffer -> 69 | if (hasBody) { 70 | Utils.fireJsonResponse(routingContext.response(), 200, 71 | [method: routingContext.request().method(), 72 | params: totalBuffer.toJsonObject()]) 73 | } else { 74 | Utils.fireSingleMessageResponse(routingContext.response(), 200) 75 | } 76 | }) 77 | } 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /examples/conf.example: -------------------------------------------------------------------------------- 1 | // To run this example: java -jar dgate-0.0.1-fat.jar -Dconf=conf.example 2 | // 1. Test login handler (401 expected) 3 | // curl http://localhost:7000/mock -v 4 | // 2. Test JWT ({"test":true} expected) 5 | // - curl -X POST -F "sub=any_value" -F "password=any_value" http://localhost:7000/login 6 | // - curl -H "Authorization:Bearer Token_returned_by_last_command" http://localhost:7000/mock 7 | 8 | import io.vertx.core.http.HttpMethod 9 | import io.vertx.core.Vertx 10 | import io.vertx.ext.auth.jwt.JWTAuth 11 | import top.dteam.dgate.utils.* 12 | 13 | apiGateway1 { 14 | port = 7000 15 | login = "/login" 16 | urls { 17 | "/login" { 18 | required = ["sub", "password"] 19 | methods = [HttpMethod.GET, HttpMethod.POST] 20 | expected { 21 | statusCode = 200 22 | payload = { 23 | JWTAuth jwtAuth = Utils.createAuthProvider(Vertx.vertx()) 24 | JWTTokenGenerator tokenGenerator = new JWTTokenGenerator(jwtAuth) 25 | [token: tokenGenerator.token(["sub": "13572209183", "name": "foxgem", "role": "normal"], 200)] 26 | } 27 | } 28 | } 29 | "/mock" { 30 | expected { 31 | statusCode = 200 32 | payload = [test: true] 33 | } 34 | } 35 | "/forward" { 36 | required = ['param1', 'param2'] 37 | methods = [HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE] 38 | upstreamURLs = [ 39 | [ 40 | host: 'localhost', port: 8080, url: '/test', 41 | before: { jsonObject -> jsonObject }, 42 | after: { simpleResponse -> simpleResponse } 43 | ] 44 | ] 45 | } 46 | "/composite" { 47 | required = ['param1', 'param2'] 48 | methods = [HttpMethod.GET, HttpMethod.POST] 49 | upstreamURLs = [ 50 | [host: 'localhost', port: 8080, url: '/test1'], 51 | [host: 'localhost', port: 8080, url: '/test2'] 52 | ] 53 | } 54 | } 55 | } 56 | apiGateway2 { 57 | port = 7001 58 | cors { 59 | allowedOriginPattern = "*" 60 | allowedMethods = [HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE] 61 | allowedHeaders = ['content-type'] 62 | allowCredentials = true 63 | } 64 | urls { 65 | "/mock" { 66 | expected { 67 | get { 68 | statusCode = 200 69 | payload = [method: 'get'] 70 | } 71 | post { 72 | statusCode = 200 73 | payload = [method: 'post'] 74 | } 75 | delete { 76 | statusCode = 200 77 | payload = [method: 'delete'] 78 | } 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/handler/MockHandlerSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler 2 | 3 | import io.vertx.core.Vertx 4 | import io.vertx.core.http.HttpServer 5 | import io.vertx.core.json.JsonObject 6 | import io.vertx.ext.web.Router 7 | import spock.lang.Specification 8 | import spock.lang.Unroll 9 | import top.dteam.dgate.config.MockUrlConfig 10 | import top.dteam.dgate.gateway.SimpleResponse 11 | import top.dteam.dgate.utils.RequestUtils 12 | import top.dteam.dgate.utils.TestUtils 13 | 14 | class MockHandlerSpec extends Specification { 15 | 16 | Vertx vertx 17 | HttpServer gate 18 | RequestUtils requestUtils 19 | 20 | void setup() { 21 | vertx = Vertx.vertx() 22 | gate = createGate() 23 | requestUtils = new RequestUtils(vertx) 24 | } 25 | 26 | void cleanup() { 27 | gate.close() 28 | vertx.close() 29 | } 30 | 31 | @Unroll 32 | def "could provide a mock response for all HTTP Methods: #method"() { 33 | setup: 34 | SimpleResponse result 35 | 36 | when: 37 | sleep(100) 38 | requestUtils."$method"("localhost", 8081, "/mock", new JsonObject()) { simpleResponse -> 39 | result = simpleResponse 40 | } 41 | TestUtils.waitResult(result, 1500) 42 | 43 | then: 44 | result.toJsonObject() == new JsonObject([statusCode: 200, payload: [method: 'all']]) 45 | 46 | where: 47 | method << ['get', 'post', 'delete'] 48 | } 49 | 50 | @Unroll 51 | def "could provide a mock response for a pointed HTTP Method: #method"() { 52 | setup: 53 | SimpleResponse result 54 | 55 | when: 56 | sleep(100) 57 | requestUtils."$method"("localhost", 8081, "/mock-methods", new JsonObject()) { simpleResponse -> 58 | result = simpleResponse 59 | } 60 | TestUtils.waitResult(result, 1500) 61 | 62 | then: 63 | result.toJsonObject() == new JsonObject([statusCode: 200, payload: [method: method]]) 64 | 65 | where: 66 | method << ['get', 'post', 'delete'] 67 | } 68 | 69 | private HttpServer createGate() { 70 | HttpServer httpServer = vertx.createHttpServer() 71 | Router router = Router.router(vertx) 72 | httpServer.requestHandler(router.&accept).listen(8081) 73 | 74 | router.route("/mock").handler( 75 | new MockHandler(vertx, 76 | new MockUrlConfig(required: null, expected: [statusCode: 200, payload: [method: 'all']]))) 77 | router.route("/mock-methods").handler( 78 | new MockHandler(vertx, 79 | new MockUrlConfig(required: null, 80 | expected: [get : [statusCode: { 200 }, payload: [method: 'get']], 81 | post : [statusCode: 200, payload: [method: 'post']], 82 | delete: [statusCode: 200, payload: { [method: 'delete'] }]]))) 83 | 84 | httpServer 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 33 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 34 | 35 | @rem Find java.exe 36 | if defined JAVA_HOME goto findJavaFromJavaHome 37 | 38 | set JAVA_EXE=java.exe 39 | %JAVA_EXE% -version >NUL 2>&1 40 | if "%ERRORLEVEL%" == "0" goto init 41 | 42 | echo. 43 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 44 | echo. 45 | echo Please set the JAVA_HOME variable in your environment to match the 46 | echo location of your Java installation. 47 | 48 | goto fail 49 | 50 | :findJavaFromJavaHome 51 | set JAVA_HOME=%JAVA_HOME:"=% 52 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 53 | 54 | if exist "%JAVA_EXE%" goto init 55 | 56 | echo. 57 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 58 | echo. 59 | echo Please set the JAVA_HOME variable in your environment to match the 60 | echo location of your Java installation. 61 | 62 | goto fail 63 | 64 | :init 65 | @rem Get command-line arguments, handling Windows variants 66 | 67 | if not "%OS%" == "Windows_NT" goto win9xME_args 68 | 69 | :win9xME_args 70 | @rem Slurp the command line arguments. 71 | set CMD_LINE_ARGS= 72 | set _SKIP=2 73 | 74 | :win9xME_args_slurp 75 | if "x%~1" == "x" goto execute 76 | 77 | set CMD_LINE_ARGS=%* 78 | 79 | :execute 80 | @rem Setup the command line 81 | 82 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 83 | 84 | @rem Execute Gradle 85 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 86 | 87 | :end 88 | @rem End local scope for the variables with windows NT shell 89 | if "%ERRORLEVEL%"=="0" goto mainEnd 90 | 91 | :fail 92 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 93 | rem the _cmd.exe /c_ return code! 94 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 95 | exit /b 1 96 | 97 | :mainEnd 98 | if "%OS%"=="Windows_NT" endlocal 99 | 100 | :omega 101 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/handler/RequestHandlerSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler 2 | 3 | import io.vertx.core.Vertx 4 | import io.vertx.core.http.HttpServer 5 | import io.vertx.core.json.JsonObject 6 | import io.vertx.ext.web.Router 7 | import spock.lang.Specification 8 | import spock.lang.Unroll 9 | import top.dteam.dgate.config.MockUrlConfig 10 | import top.dteam.dgate.gateway.SimpleResponse 11 | import top.dteam.dgate.utils.RequestUtils 12 | import top.dteam.dgate.utils.TestUtils 13 | 14 | class RequestHandlerSpec extends Specification { 15 | 16 | Vertx vertx 17 | HttpServer gate 18 | RequestUtils requestUtils 19 | 20 | void setup() { 21 | vertx = Vertx.vertx() 22 | gate = createGate() 23 | requestUtils = new RequestUtils(vertx) 24 | } 25 | 26 | void cleanup() { 27 | gate.close() 28 | vertx.close() 29 | } 30 | 31 | @Unroll 32 | def "required could be a list: #url (#body)"() { 33 | setup: 34 | SimpleResponse result 35 | 36 | when: 37 | sleep(100) 38 | requestUtils.get("localhost", 8081, url, new JsonObject(body)) { simpleResponse -> 39 | result = simpleResponse 40 | } 41 | TestUtils.waitResult(result, 1500) 42 | 43 | then: 44 | result.statusCode == statusCode 45 | 46 | where: 47 | body | statusCode | url 48 | [:] | 400 | "/required-list" 49 | [param1: 'test'] | 400 | "/required-list" 50 | [param2: 'test'] | 400 | "/required-list" 51 | [param1: 'test', param2: 'test'] | 200 | "/required-list" 52 | [:] | 400 | "/required-list" 53 | [:] | 400 | "/required-list?param1=test" 54 | [:] | 400 | "/required-list?param2=test" 55 | [:] | 200 | "/required-list?param1=test&¶m2=test" 56 | [param2: 'test'] | 200 | "/required-list?param1=test" 57 | } 58 | 59 | @Unroll 60 | def "required could be a map: #url (#body)"() { 61 | setup: 62 | SimpleResponse result 63 | 64 | when: 65 | sleep(100) 66 | requestUtils."$method"("localhost", 8081, url, new JsonObject(body)) { simpleResponse -> 67 | result = simpleResponse 68 | } 69 | TestUtils.waitResult(result, 1500) 70 | 71 | then: 72 | result.statusCode == statusCode 73 | 74 | where: 75 | method | body | statusCode | url 76 | 'get' | [:] | 400 | "/required-map" 77 | 'get' | [:] | 200 | "/required-map?param1=test" 78 | 'post' | [:] | 400 | "/required-map" 79 | 'post' | [param2: 'test'] | 200 | "/required-map" 80 | 'delete' | [:] | 400 | "/required-map" 81 | 'delete' | [param3: 'test'] | 200 | "/required-map" 82 | 83 | } 84 | 85 | private HttpServer createGate() { 86 | HttpServer httpServer = vertx.createHttpServer() 87 | Router router = Router.router(vertx) 88 | httpServer.requestHandler(router.&accept).listen(8081) 89 | 90 | router.route("/required-list").handler(new MockHandler(vertx, 91 | new MockUrlConfig(required: ['param1', 'param2'], expected: [statusCode: 200, payload: [method: 'all']]))) 92 | router.route("/required-map").handler(new MockHandler(vertx, 93 | new MockUrlConfig(required: [get: ['param1'], post: ['param2'], delete: ['param3']], 94 | expected: [statusCode: 200, payload: [method: 'all']]))) 95 | 96 | httpServer 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/config/UpstreamURLSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import io.vertx.core.json.JsonObject 4 | import spock.lang.Specification 5 | import spock.lang.Unroll 6 | 7 | class UpstreamURLSpec extends Specification { 8 | 9 | @Unroll 10 | def "should return itself for a url without path params: #url"() { 11 | setup: 12 | UpstreamURL upstreamURL = new UpstreamURL(host: 'localhost', port: 8080, url: url) 13 | 14 | when: 15 | String result = upstreamURL.resolve(context) 16 | 17 | then: 18 | result == finalUrl 19 | 20 | where: 21 | url | context | finalUrl 22 | '/x' | new JsonObject([x: 'test']) | '/x' 23 | '/x/y' | new JsonObject([x: 'x1', y: 'y1']) | '/x/y' 24 | } 25 | 26 | @Unroll 27 | def "url could include required path params: #url"() { 28 | setup: 29 | UpstreamURL upstreamURL = new UpstreamURL(host: 'localhost', port: 8080, url: url) 30 | 31 | when: 32 | String result = upstreamURL.resolve(context) 33 | 34 | then: 35 | result == finalUrl 36 | 37 | where: 38 | url | context | finalUrl 39 | '/:x' | new JsonObject([x: 'x1']) | '/x1' 40 | '/y/:x' | new JsonObject([x: 'x1']) | '/y/x1' 41 | '/:x/:y' | new JsonObject([x: 'x1', y: 'y1']) | '/x1/y1' 42 | '/:x/test/:y' | new JsonObject([x: 'x1', y: 'y1']) | '/x1/test/y1' 43 | } 44 | 45 | @Unroll 46 | def "should throw an exception if required path params not exist"() { 47 | setup: 48 | UpstreamURL upstreamURL = new UpstreamURL(host: 'localhost', port: 8080, url: url) 49 | 50 | when: 51 | upstreamURL.resolve(context) 52 | 53 | then: 54 | thrown(InvalidConfiguriationException) 55 | 56 | where: 57 | url | context 58 | '/:x' | new JsonObject([:]) 59 | '/:x/:y' | new JsonObject([y: 'y1']) 60 | '/:x/:y' | new JsonObject([x: 'x1']) 61 | '/:x/:y' | new JsonObject([:]) 62 | } 63 | 64 | @Unroll 65 | def "url could include optional path params: #url (#context)"() { 66 | setup: 67 | UpstreamURL upstreamURL = new UpstreamURL(host: 'localhost', port: 8080, url: url) 68 | 69 | when: 70 | String result = upstreamURL.resolve(context) 71 | 72 | then: 73 | result == finalUrl 74 | 75 | where: 76 | url | context | finalUrl 77 | '/:x?' | new JsonObject([x: 'x1']) | '/x1' 78 | '/:x?' | new JsonObject([:]) | '/' 79 | '/y/:x?' | new JsonObject([x: 'x1']) | '/y/x1' 80 | '/y/:x?' | new JsonObject([:]) | '/y' 81 | '/:x?/:y?' | new JsonObject([x: 'x1', y: 'y1']) | '/x1/y1' 82 | '/:x?/:y?' | new JsonObject([x: 'x1']) | '/x1' 83 | '/:x?/:y' | new JsonObject([x: 'x1', y: 'y1']) | '/x1/y1' 84 | '/:x?/test/:y?' | new JsonObject([x: 'x1']) | '/x1/test' 85 | '/:x?/:y?' | new JsonObject([:]) | '/' 86 | '/:x?/:y?/:z?' | new JsonObject([:]) | '/' 87 | } 88 | 89 | @Unroll 90 | def "should throw an exception if missed optional path params are not the last ones in a url: #url (#context)"() { 91 | setup: 92 | UpstreamURL upstreamURL = new UpstreamURL(host: 'localhost', port: 8080, url: url) 93 | 94 | when: 95 | upstreamURL.resolve(context) 96 | 97 | then: 98 | thrown(InvalidConfiguriationException) 99 | 100 | where: 101 | url | context 102 | '/:x?/:y?' | new JsonObject([y: 'y1']) 103 | '/:x?/test/:y?' | new JsonObject([y: 'y1']) 104 | '/:x?/:y' | new JsonObject([y: 'y1']) 105 | '/:x?/:y' | new JsonObject([:]) 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/gateway/ApiGateway.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.gateway; 2 | 3 | import groovy.lang.Closure; 4 | import io.vertx.core.AbstractVerticle; 5 | import io.vertx.core.eventbus.EventBus; 6 | import io.vertx.core.eventbus.Message; 7 | import io.vertx.core.http.HttpServer; 8 | import io.vertx.core.json.JsonObject; 9 | import io.vertx.ext.bridge.PermittedOptions; 10 | import io.vertx.ext.web.Router; 11 | import io.vertx.ext.web.handler.sockjs.BridgeOptions; 12 | import io.vertx.ext.web.handler.sockjs.SockJSHandler; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | import top.dteam.dgate.config.ApiGatewayConfig; 16 | import top.dteam.dgate.config.Consumer; 17 | import top.dteam.dgate.config.EventBusBridgeConfig; 18 | import top.dteam.dgate.config.Publisher; 19 | 20 | import java.util.List; 21 | import java.util.Map; 22 | 23 | public class ApiGateway extends AbstractVerticle { 24 | 25 | private static final Logger logger = LoggerFactory.getLogger(ApiGateway.class); 26 | 27 | private ApiGatewayConfig config; 28 | 29 | public ApiGateway(ApiGatewayConfig config) { 30 | this.config = config; 31 | } 32 | 33 | @Override 34 | public void start() { 35 | HttpServer httpServer = vertx.createHttpServer(); 36 | Router router = RouterBuilder.build(vertx, config); 37 | 38 | EventBusBridgeConfig eventBusBridgeConfig = config.getEventBusBridgeConfig(); 39 | if (eventBusBridgeConfig != null) { 40 | buildEventBusBridge(eventBusBridgeConfig.getUrlPattern(), router); 41 | } 42 | 43 | httpServer.requestHandler(router::accept).listen(config.getPort(), config.getHost(), result -> { 44 | if (result.succeeded()) { 45 | if (eventBusBridgeConfig != null) { 46 | EventBus eventBus = vertx.eventBus(); 47 | registerConsumers(eventBus, eventBusBridgeConfig.getConsumers()); 48 | registerPublishers(eventBus, eventBusBridgeConfig.getPublishers()); 49 | } 50 | 51 | logger.info("API Gateway {} is listening at {}:{} ...", 52 | config.getName(), config.getHost(), config.getPort()); 53 | } 54 | }); 55 | } 56 | 57 | private void buildEventBusBridge(String urlPattern, Router router) { 58 | SockJSHandler sockJSHandler = SockJSHandler.create(vertx); 59 | PermittedOptions allAllowed = new PermittedOptions().setAddressRegex(".*"); 60 | router.mountSubRouter(urlPattern, sockJSHandler.bridge(new BridgeOptions() 61 | .addInboundPermitted(allAllowed) 62 | .addOutboundPermitted(allAllowed))); 63 | } 64 | 65 | private void registerConsumers(EventBus eventBus, List consumers) { 66 | consumers.forEach(consumer -> eventBus.consumer(consumer.getAddress(), message -> { 67 | if (consumer.getTarget() == null) { 68 | message.reply(transformIfNeeded(consumer.getExpected(), message)); 69 | } else { 70 | eventBus.publish(consumer.getTarget(), transformIfNeeded(consumer.getExpected(), message)); 71 | } 72 | })); 73 | } 74 | 75 | private void registerPublishers(EventBus eventBus, List publishers) { 76 | publishers.forEach(publisher -> 77 | vertx.setPeriodic(publisher.getTimer(), tid -> eventBus.publish(publisher.getTarget() 78 | , transformIfNeeded(publisher.getExpected(), null))) 79 | ); 80 | } 81 | 82 | @SuppressWarnings("unchecked") 83 | private JsonObject transformIfNeeded(Object expected, Message message) { 84 | JsonObject result; 85 | if (expected instanceof Closure) { 86 | result = (message == null) ? new JsonObject(((Closure>) expected).call()) 87 | : new JsonObject(((Closure>) expected).call(message)); 88 | } else { 89 | result = new JsonObject((Map) expected); 90 | } 91 | 92 | logger.debug("{}", result); 93 | 94 | return result; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/handler/RequestHeadersSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler 2 | 3 | import io.vertx.core.MultiMap 4 | import io.vertx.core.Vertx 5 | import io.vertx.core.http.HttpClient 6 | import io.vertx.core.http.HttpClientRequest 7 | import io.vertx.core.http.HttpClientResponse 8 | import io.vertx.core.http.HttpServer 9 | import spock.lang.Specification 10 | import top.dteam.dgate.config.ApiGatewayRepository 11 | import top.dteam.dgate.gateway.ApiGateway 12 | import top.dteam.dgate.utils.TestUtils 13 | 14 | class RequestHeadersSpec extends Specification { 15 | private static final Vertx vertx = Vertx.vertx() 16 | private static MultiMap headers 17 | private static final HttpServer backend = vertx 18 | .createHttpServer().requestHandler({ request -> 19 | headers = request.headers() 20 | request.response().end('{"result": "OK"}') 21 | }).listen(7777) 22 | 23 | private static final CONFIG = ''' 24 | testgateway { 25 | port = 7776 26 | urls { 27 | "/proxy" { 28 | upstreamURLs = [ 29 | [host: 'localhost', port: 7777, url: '/proxy'] 30 | ] 31 | } 32 | "/relay" { 33 | relayTo { 34 | host = 'localhost' 35 | port = 7777 36 | } 37 | } 38 | } 39 | } 40 | ''' 41 | 42 | void setupSpec() { 43 | ApiGatewayRepository.respository.clear() 44 | ApiGatewayRepository.build(CONFIG) 45 | ApiGatewayRepository.respository.each { 46 | vertx.deployVerticle(new ApiGateway(it)) 47 | } 48 | } 49 | 50 | void cleanSpec() { 51 | backend.close() 52 | vertx.close() 53 | } 54 | 55 | def "Should get correct proxy headers if client isn't from proxy"(String requestURI) { 56 | setup: 57 | HttpClientResponse clientResponse 58 | HttpClient client = vertx.createHttpClient() 59 | HttpClientRequest request = client.get(7776, "localhost", requestURI) { response -> 60 | clientResponse = response 61 | } 62 | request.putHeader("User-Agent", "Dgate Test") 63 | request.end() 64 | 65 | TestUtils.waitResult(headers, 5000) 66 | TestUtils.waitResult(clientResponse, 5000) 67 | 68 | expect: 69 | headers.get("User-Agent") == "Dgate Test" 70 | headers.get("X-Real-IP") == "127.0.0.1" 71 | headers.get("X-Forwarded-For") == "127.0.0.1" 72 | headers.get("X-Forwarded-Host") == "localhost:7776" 73 | headers.get("X-Forwarded-Proto") == "http" 74 | 75 | cleanup: 76 | headers = null 77 | 78 | where: 79 | requestURI | _ 80 | "/proxy" | _ 81 | "/relay" | _ 82 | } 83 | 84 | def "Should get correct proxy headers if client is from proxy"(String requestURI) { 85 | setup: 86 | HttpClientResponse clientResponse 87 | HttpClient client = vertx.createHttpClient() 88 | HttpClientRequest request = client.get(7776, "localhost", requestURI) { response -> 89 | clientResponse = response 90 | } 91 | request.putHeader("User-Agent", "Dgate Test Via Proxy") 92 | .putHeader("X-Real-IP", "8.8.8.8") 93 | .putHeader("X-Forwarded-For", "8.8.8.8,8.8.4.4") 94 | .end() 95 | 96 | TestUtils.waitResult(headers, 5000) 97 | TestUtils.waitResult(clientResponse, 5000) 98 | 99 | expect: 100 | headers.get("User-Agent") == "Dgate Test Via Proxy" 101 | headers.get("X-Real-IP") == "8.8.8.8" 102 | headers.get("X-Forwarded-For") == "8.8.8.8,8.8.4.4,127.0.0.1" 103 | headers.get("X-Forwarded-Host") == "localhost:7776" 104 | headers.get("X-Forwarded-Proto") == "http" 105 | 106 | cleanup: 107 | headers = null 108 | 109 | where: 110 | requestURI | _ 111 | "/proxy" | _ 112 | "/relay" | _ 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/utils/cache/ResponseHolder.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.utils.cache; 2 | 3 | import io.vertx.core.json.JsonObject; 4 | import org.apache.ignite.IgniteCache; 5 | import org.apache.ignite.cache.CacheMode; 6 | import org.apache.ignite.cache.eviction.lru.LruEvictionPolicy; 7 | import org.apache.ignite.configuration.CacheConfiguration; 8 | 9 | import javax.cache.expiry.Duration; 10 | import javax.cache.expiry.ModifiedExpiryPolicy; 11 | import java.util.concurrent.TimeUnit; 12 | 13 | public class ResponseHolder { 14 | private static final int MAX_ENTRY_PER_CACHE = 1000; 15 | 16 | private static IgniteCache getOrCreate( 17 | String apiGatewayName, String route, int expires) { 18 | String cacheName = cacheName(apiGatewayName, route); 19 | CacheConfiguration cacheCfg = 20 | new CacheConfiguration<>(cacheName); 21 | cacheCfg.setCacheMode(CacheMode.LOCAL); 22 | cacheCfg.setExpiryPolicyFactory(ModifiedExpiryPolicy.factoryOf( 23 | new Duration(TimeUnit.MILLISECONDS, expires))); 24 | cacheCfg.setEagerTtl(false); 25 | cacheCfg.setEvictionPolicy(new LruEvictionPolicy(MAX_ENTRY_PER_CACHE)); 26 | cacheCfg.setOnheapCacheEnabled(true); 27 | 28 | return CacheLocator.getOrCreateCache(cacheCfg); 29 | } 30 | 31 | public static void put(String apiGatewayName, String route 32 | , String URL, JsonObject token, JsonObject content, int expires) { 33 | IgniteCache cache = 34 | getOrCreate(apiGatewayName, route, expires); 35 | String tokenString = token != null ? token.toString() : null; 36 | String cacheKey = cacheKey(URL, tokenString); 37 | 38 | cache.put(cacheKey, content); 39 | } 40 | 41 | public static void put(String apiGatewayName, String route 42 | , String upstreamHost, int upstreamPort, String upstreamURL 43 | , String URL, JsonObject token, JsonObject content, int expires) { 44 | String upstreamRoute = upstreamRoute(route, upstreamHost, upstreamPort, upstreamURL); 45 | 46 | put(apiGatewayName, upstreamRoute, URL, token, content, expires); 47 | } 48 | 49 | public static JsonObject get(String apiGatewayName, String route 50 | , String URL, JsonObject token) { 51 | String cacheName = cacheName(apiGatewayName, route); 52 | if (CacheLocator.containsCache(cacheName)) { 53 | IgniteCache cache = 54 | CacheLocator.getCacheByName(cacheName); 55 | String tokenString = token != null ? token.toString() : null; 56 | 57 | return cache.get(cacheKey(URL, tokenString)); 58 | } else { 59 | return null; 60 | } 61 | } 62 | 63 | public static JsonObject get(String apiGatewayName, String route 64 | , String upstreamHost, int upstreamPort, String upstreamURL 65 | , String URL, JsonObject token) { 66 | String upstreamRoute = upstreamRoute(route, upstreamHost, upstreamPort, upstreamURL); 67 | 68 | return get(apiGatewayName, upstreamRoute, URL, token); 69 | } 70 | 71 | public static boolean containsCacheName(String apiGatewayName, String route) { 72 | return CacheLocator.containsCache(cacheName(apiGatewayName, route)); 73 | } 74 | 75 | public static boolean containsCacheEntry(String apiGatewayName, String route 76 | , String URL, JsonObject token) { 77 | if (containsCacheName(apiGatewayName, route)) { 78 | IgniteCache cache = 79 | CacheLocator.getCacheByName(cacheName(apiGatewayName, route)); 80 | String tokenString = token != null ? token.toString() : null; 81 | String cacheKey = cacheKey(URL, tokenString); 82 | 83 | return (cache.containsKey(cacheKey) && cache.get(cacheKey) != null); 84 | } else { 85 | return false; 86 | } 87 | } 88 | 89 | public static boolean containsCacheEntry(String apiGatewayName, String route 90 | , String upstreamHost, int upstreamPort, String upstreamURL 91 | , String URL, JsonObject token) { 92 | String upstreamRoute = upstreamRoute(route, upstreamHost, upstreamPort, upstreamURL); 93 | 94 | return containsCacheEntry(apiGatewayName, upstreamRoute, URL, token); 95 | } 96 | 97 | private static String cacheName(String apiGatewayName, String route) { 98 | return apiGatewayName + route; 99 | } 100 | 101 | private static String cacheKey(String requestURI, String token) { 102 | return token == null ? requestURI : requestURI + "-" + token; 103 | } 104 | 105 | private static String upstreamRoute(String route, String upstreamHost 106 | , int upstreamPort, String upstreamURL) { 107 | return route + "-" + upstreamHost + ":" + upstreamPort + upstreamURL; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/utils/Utils.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.utils; 2 | 3 | import io.vertx.core.Handler; 4 | import io.vertx.core.Vertx; 5 | import io.vertx.core.buffer.Buffer; 6 | import io.vertx.core.file.FileSystemException; 7 | import io.vertx.core.http.HttpHeaders; 8 | import io.vertx.core.http.HttpServerRequest; 9 | import io.vertx.core.http.HttpServerResponse; 10 | import io.vertx.core.json.JsonObject; 11 | import io.vertx.ext.auth.jwt.JWTAuth; 12 | import io.vertx.ext.auth.jwt.JWTAuthOptions; 13 | import io.vertx.ext.jwt.JWT; 14 | import io.vertx.ext.web.Router; 15 | import io.vertx.ext.web.RoutingContext; 16 | import org.slf4j.Logger; 17 | import org.slf4j.LoggerFactory; 18 | 19 | import java.io.ByteArrayInputStream; 20 | import java.io.IOException; 21 | import java.io.InputStream; 22 | import java.security.KeyStore; 23 | import java.security.KeyStoreException; 24 | import java.security.NoSuchAlgorithmException; 25 | import java.security.cert.CertificateException; 26 | import java.util.List; 27 | import java.util.Map; 28 | import java.util.regex.Pattern; 29 | 30 | public class Utils { 31 | 32 | private static final Logger logger = LoggerFactory.getLogger(Utils.class); 33 | 34 | private static final Pattern BEARER = Pattern.compile("^Bearer$", Pattern.CASE_INSENSITIVE); 35 | 36 | public static void fireSingleMessageResponse(HttpServerResponse response, int statusCode) { 37 | response.setStatusCode(statusCode).end(); 38 | } 39 | 40 | public static void fireSingleMessageResponse(HttpServerResponse response, int statusCode, String message) { 41 | response.setStatusCode(statusCode).end(message); 42 | } 43 | 44 | public static void fireJsonResponse(HttpServerResponse response, int statusCode, Map payload) { 45 | response.setStatusCode(statusCode); 46 | JsonObject jsonObject = new JsonObject(payload); 47 | response.putHeader("content-type", "application/json; charset=utf-8").end(jsonObject.toString()); 48 | } 49 | 50 | public static JWTAuth createAuthProvider(Vertx vertx) { 51 | return JWTAuth.create(vertx, new JWTAuthOptions(jwtOptions())); 52 | } 53 | 54 | // extracted from constructor of JWTAuthProviderImpl 55 | public static JWT createJWT(Vertx vertx) { 56 | JsonObject jwtOptions = jwtOptions(); 57 | JsonObject keyStore = jwtOptions.getJsonObject("keyStore"); 58 | 59 | try { 60 | KeyStore ks = KeyStore.getInstance(keyStore.getString("type", "jceks")); 61 | synchronized (Utils.class) { 62 | final Buffer keystore = vertx.fileSystem().readFileBlocking(keyStore.getString("path")); 63 | try (InputStream in = new ByteArrayInputStream(keystore.getBytes())) { 64 | ks.load(in, keyStore.getString("password").toCharArray()); 65 | } 66 | } 67 | 68 | return new JWT(ks, keyStore.getString("password").toCharArray()); 69 | } catch (KeyStoreException | IOException | FileSystemException | CertificateException | NoSuchAlgorithmException e) { 70 | throw new RuntimeException(e); 71 | } 72 | } 73 | 74 | private static JsonObject jwtOptions() { 75 | if (System.getenv("dgate_key_store") != null) { 76 | return new JsonObject().put("keyStore", new JsonObject() 77 | .put("path", System.getenv("dgate_key_store")) 78 | .put("type", System.getenv("dgate_key_type")) 79 | .put("password", System.getenv("dgate_key_password"))); 80 | } else { 81 | // dgate.jceks is in test/resources and for test only !!! 82 | return new JsonObject().put("keyStore", new JsonObject() 83 | .put("path", "dgate.jceks") 84 | .put("type", "jceks") 85 | .put("password", "dcloud")); 86 | } 87 | } 88 | 89 | // extracted from JWTAuthHandlerImpl 90 | public static String getTokenFromHeader(HttpServerRequest request) { 91 | final String authorization = request.headers().get(HttpHeaders.AUTHORIZATION); 92 | 93 | if (authorization != null) { 94 | String[] parts = authorization.split(" "); 95 | if (parts.length == 2) { 96 | final String scheme = parts[0], 97 | credentials = parts[1]; 98 | 99 | if (BEARER.matcher(scheme).matches()) { 100 | return credentials; 101 | } 102 | } else { 103 | logger.warn("Format is Authorization: Bearer [token]"); 104 | return null; 105 | } 106 | } else { 107 | logger.warn("No Authorization header was found"); 108 | return null; 109 | } 110 | 111 | return null; 112 | } 113 | 114 | public static void addHandlerExcept(List all, List ignore, Router router, Handler handler) { 115 | all.removeAll(ignore); 116 | all.forEach(url -> router.route(url).handler(handler)); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/handler/RequestHandler.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler; 2 | 3 | import io.vertx.core.MultiMap; 4 | import io.vertx.core.Vertx; 5 | import io.vertx.core.buffer.Buffer; 6 | import io.vertx.core.http.HttpMethod; 7 | import io.vertx.core.http.HttpServerRequest; 8 | import io.vertx.core.http.HttpServerResponse; 9 | import io.vertx.core.json.JsonObject; 10 | import io.vertx.ext.web.RoutingContext; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import top.dteam.dgate.config.InvalidConfiguriationException; 14 | import top.dteam.dgate.config.UrlConfig; 15 | import top.dteam.dgate.utils.Utils; 16 | 17 | import java.util.HashMap; 18 | import java.util.List; 19 | import java.util.Map; 20 | 21 | public abstract class RequestHandler implements GatewayRequestHandler { 22 | 23 | private static final Logger logger = LoggerFactory.getLogger(RequestHandler.class); 24 | 25 | protected Vertx vertx; 26 | protected UrlConfig urlConfig; 27 | protected String nameOfApiGateway; 28 | 29 | protected RequestHandler(Vertx vertx, UrlConfig urlConfig) { 30 | this.vertx = vertx; 31 | this.urlConfig = urlConfig; 32 | } 33 | 34 | @Override 35 | public void handle(RoutingContext routingContext) { 36 | verifyMethodsAllowed(routingContext); 37 | 38 | if (routingContext.request().isEnded()) { 39 | processRequest(routingContext, routingContext.getBody()); 40 | } else { 41 | routingContext.request().bodyHandler(totalBuffer -> processRequest(routingContext, totalBuffer)); 42 | } 43 | } 44 | 45 | private Object requiredParams() { 46 | return urlConfig.getRequired(); 47 | } 48 | 49 | private List allowedMethods() { 50 | return urlConfig.getMethods(); 51 | } 52 | 53 | @Override 54 | public GatewayRequestHandler nameOfApiGateway(String nameOfApiGateway) { 55 | this.nameOfApiGateway = nameOfApiGateway; 56 | return this; 57 | } 58 | 59 | protected abstract void processRequestBody(HttpServerRequest request, HttpServerResponse response, JsonObject body); 60 | 61 | private void verifyMethodsAllowed(RoutingContext routingContext) { 62 | if (allowedMethods() != null && !allowedMethods().isEmpty()) { 63 | if (allowedMethods().stream().noneMatch(method -> routingContext.request().method() == method)) { 64 | HashMap error = new HashMap<>(); 65 | error.put("error", "Unsupported HTTP Method."); 66 | Utils.fireJsonResponse(routingContext.response(), 400, error); 67 | throw new UnsupportedOperationException("Unsupported HTTP Method."); 68 | } 69 | } 70 | } 71 | 72 | @SuppressWarnings("unchecked") 73 | private void verifyRequiredExists(RoutingContext routingContext, JsonObject body) { 74 | if (requiredParams() == null) { 75 | return; 76 | } 77 | 78 | List params; 79 | if (requiredParams() instanceof List) { 80 | params = (List) requiredParams(); 81 | } else if (requiredParams() instanceof Map) { 82 | params = ((Map>) requiredParams()).get(routingContext.request().method().toString().toLowerCase()); 83 | } else { 84 | throw new InvalidConfiguriationException("required must be List or Map:" + requiredParams().getClass().getName()); 85 | } 86 | 87 | if (params != null && !params.isEmpty()) { 88 | if (params.stream().anyMatch(param -> !body.containsKey(param))) { 89 | HashMap error = new HashMap<>(); 90 | error.put("error", "required params not in request."); 91 | Utils.fireJsonResponse(routingContext.response(), 400, error); 92 | throw new IllegalArgumentException("required params not in request"); 93 | } 94 | } 95 | } 96 | 97 | private JsonObject getBodyFromBuffer(Buffer buffer) { 98 | if (buffer.toString().trim().length() == 0) { 99 | return new JsonObject(); 100 | } else { 101 | return buffer.toJsonObject(); 102 | } 103 | } 104 | 105 | private void putJwtTokenInBody(JsonObject body, RoutingContext routingContext) { 106 | if (routingContext.user() != null) { 107 | JsonObject token = routingContext.user().principal(); 108 | body.put("token", token); 109 | } else if (routingContext.get("token") != null) { 110 | body.put("token", (JsonObject) routingContext.get("token")); 111 | } 112 | } 113 | 114 | private void putNameOfApiGatewayInBody(JsonObject body) { 115 | if (nameOfApiGateway != null) { 116 | body.put("nameOfApiGateway", nameOfApiGateway); 117 | } 118 | } 119 | 120 | private void mergeRequestParams(JsonObject body, MultiMap params) { 121 | if (params == null) { 122 | return; 123 | } 124 | 125 | params.forEach(entry -> body.put(entry.getKey(), entry.getValue())); 126 | 127 | logger.debug("Merged paramaters {}: ", body); 128 | } 129 | 130 | private void processRequest(RoutingContext routingContext, Buffer buffer) { 131 | JsonObject body = getBodyFromBuffer(buffer); 132 | mergeRequestParams(body, routingContext.request().params()); 133 | verifyRequiredExists(routingContext, body); 134 | putJwtTokenInBody(body, routingContext); 135 | putNameOfApiGatewayInBody(body); 136 | 137 | processRequestBody(routingContext.request(), routingContext.response(), body); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/handler/RelayHandlerSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler 2 | 3 | import io.vertx.core.Vertx 4 | import io.vertx.core.http.HttpMethod 5 | import io.vertx.core.http.HttpServer 6 | import io.vertx.core.json.JsonObject 7 | import io.vertx.ext.auth.jwt.JWTAuth 8 | import io.vertx.ext.web.FileUpload 9 | import io.vertx.ext.web.Router 10 | import io.vertx.ext.web.handler.BodyHandler 11 | import spock.lang.Specification 12 | import top.dteam.dgate.config.ApiGatewayConfig 13 | import top.dteam.dgate.config.LoginConfig 14 | import top.dteam.dgate.config.MockUrlConfig 15 | import top.dteam.dgate.config.RelayUrlConfig 16 | import top.dteam.dgate.gateway.ApiGateway 17 | import top.dteam.dgate.gateway.SimpleResponse 18 | import top.dteam.dgate.utils.JWTTokenGenerator 19 | import top.dteam.dgate.utils.RequestUtils 20 | import top.dteam.dgate.utils.TestUtils 21 | import top.dteam.dgate.utils.Utils 22 | 23 | class RelayHandlerSpec extends Specification { 24 | 25 | Vertx vertx 26 | RequestUtils requestUtils 27 | HttpServer destServer 28 | 29 | void setup() { 30 | vertx = Vertx.vertx() 31 | destServer = createDestServer() 32 | requestUtils = new RequestUtils(vertx) 33 | vertx.deployVerticle(new ApiGateway(prepareConfig())) 34 | } 35 | 36 | void cleanup() { 37 | vertx.close() 38 | destServer.close() 39 | } 40 | 41 | def "should support upload"() { 42 | setup: 43 | SimpleResponse result 44 | String filename = "src/test/resources/fileForUpload1" 45 | def size = new File(filename).size() 46 | 47 | when: 48 | sleep(100) 49 | requestUtils.upload(filename, 'localhost', 8080, '/uploadOne') { simpleResponse -> 50 | result = simpleResponse 51 | } 52 | TestUtils.waitResult(result, 1500) 53 | 54 | then: 55 | result.statusCode == 200 56 | result.payload.map == [filename: filename, size: size] 57 | } 58 | 59 | def "should support form"() { 60 | setup: 61 | SimpleResponse result 62 | JsonObject form = new JsonObject([name1: 1, name2: 2, name3: 3]) 63 | 64 | when: 65 | sleep(100) 66 | requestUtils.form(form, 'localhost', 8080, '/form') { simpleResponse -> 67 | result = simpleResponse 68 | } 69 | TestUtils.waitResult(result, 1500) 70 | 71 | then: 72 | result.statusCode == 200 73 | result.payload.map.names.contains('name1') 74 | result.payload.map.names.contains('name2') 75 | result.payload.map.names.contains('name3') 76 | } 77 | 78 | def "should pass jwt token and name of api gateway to backend"() { 79 | setup: 80 | SimpleResponse result 81 | 82 | when: 83 | sleep(100) 84 | requestUtils.get("localhost", 8080, '/login', new JsonObject()) { loginResponse -> 85 | requestUtils.requestWithJwtToken(HttpMethod.GET, "localhost", 8080, "/private", new JsonObject() 86 | , loginResponse.payload.getString('token')) { simpleResponse -> 87 | result = simpleResponse 88 | } 89 | } 90 | 91 | TestUtils.waitResult(result, 1500) 92 | 93 | then: 94 | result.statusCode == 200 95 | result.payload.map.token.sub == '13572209183' 96 | result.payload.map.token.name == 'foxgem' 97 | result.payload.map.token.role == 'normal' 98 | result.payload.map.name == 'testGateway' 99 | } 100 | 101 | private HttpServer createDestServer() { 102 | HttpServer httpServer = vertx.createHttpServer() 103 | Router router = Router.router(vertx) 104 | httpServer.requestHandler(router.&accept).listen(8081) 105 | 106 | router.route().handler(BodyHandler.create().setDeleteUploadedFilesOnEnd(true)) 107 | 108 | router.route("/uploadOne").handler { routingContext -> 109 | FileUpload[] uploads = routingContext.fileUploads().toArray() 110 | Utils.fireJsonResponse(routingContext.response(), 200, [filename: uploads[0].fileName(), size: uploads[0].size()]) 111 | } 112 | 113 | router.route("/form").handler { routingContext -> 114 | Set names = routingContext.request().formAttributes().names() 115 | Utils.fireJsonResponse(routingContext.response(), 200, [names: names]) 116 | } 117 | 118 | router.route("/private").handler { routingContext -> 119 | Utils.fireJsonResponse(routingContext.response(), 200, 120 | [token: new JsonObject(requestUtils.getJwtHeader(routingContext.request())), 121 | name : requestUtils.getAPIGatewayNameHeader(routingContext.request())]) 122 | } 123 | 124 | httpServer 125 | } 126 | 127 | private ApiGatewayConfig prepareConfig() { 128 | new ApiGatewayConfig( 129 | name: 'testGateway', 130 | port: 8080, 131 | login: new LoginConfig([url: '/login', only: ['/private']]), 132 | urlConfigs: [ 133 | new MockUrlConfig(url: "/login", 134 | expected: [statusCode: 200, 135 | payload : { 136 | JWTAuth jwtAuth = Utils.createAuthProvider(vertx) 137 | JWTTokenGenerator tokenGenerator = new JWTTokenGenerator(jwtAuth) 138 | [token: tokenGenerator.token(["sub" : "13572209183", "name": "foxgem", 139 | "role": "normal"], 200)] 140 | }()]), 141 | new RelayUrlConfig(url: "/private", relayTo: [host: 'localhost', port: 8081]), 142 | new RelayUrlConfig(url: "/uploadOne", relayTo: [host: 'localhost', port: 8081]), 143 | new RelayUrlConfig(url: "/form", relayTo: [host: 'localhost', port: 8081]) 144 | ] 145 | ) 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/handler/CircuitBreakerSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler 2 | 3 | import io.vertx.core.Vertx 4 | import io.vertx.core.http.HttpServer 5 | import io.vertx.core.json.JsonObject 6 | import io.vertx.ext.web.Router 7 | import spock.lang.Specification 8 | import spock.lang.Unroll 9 | import spock.util.concurrent.PollingConditions 10 | import top.dteam.dgate.config.ApiGatewayRepository 11 | import top.dteam.dgate.gateway.ApiGateway 12 | import top.dteam.dgate.gateway.SimpleResponse 13 | import top.dteam.dgate.utils.RequestUtils 14 | import top.dteam.dgate.utils.TestUtils 15 | import top.dteam.dgate.utils.Utils 16 | 17 | class CircuitBreakerSpec extends Specification { 18 | 19 | private static final long DEFAULT_OP_TIMEOUT = 5000 20 | 21 | String config = """ 22 | apiGateway1 { 23 | port = 7000 24 | urls { 25 | "/test" { 26 | upstreamURLs = [ 27 | [ host: 'localhost', port: 8080, url: '/test-5s'] 28 | ] 29 | } 30 | "/relay" { 31 | relayTo { 32 | host = 'localhost' 33 | port = 8080 34 | } 35 | } 36 | } 37 | } 38 | apiGateway2 { 39 | port = 7001 40 | circuitBreaker { 41 | maxFailures = 2 42 | timeout = 2000 43 | resetTimeout = 5000 44 | } 45 | urls { 46 | "/test" { 47 | upstreamURLs = [ 48 | [host: 'localhost', port: 8080, url: '/test-2s'] 49 | ] 50 | } 51 | "/relay" { 52 | relayTo { 53 | host = 'localhost' 54 | port = 8080 55 | } 56 | } 57 | } 58 | } 59 | apiGateway3 { 60 | port = 7002 61 | circuitBreaker { 62 | maxFailures = 2 63 | timeout = 5000 64 | resetTimeout = 2000 65 | } 66 | urls { 67 | "/test" { 68 | upstreamURLs = [ 69 | [host: 'localhost', port: 8080, url: '/test-3s', 70 | circuitBreaker: [maxFailures: 2, timeout: 3000, resetTimeout: 3000]] 71 | ] 72 | } 73 | "/relay" { 74 | relayTo { 75 | host = 'localhost' 76 | port = 8080 77 | circuitBreaker = [maxFailures: 2, timeout: 3000, resetTimeout: 3000] 78 | } 79 | } 80 | } 81 | } 82 | """ 83 | 84 | Vertx vertx 85 | HttpServer dest 86 | RequestUtils requestUtils 87 | 88 | void setup() { 89 | vertx = Vertx.vertx() 90 | deployGate() 91 | dest = createDest() 92 | requestUtils = new RequestUtils(vertx) 93 | } 94 | 95 | void cleanup() { 96 | dest.close() 97 | vertx.close() 98 | } 99 | 100 | @Unroll 101 | def "CircuitBreak Default Options should work"() { 102 | setup: 103 | SimpleResponse result 104 | PollingConditions conditions = new PollingConditions(timeout: 10) 105 | 106 | when: 107 | sleep(100) 108 | requestUtils.post("localhost", 7000, url, new JsonObject()) { simpleResponse -> 109 | result = simpleResponse 110 | } 111 | 112 | then: 113 | conditions.eventually { 114 | assert result.statusCode == 500 115 | assert result.payload.map.error == "operation timeout" 116 | } 117 | 118 | where: 119 | url << ['/test', '/relay'] 120 | } 121 | 122 | @Unroll 123 | def "Global CircuitBreak Options should override the default Circuit Break Options"() { 124 | setup: 125 | SimpleResponse result 126 | PollingConditions conditions = new PollingConditions(timeout: 10) 127 | 128 | when: 129 | sleep(100) 130 | requestUtils.post("localhost", 7001, url, new JsonObject()) { simpleResponse -> 131 | result = simpleResponse 132 | } 133 | 134 | then: 135 | conditions.eventually { 136 | assert result.statusCode == 500 137 | assert result.payload.map.error == "operation timeout" 138 | } 139 | 140 | where: 141 | url << ['/test', '/relay'] 142 | } 143 | 144 | @Unroll 145 | def "Circuit Break Options for specific URL should be used first"() { 146 | setup: 147 | SimpleResponse result 148 | PollingConditions conditions = new PollingConditions(timeout: 10) 149 | 150 | when: 151 | sleep(100) 152 | requestUtils.post("localhost", 7002, url, new JsonObject()) { simpleResponse -> 153 | result = simpleResponse 154 | } 155 | TestUtils.waitResult(result, 3000 + 500) 156 | 157 | then: 158 | conditions.eventually { 159 | assert result.statusCode == 500 160 | assert result.payload.map.error == "operation timeout" 161 | } 162 | 163 | where: 164 | url << ['/test', '/relay'] 165 | } 166 | 167 | private HttpServer createDest() { 168 | HttpServer httpServer = vertx.createHttpServer() 169 | Router router = Router.router(vertx) 170 | httpServer.requestHandler(router.&accept).listen(8080) 171 | 172 | router.route().handler { routingContext -> 173 | sleep(DEFAULT_OP_TIMEOUT + 200) 174 | routingContext.request().bodyHandler { totalBuffer -> 175 | Utils.fireJsonResponse(routingContext.response(), 200, [test: true]) 176 | } 177 | } 178 | 179 | httpServer 180 | } 181 | 182 | private void deployGate() { 183 | ApiGatewayRepository.respository.clear() 184 | ApiGatewayRepository.build(config) 185 | 186 | vertx.deployVerticle(new ApiGateway(ApiGatewayRepository.respository[0])) 187 | vertx.deployVerticle(new ApiGateway(ApiGatewayRepository.respository[1])) 188 | vertx.deployVerticle(new ApiGateway(ApiGatewayRepository.respository[2])) 189 | } 190 | 191 | } 192 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/gateway/RouterBuilder.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.gateway; 2 | 3 | import io.vertx.core.Vertx; 4 | import io.vertx.ext.auth.jwt.JWTAuth; 5 | import io.vertx.ext.web.Router; 6 | import io.vertx.ext.web.handler.BodyHandler; 7 | import io.vertx.ext.web.handler.CorsHandler; 8 | import io.vertx.ext.web.handler.JWTAuthHandler; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | import top.dteam.dgate.config.*; 12 | import top.dteam.dgate.handler.GatewayRequestHandler; 13 | import top.dteam.dgate.handler.JWTTokenRefreshHandler; 14 | import top.dteam.dgate.handler.JWTTokenSniffer; 15 | import top.dteam.dgate.utils.JWTTokenRefresher; 16 | import top.dteam.dgate.utils.Utils; 17 | 18 | import java.util.HashMap; 19 | import java.util.List; 20 | import java.util.Map; 21 | import java.util.stream.Collectors; 22 | 23 | public class RouterBuilder { 24 | 25 | private static final Logger logger = LoggerFactory.getLogger(RouterBuilder.class); 26 | 27 | public static Router build(Vertx vertx, ApiGatewayConfig apiGatewayConfig) { 28 | Router router = Router.router(vertx); 29 | addCorsHandler(router, apiGatewayConfig); 30 | addBodyHandlerExceptRelayTo(router, apiGatewayConfig); 31 | addJWTTokenSniffer(vertx, router, apiGatewayConfig); 32 | addRequestHandlers(vertx, router, apiGatewayConfig); 33 | addFailureHandler(router); 34 | return router; 35 | } 36 | 37 | private static void addCorsHandler(Router router, ApiGatewayConfig apiGatewayConfig) { 38 | CorsConfig corsConfig = apiGatewayConfig.getCors(); 39 | if (corsConfig != null) { 40 | CorsHandler corsHandler = CorsHandler.create(corsConfig.getAllowedOriginPattern()); 41 | 42 | if (corsConfig.getAllowedMethods() != null) { 43 | corsHandler.allowedMethods(corsConfig.getAllowedMethods()); 44 | } 45 | 46 | if (corsConfig.getAllowCredentials() != null) { 47 | corsHandler.allowCredentials(corsConfig.getAllowCredentials()); 48 | } 49 | 50 | if (corsConfig.getAllowedHeaders() != null) { 51 | corsHandler.allowedHeaders(corsConfig.getAllowedHeaders()); 52 | } 53 | 54 | if (corsConfig.getExposedHeaders() != null) { 55 | corsHandler.exposedHeaders(corsConfig.getExposedHeaders()); 56 | } 57 | 58 | if (corsConfig.getMaxAgeSeconds() != null) { 59 | corsHandler.maxAgeSeconds(corsConfig.getMaxAgeSeconds()); 60 | } 61 | 62 | router.route().handler(corsHandler); 63 | } 64 | } 65 | 66 | private static void addBodyHandlerExceptRelayTo(Router router, ApiGatewayConfig apiGatewayConfig) { 67 | apiGatewayConfig.getUrlConfigs().forEach(urlConfig -> { 68 | if (urlConfig.getClass() != RelayUrlConfig.class) { 69 | router.route(urlConfig.getUrl()).handler(BodyHandler.create()); 70 | } 71 | }); 72 | } 73 | 74 | private static void addJWTTokenSniffer(Vertx vertx, Router router, ApiGatewayConfig apiGatewayConfig) { 75 | if (apiGatewayConfig.getLogin() != null) { 76 | router.route().handler(new JWTTokenSniffer(Utils.createJWT(vertx))); 77 | } 78 | } 79 | 80 | private static void addRequestHandlers(Vertx vertx, Router router, ApiGatewayConfig apiGatewayConfig) { 81 | List urlConfigs = apiGatewayConfig.getUrlConfigs(); 82 | LoginConfig login = apiGatewayConfig.getLogin(); 83 | JWTAuth auth = createAuthIfNeeded(vertx, router, login, urlConfigs); 84 | 85 | urlConfigs.forEach(urlConfig -> { 86 | if (login != null && urlConfig.getUrl().equals(login.login())) { 87 | router.route(urlConfig.getUrl()).handler(GatewayRequestHandler.create(vertx, urlConfig, auth) 88 | .nameOfApiGateway(apiGatewayConfig.getName())); 89 | } else { 90 | router.route(urlConfig.getUrl()).handler(GatewayRequestHandler.create(vertx, urlConfig, null) 91 | .nameOfApiGateway(apiGatewayConfig.getName())); 92 | } 93 | }); 94 | } 95 | 96 | private static JWTAuth createAuthIfNeeded(Vertx vertx, Router router, LoginConfig login, 97 | List urlConfigs) { 98 | if (login != null) { 99 | 100 | // this handler MUST BE the first handler if login is enabled !!! 101 | createTokenFreshHandler(vertx, router, login); 102 | 103 | JWTAuth jwtAuth = Utils.createAuthProvider(vertx); 104 | JWTAuthHandler jwtAuthHandler = JWTAuthHandler.create(jwtAuth, login.login()); 105 | if (login.only().isEmpty() && login.ignore().isEmpty()) { 106 | router.route().handler(jwtAuthHandler); 107 | } else if (login.ignore().isEmpty()) { 108 | login.only().forEach(url -> router.route(url).handler(jwtAuthHandler)); 109 | } else if (login.only().isEmpty()) { 110 | List allUrls = urlConfigs.stream().map(UrlConfig::getUrl).collect(Collectors.toList()); 111 | Utils.addHandlerExcept(allUrls, login.ignore(), router, jwtAuthHandler); 112 | } 113 | 114 | return jwtAuth; 115 | } else { 116 | return null; 117 | } 118 | } 119 | 120 | private static void createTokenFreshHandler(Vertx vertx, Router router, LoginConfig login) { 121 | JWTTokenRefresher jwtTokenRefresher = new JWTTokenRefresher(vertx); 122 | router.route(JWTTokenRefreshHandler.URL).handler( 123 | new JWTTokenRefreshHandler(jwtTokenRefresher, login.refreshLimit(), login.refreshExpire())); 124 | } 125 | 126 | private static void addFailureHandler(Router router) { 127 | router.route().failureHandler(routingContext -> { 128 | if (routingContext.response().ended()) { 129 | return; 130 | } 131 | 132 | int statusCode = routingContext.statusCode() == -1 ? 500 : routingContext.statusCode(); 133 | 134 | logger.error("Got [{}] during processing [{}], status code: {}. ", 135 | routingContext.response().getStatusMessage(), routingContext.request().absoluteURI(), statusCode, routingContext.failure()); 136 | Map payload = new HashMap<>(); 137 | payload.put("error", routingContext.response().getStatusMessage()); 138 | Utils.fireJsonResponse(routingContext.response(), statusCode, payload); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | # Determine the Java command to use to start the JVM. 86 | if [ -n "$JAVA_HOME" ] ; then 87 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 88 | # IBM's JDK on AIX uses strange locations for the executables 89 | JAVACMD="$JAVA_HOME/jre/sh/java" 90 | else 91 | JAVACMD="$JAVA_HOME/bin/java" 92 | fi 93 | if [ ! -x "$JAVACMD" ] ; then 94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 95 | 96 | Please set the JAVA_HOME variable in your environment to match the 97 | location of your Java installation." 98 | fi 99 | else 100 | JAVACMD="java" 101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 102 | 103 | Please set the JAVA_HOME variable in your environment to match the 104 | location of your Java installation." 105 | fi 106 | 107 | # Increase the maximum file descriptors if we can. 108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 109 | MAX_FD_LIMIT=`ulimit -H -n` 110 | if [ $? -eq 0 ] ; then 111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 112 | MAX_FD="$MAX_FD_LIMIT" 113 | fi 114 | ulimit -n $MAX_FD 115 | if [ $? -ne 0 ] ; then 116 | warn "Could not set maximum file descriptor limit: $MAX_FD" 117 | fi 118 | else 119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 120 | fi 121 | fi 122 | 123 | # For Darwin, add options to specify how the application appears in the dock 124 | if $darwin; then 125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 126 | fi 127 | 128 | # For Cygwin or MSYS, switch paths to Windows format before running java 129 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 130 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 131 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 132 | JAVACMD=`cygpath --unix "$JAVACMD"` 133 | 134 | # We build the pattern for arguments to be converted via cygpath 135 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 136 | SEP="" 137 | for dir in $ROOTDIRSRAW ; do 138 | ROOTDIRS="$ROOTDIRS$SEP$dir" 139 | SEP="|" 140 | done 141 | OURCYGPATTERN="(^($ROOTDIRS))" 142 | # Add a user-defined pattern to the cygpath arguments 143 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 144 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 145 | fi 146 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 147 | i=0 148 | for arg in "$@" ; do 149 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 150 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 151 | 152 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 153 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 154 | else 155 | eval `echo args$i`="\"$arg\"" 156 | fi 157 | i=$((i+1)) 158 | done 159 | case $i in 160 | (0) set -- ;; 161 | (1) set -- "$args0" ;; 162 | (2) set -- "$args0" "$args1" ;; 163 | (3) set -- "$args0" "$args1" "$args2" ;; 164 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 165 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 166 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 167 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 168 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 169 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 170 | esac 171 | fi 172 | 173 | # Escape application args 174 | save () { 175 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 176 | echo " " 177 | } 178 | APP_ARGS=$(save "$@") 179 | 180 | # Collect all arguments for the java command, following the shell quoting and substitution rules 181 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 182 | 183 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 184 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 185 | cd "$(dirname "$0")" 186 | fi 187 | 188 | exec "$JAVACMD" "$@" 189 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/handler/RelayHandler.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler; 2 | 3 | import io.vertx.circuitbreaker.CircuitBreaker; 4 | import io.vertx.core.Vertx; 5 | import io.vertx.core.http.HttpClientRequest; 6 | import io.vertx.core.http.HttpServerRequest; 7 | import io.vertx.core.json.JsonObject; 8 | import io.vertx.core.streams.Pump; 9 | import io.vertx.ext.web.RoutingContext; 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | import top.dteam.dgate.config.RelayTo; 13 | import top.dteam.dgate.config.RelayUrlConfig; 14 | import top.dteam.dgate.gateway.SimpleResponse; 15 | import top.dteam.dgate.utils.RequestUtils; 16 | import top.dteam.dgate.utils.Utils; 17 | import top.dteam.dgate.utils.cache.ResponseHolder; 18 | 19 | import java.util.Base64; 20 | 21 | public class RelayHandler implements GatewayRequestHandler { 22 | 23 | private static final Logger logger = LoggerFactory.getLogger(RelayHandler.class); 24 | 25 | private Vertx vertx; 26 | private RelayUrlConfig urlConfig; 27 | private RelayTo relayTo; 28 | private String nameOfApiGateway; 29 | private RequestUtils requestUtils; 30 | private CircuitBreaker circuitBreaker; 31 | 32 | public RelayHandler(Vertx vertx, RelayUrlConfig urlConfig) { 33 | this.vertx = vertx; 34 | this.urlConfig = urlConfig; 35 | this.relayTo = urlConfig.getRelayTo(); 36 | this.requestUtils = new RequestUtils(vertx); 37 | if (relayTo.getCircuitBreaker() != null) { 38 | this.circuitBreaker = CircuitBreaker.create(String.format("cb-%s-%s", urlConfig.getUrl(), 39 | relayTo.toString()), vertx, relayTo.getCircuitBreaker()); 40 | } else { 41 | this.circuitBreaker = CircuitBreaker.create(String.format("cb-%s-%s", urlConfig.getUrl(), 42 | relayTo.toString()), vertx); 43 | } 44 | } 45 | 46 | @Override 47 | public void handle(RoutingContext routingContext) { 48 | HttpServerRequest request = routingContext.request(); 49 | JsonObject token = getJwtTokenFromRoutingContext(routingContext); 50 | 51 | if (isResponseCached(request.uri(), token)) { 52 | logger.info("Found response cache for {}/{}" 53 | , nameOfApiGateway, urlConfig.getUrl()); 54 | SimpleResponse simpleResponse = new SimpleResponse(); 55 | simpleResponse.setStatusCode(200); 56 | simpleResponse.setPayload(getResponseFromCache(request.uri(), token)); 57 | 58 | Utils.fireJsonResponse(routingContext.response(), simpleResponse.getStatusCode(), 59 | simpleResponse.getPayload().getMap()); 60 | 61 | return; 62 | } 63 | 64 | try { 65 | circuitBreaker.execute(future -> { 66 | HttpClientRequest relay = requestUtils.relay(request.method() 67 | , relayTo.getHost(), relayTo.getPort(), request.uri() 68 | , future::complete); 69 | 70 | relay.headers().addAll(request.headers()); 71 | 72 | RequestUtils.putProxyHeaders(relay, request); 73 | 74 | putJwtTokenToHeader(relay, routingContext); 75 | putNameOfApiGatewayInBody(relay, nameOfApiGateway); 76 | 77 | Pump pump = Pump.pump(request, relay); 78 | request.endHandler(end -> relay.end()); 79 | pump.start(); 80 | }).setHandler(result -> { 81 | SimpleResponse simpleResponse; 82 | if (result.succeeded()) { 83 | simpleResponse = (SimpleResponse) result.result(); 84 | 85 | if (urlConfig.getExpires() > 0) { 86 | logger.info("Put response cache for {}{}." 87 | , nameOfApiGateway, urlConfig.getUrl()); 88 | 89 | putResponseToCache(request.uri(), token 90 | , simpleResponse.getPayload() 91 | , urlConfig.getExpires()); 92 | } 93 | } else { 94 | logger.error("CB[{}] execution failed, cause: ", circuitBreaker.name(), result.cause()); 95 | 96 | simpleResponse = new SimpleResponse(); 97 | JsonObject error = new JsonObject(); 98 | error.put("error", result.cause().getMessage()); 99 | simpleResponse.setPayload(error); 100 | simpleResponse.setStatusCode(500); 101 | } 102 | 103 | Utils.fireJsonResponse(routingContext.response(), simpleResponse.getStatusCode(), 104 | simpleResponse.getPayload().getMap()); 105 | }); 106 | } catch (Exception e) { 107 | logger.error(e.getMessage()); 108 | e.printStackTrace(); 109 | throw e; 110 | } 111 | } 112 | 113 | @Override 114 | public GatewayRequestHandler nameOfApiGateway(String nameOfApiGateway) { 115 | this.nameOfApiGateway = nameOfApiGateway; 116 | return this; 117 | } 118 | 119 | private JsonObject getJwtTokenFromRoutingContext(RoutingContext routingContext) { 120 | JsonObject token = null; 121 | if (routingContext.user() != null) { 122 | token = routingContext.user().principal(); 123 | } else if (routingContext.get("token") != null) { 124 | token = routingContext.get("token"); 125 | } 126 | 127 | return token; 128 | } 129 | 130 | private void putJwtTokenToHeader(HttpClientRequest request, RoutingContext routingContext) { 131 | JsonObject token = getJwtTokenFromRoutingContext(routingContext); 132 | 133 | if (token != null) { 134 | request.putHeader(RequestUtils.JWT_HEADER, Base64.getEncoder().encodeToString(token.toString().getBytes())); 135 | } 136 | } 137 | 138 | private void putNameOfApiGatewayInBody(HttpClientRequest request, String nameOfApiGateway) { 139 | if (nameOfApiGateway != null) { 140 | request.putHeader(RequestUtils.API_GATEWAY_NAME_HEADER 141 | , Base64.getEncoder().encodeToString(nameOfApiGateway.getBytes())); 142 | } 143 | } 144 | 145 | private boolean isResponseCached(String requestURI, JsonObject token) { 146 | return urlConfig.getExpires() > 0 && 147 | ResponseHolder.containsCacheEntry(nameOfApiGateway 148 | , urlConfig.getUrl(), requestURI, token); 149 | } 150 | 151 | private JsonObject getResponseFromCache(String requestURI, JsonObject token) { 152 | return ResponseHolder.get(nameOfApiGateway, urlConfig.getUrl(), requestURI, token); 153 | } 154 | 155 | private void putResponseToCache(String requestURI, JsonObject token 156 | , JsonObject response, int expires) { 157 | ResponseHolder.put(nameOfApiGateway, urlConfig.getUrl(), requestURI, token, response, expires); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/main/groovy/top/dteam/dgate/config/ApiGatewayRepository.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.config 2 | 3 | import groovy.io.FileType 4 | import io.vertx.circuitbreaker.CircuitBreakerOptions 5 | import io.vertx.core.http.HttpMethod 6 | 7 | class ApiGatewayRepository { 8 | 9 | @Delegate 10 | static List respository = new ArrayList<>() 11 | 12 | static void load(String file = System.getProperty('conf')) { 13 | respository.clear() 14 | 15 | if (!file) { 16 | throw new FileNotFoundException("Please set config file first!") 17 | } 18 | 19 | File f = new File(file) 20 | if (f.isFile()) { 21 | build(f.text) 22 | } else if (f.isDirectory()) { 23 | // Matching files' name end with .conf, treat as config files. 24 | f.eachFileMatch(FileType.FILES, ~/.*\.conf$/) { 25 | build(it.text) 26 | } 27 | } else { 28 | throw new FileNotFoundException("No such file or directory: ${file}") 29 | } 30 | } 31 | 32 | static void build(String config) { 33 | ConfigSlurper slurper = new ConfigSlurper() 34 | ConfigObject configObject = slurper.parse(config) 35 | configObject.keySet().each { apiGateway -> 36 | respository << buildApiGateway(apiGateway, configObject[apiGateway]) 37 | } 38 | } 39 | 40 | private static ApiGatewayConfig buildApiGateway(def key, def body) { 41 | String name = key 42 | int port = body.port 43 | String host = body.host ?: '0.0.0.0' 44 | int expires = body.expires ?: 0 45 | LoginConfig login = body.login ? buildLogin(body.login) : null 46 | CorsConfig cors = buildCors(body.cors as Map) 47 | CircuitBreakerOptions defaultCBOptions = buildCircuitBreaker(body.circuitBreaker as Map) 48 | List urlConfigs = new ArrayList<>() 49 | body.urls.keySet().each { url -> 50 | urlConfigs << buildUrl(url, body.urls[url], defaultCBOptions, expires) 51 | } 52 | EventBusBridgeConfig eventBusBridgeConfig = buildEventBusBridge(body.eventBusBridge as Map) 53 | 54 | new ApiGatewayConfig( 55 | name: name, 56 | port: port, 57 | host: host, 58 | urlConfigs: urlConfigs, 59 | login: login, 60 | cors: cors, 61 | eventBusBridgeConfig: eventBusBridgeConfig 62 | ) 63 | } 64 | 65 | private static LoginConfig buildLogin(def login) { 66 | new LoginConfig(login) 67 | } 68 | 69 | private static CorsConfig buildCors(Map cors) { 70 | if (cors) { 71 | new CorsConfig(cors) 72 | } else { 73 | null 74 | } 75 | } 76 | 77 | private static CircuitBreakerOptions buildCircuitBreaker(Map circuitBreaker) { 78 | new CircuitBreakerOptions() 79 | .setMaxFailures(circuitBreaker?.maxFailures ?: 3) 80 | .setTimeout(circuitBreaker?.timeout ?: 5000) 81 | .setResetTimeout(circuitBreaker?.resetTimeout ?: 10000) 82 | } 83 | 84 | private static UrlConfig buildUrl( 85 | def key, def body, CircuitBreakerOptions defaultCBOptions, int defaultExpires = 0) { 86 | String url = key 87 | int expires = body.expires != [:] ? body.expires : defaultExpires 88 | // To avoid expires = 0 but defaultExpires != 0 89 | Object required = body.required ?: null 90 | List methods = (body.methods && body.methods instanceof List) ? 91 | parseMethods(body.methods as List) : [] 92 | Map expected = body.expected 93 | List upstreamURLs = new ArrayList<>() 94 | body.upstreamURLs.each { upstreamURL -> 95 | upstreamURL.expires = upstreamURL.expires != null ? upstreamURL.expires : expires 96 | CircuitBreakerOptions cbOptionsForUpstreamURL = 97 | upstreamURL.circuitBreaker ? 98 | buildCircuitBreaker(upstreamURL.circuitBreaker as Map) : 99 | defaultCBOptions 100 | 101 | upstreamURL << [circuitBreaker: cbOptionsForUpstreamURL] 102 | upstreamURLs << new UpstreamURL(upstreamURL) 103 | } 104 | 105 | Map relayTo = body.relayTo 106 | 107 | if (expected) { 108 | return new MockUrlConfig(url: url, 109 | required: required, 110 | methods: methods, 111 | expected: expected) 112 | } else if (upstreamURLs) { 113 | return new ProxyUrlConfig(url: url, 114 | required: required, 115 | methods: methods, 116 | expires: expires, 117 | upstreamURLs: upstreamURLs) 118 | } else if (relayTo) { 119 | CircuitBreakerOptions cbOptionsForRelayTo = 120 | relayTo.circuitBreaker ? buildCircuitBreaker(relayTo.circuitBreaker) : defaultCBOptions 121 | 122 | relayTo << [circuitBreaker: cbOptionsForRelayTo] 123 | return new RelayUrlConfig(url: url, expires: expires, 124 | relayTo: new RelayTo(relayTo)) 125 | } else { 126 | throw new InvalidConfiguriationException('Unknown URL type!') 127 | } 128 | 129 | } 130 | 131 | private static List parseMethods(List methods) { 132 | List parsedMethods = [] 133 | 134 | methods.each { 135 | if (it instanceof String) { 136 | parsedMethods.add(HttpMethod.valueOf(it)) 137 | } else if (it instanceof HttpMethod) { 138 | parsedMethods.add(it) 139 | } else { 140 | throw new InvalidConfiguriationException("Unknown method config '${it}'!") 141 | } 142 | } 143 | 144 | parsedMethods 145 | } 146 | 147 | private static EventBusBridgeConfig buildEventBusBridge(Map eventBusBridge) { 148 | if (!eventBusBridge) { 149 | null 150 | } else { 151 | EventBusBridgeConfig config = new EventBusBridgeConfig() 152 | 153 | if (eventBusBridge.urlPattern && eventBusBridge.consumers) { 154 | config.urlPattern = eventBusBridge.urlPattern 155 | 156 | config.publishers = new ArrayList<>() 157 | eventBusBridge.publishers.keySet().each { target -> 158 | config.publishers << new Publisher( 159 | target: target, 160 | expected: eventBusBridge.publishers."$target".expected, 161 | timer: eventBusBridge.publishers."$target".timer 162 | ) 163 | } 164 | 165 | config.consumers = new ArrayList<>() 166 | eventBusBridge.consumers.keySet().each { address -> 167 | config.consumers << new Consumer( 168 | address: address, 169 | target: eventBusBridge.consumers."$address".target ?: null, 170 | expected: eventBusBridge.consumers."$address".expected, 171 | ) 172 | } 173 | } else { 174 | throw new InvalidConfiguriationException('no URL Pattern or Consumers in EventBusBridge!') 175 | } 176 | 177 | config 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/handler/JWTHandlerSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler 2 | 3 | import io.vertx.core.Vertx 4 | import io.vertx.core.http.HttpMethod 5 | import io.vertx.core.http.HttpServer 6 | import io.vertx.core.json.JsonObject 7 | import io.vertx.ext.web.Router 8 | import spock.lang.Specification 9 | import top.dteam.dgate.config.ApiGatewayRepository 10 | import top.dteam.dgate.gateway.ApiGateway 11 | import top.dteam.dgate.gateway.SimpleResponse 12 | import top.dteam.dgate.utils.RequestUtils 13 | import top.dteam.dgate.utils.TestUtils 14 | import top.dteam.dgate.utils.Utils 15 | 16 | class JWTHandlerSpec extends Specification { 17 | 18 | private final static long TIME_OUT = 5 19 | 20 | Vertx vertx 21 | RequestUtils requestUtils 22 | HttpServer dest 23 | 24 | String config = """ 25 | import io.vertx.core.http.HttpMethod 26 | import io.vertx.core.Vertx 27 | import io.vertx.ext.auth.jwt.JWTAuth 28 | import top.dteam.dgate.utils.* 29 | 30 | apiGateway { 31 | port = 7000 32 | login { 33 | url = "/login" 34 | ignore = ['/public'] 35 | refreshLimit = ${TIME_OUT} 36 | refreshExpire = ${TIME_OUT} 37 | } 38 | urls { 39 | "/login" { 40 | expected { 41 | statusCode = 200 42 | payload = { 43 | JWTAuth jwtAuth = Utils.createAuthProvider(Vertx.vertx()) 44 | JWTTokenGenerator tokenGenerator = new JWTTokenGenerator(jwtAuth) 45 | [token: tokenGenerator.token(["sub": "13572209183", "name": "foxgem", "role": "normal"], ${ 46 | TIME_OUT 47 | })] 48 | } 49 | } 50 | } 51 | "/proxy" { 52 | expected { 53 | statusCode = 200 54 | payload = [test: 'true'] 55 | } 56 | } 57 | "/public" { 58 | upstreamURLs = [ 59 | [ host: 'localhost', port: 8082, url: '/normal' ] 60 | ] 61 | } 62 | } 63 | } 64 | """ 65 | 66 | void setup() { 67 | vertx = Vertx.vertx() 68 | deployGate() 69 | requestUtils = new RequestUtils(vertx) 70 | dest = createDest() 71 | } 72 | 73 | void cleanup() { 74 | dest.close() 75 | vertx.close() 76 | } 77 | 78 | def "Should get 401 if refreshing happens before login"() { 79 | setup: 80 | SimpleResponse result 81 | 82 | when: 83 | sleep(100) 84 | requestUtils.get("localhost", 7000, JWTTokenRefreshHandler.URL, new JsonObject()) { simpleResponse -> 85 | result = simpleResponse 86 | } 87 | TestUtils.waitResult(result, 1500) 88 | 89 | then: 90 | result.statusCode == 401 91 | } 92 | 93 | def "An expired token which is activity before refreshLimit seconds could refresh successfully"() { 94 | setup: 95 | String oldToken 96 | String newToken 97 | SimpleResponse resultAfterRefreshing 98 | SimpleResponse resultAfterTokenRefreshedExpired 99 | 100 | when: 101 | sleep(100) 102 | requestUtils.get("localhost", 7000, '/login', new JsonObject()) { simpleResponse -> 103 | oldToken = simpleResponse.payload.getString('token') 104 | } 105 | TestUtils.waitResult(oldToken, 1500) 106 | 107 | sleep(TIME_OUT * 1000) 108 | 109 | requestUtils.requestWithJwtToken(HttpMethod.POST, "localhost", 7000, JWTTokenRefreshHandler.URL, 110 | new JsonObject(), oldToken) { simpleResponse -> 111 | newToken = simpleResponse.payload.getString('token') 112 | } 113 | TestUtils.waitResult(newToken, 1500) 114 | 115 | requestUtils.requestWithJwtToken(HttpMethod.POST, "localhost", 7000, '/proxy', 116 | new JsonObject(), newToken) { simpleResponse -> 117 | resultAfterRefreshing = simpleResponse 118 | } 119 | TestUtils.waitResult(resultAfterRefreshing, 1500) 120 | 121 | sleep(TIME_OUT * 1000) 122 | 123 | requestUtils.requestWithJwtToken(HttpMethod.POST, "localhost", 7000, '/proxy', 124 | new JsonObject(), newToken) { simpleResponse -> 125 | resultAfterTokenRefreshedExpired = simpleResponse 126 | } 127 | TestUtils.waitResult(resultAfterTokenRefreshedExpired, 1500) 128 | 129 | then: 130 | oldToken != newToken 131 | resultAfterRefreshing.statusCode == 200 132 | resultAfterRefreshing.payload.map == [test: 'true'] 133 | resultAfterTokenRefreshedExpired.statusCode == 401 134 | } 135 | 136 | def "A very old expired token could not refresh successfully"() { 137 | setup: 138 | SimpleResponse result 139 | String oldToken 140 | 141 | when: 142 | sleep(100) 143 | requestUtils.get("localhost", 7000, '/login', new JsonObject()) { simpleResponse -> 144 | oldToken = simpleResponse.payload.getString('token') 145 | } 146 | TestUtils.waitResult(oldToken, 1500) 147 | 148 | sleep(TIME_OUT * 2 * 1000 + 500) 149 | 150 | requestUtils.requestWithJwtToken(HttpMethod.POST, "localhost", 7000, JWTTokenRefreshHandler.URL, 151 | new JsonObject(), oldToken) { simpleResponse -> 152 | result = simpleResponse 153 | } 154 | TestUtils.waitResult(result, 1500) 155 | 156 | then: 157 | result.statusCode == 401 158 | } 159 | 160 | def "should pass jwt token when Authorization header exists"() { 161 | setup: 162 | SimpleResponse result 163 | 164 | when: 165 | sleep(100) 166 | requestUtils.get("localhost", 7000, '/login', new JsonObject()) { loginResponse -> 167 | requestUtils.requestWithJwtToken(HttpMethod.GET, "localhost", 7000, "/public", new JsonObject() 168 | , loginResponse.payload.getString('token')) { simpleResponse -> 169 | result = simpleResponse 170 | } 171 | } 172 | 173 | TestUtils.waitResult(result, 1500) 174 | 175 | then: 176 | result.statusCode == 200 177 | result.payload.map.params.sub == '13572209183' 178 | result.payload.map.params.name == 'foxgem' 179 | result.payload.map.params.role == 'normal' 180 | } 181 | 182 | private void deployGate() { 183 | ApiGatewayRepository.respository.clear() 184 | ApiGatewayRepository.build(config) 185 | vertx.deployVerticle(new ApiGateway(ApiGatewayRepository.respository[0])) 186 | } 187 | 188 | private HttpServer createDest() { 189 | HttpServer httpServer = vertx.createHttpServer() 190 | Router router = Router.router(vertx) 191 | httpServer.requestHandler(router.&accept).listen(8082) 192 | 193 | router.route("/normal").handler { routingContext -> 194 | routingContext.request().bodyHandler { totalBuffer -> 195 | JsonObject jwt = new JsonObject(requestUtils.getJwtHeader(routingContext.request())); 196 | Utils.fireJsonResponse(routingContext.response(), 200, 197 | [method: routingContext.request().method(), 198 | params: totalBuffer.toJsonObject().mergeIn(jwt)]) 199 | } 200 | } 201 | 202 | httpServer 203 | } 204 | 205 | } 206 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/ApiGatewayIntegationSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate 2 | 3 | import io.vertx.core.Vertx 4 | import io.vertx.core.http.HttpMethod 5 | import io.vertx.core.http.HttpServer 6 | import io.vertx.core.json.JsonObject 7 | import io.vertx.ext.web.Router 8 | import spock.lang.Specification 9 | import spock.lang.Stepwise 10 | import spock.lang.Unroll 11 | import top.dteam.dgate.config.ApiGatewayRepository 12 | import top.dteam.dgate.gateway.ApiGateway 13 | import top.dteam.dgate.gateway.SimpleResponse 14 | import top.dteam.dgate.utils.RequestUtils 15 | import top.dteam.dgate.utils.TestUtils 16 | import top.dteam.dgate.utils.Utils 17 | 18 | @Stepwise 19 | class ApiGatewayIntegationSpec extends Specification { 20 | 21 | static Vertx vertx 22 | static HttpServer dest 23 | static RequestUtils requestUtils 24 | 25 | static String config = """ 26 | import io.vertx.core.http.HttpMethod 27 | 28 | apiGateway { 29 | port = 7000 30 | login = "/login" 31 | urls { 32 | "/login" { 33 | required = ["sub", "password"] 34 | methods = [HttpMethod.GET, HttpMethod.POST] 35 | upstreamURLs = [ 36 | [ 37 | host: 'localhost', port: 8080, url: '/login', 38 | after: { simpleResponse -> 39 | Map payload = [ 40 | sub: simpleResponse.payload.getString("sub"), 41 | name: simpleResponse.payload.getString("name"), 42 | role: simpleResponse.payload.getString("role") 43 | ] 44 | simpleResponse.payload.put('token', delegate.tokenGenerator.token(payload, 5)) 45 | simpleResponse 46 | } 47 | ] 48 | ] 49 | } 50 | "/forward" { 51 | required = ['param'] 52 | methods = [HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE] 53 | upstreamURLs = [ 54 | [ 55 | host: 'localhost', port: 8080, url: '/test', 56 | before: { jsonObject -> jsonObject }, 57 | after: { simpleResponse -> simpleResponse } 58 | ] 59 | ] 60 | } 61 | } 62 | } 63 | """ 64 | 65 | static String token 66 | 67 | void setupSpec() { 68 | vertx = Vertx.vertx() 69 | deployGate() 70 | dest = createDest() 71 | requestUtils = new RequestUtils(vertx) 72 | } 73 | 74 | void cleanupSpec() { 75 | dest.close() 76 | vertx.close() 77 | } 78 | 79 | def "should get 401 when request /forward before login"() { 80 | setup: 81 | SimpleResponse result 82 | 83 | when: 84 | sleep(100) 85 | requestUtils.get("localhost", 7000, "/forward", new JsonObject()) { simpleResponse -> 86 | result = simpleResponse 87 | } 88 | TestUtils.waitResult(result, 1500) 89 | 90 | then: 91 | result.statusCode == 401 92 | } 93 | 94 | def "should get 400 when request /login with unsupported HTTP methods"() { 95 | setup: 96 | SimpleResponse result 97 | 98 | when: 99 | requestUtils.request(HttpMethod.PUT, "localhost", 7000, "/login", new JsonObject()) { simpleResponse -> 100 | result = simpleResponse 101 | } 102 | TestUtils.waitResult(result, 1500) 103 | 104 | then: 105 | result.statusCode == 400 106 | } 107 | 108 | @Unroll 109 | def "should get 400 when request /login with required not in the request"() { 110 | setup: 111 | SimpleResponse result 112 | 113 | when: 114 | requestUtils.get("localhost", 7000, url, new JsonObject(params)) { simpleResponse -> 115 | result = simpleResponse 116 | } 117 | TestUtils.waitResult(result, 1500) 118 | 119 | then: 120 | result.statusCode == 400 121 | 122 | where: 123 | url | params 124 | '/login' | [:] 125 | '/login' | [sub: '13572209183'] 126 | '/login' | [password: 'password'] 127 | '/login?sub=13572209183' | [:] 128 | '/login?password=password' | [:] 129 | } 130 | 131 | @Unroll 132 | def "should get a jwt token when request /login successfully"() { 133 | setup: 134 | SimpleResponse result 135 | 136 | when: 137 | requestUtils.get("localhost", 7000, url, new JsonObject(params)) { simpleResponse -> 138 | result = simpleResponse 139 | token = simpleResponse.payload.getString('token') 140 | } 141 | TestUtils.waitResult(result, 1500) 142 | 143 | then: 144 | result.statusCode == 200 145 | token 146 | 147 | where: 148 | url | params 149 | '/login?sub=13572209183&&password=password' | [:] 150 | '/login' | [sub: '13572209183', password: 'password'] 151 | } 152 | 153 | def "should get 200 and a payload including a jwt token & the name of api gateway when request /forward after login"() { 154 | setup: 155 | SimpleResponse result 156 | 157 | when: 158 | requestUtils.requestWithJwtToken(HttpMethod.POST, "localhost", 7000, "/forward", 159 | new JsonObject([param: 'param']), token) { simpleResponse -> 160 | result = simpleResponse 161 | } 162 | TestUtils.waitResult(result, 1500) 163 | 164 | then: 165 | result.statusCode == 200 166 | result.payload.map.method == HttpMethod.POST.toString() 167 | result.payload.map.params.param == 'param' 168 | result.payload.map.params.nameOfApiGateway == 'apiGateway' 169 | result.payload.map.params.sub == '13572209183' 170 | result.payload.map.params.name == 'foxgem' 171 | result.payload.map.params.role == 'normal' 172 | } 173 | 174 | def "should get 401 when request /forward with an expired jwt token "() { 175 | setup: 176 | SimpleResponse result 177 | 178 | when: 179 | sleep(5000) 180 | requestUtils.requestWithJwtToken(HttpMethod.POST, "localhost", 7000, "/forward", 181 | new JsonObject([param: 'param']), token) { simpleResponse -> 182 | result = simpleResponse 183 | } 184 | TestUtils.waitResult(result, 1500) 185 | 186 | then: 187 | result.statusCode == 401 188 | } 189 | 190 | private HttpServer createDest() { 191 | HttpServer httpServer = vertx.createHttpServer() 192 | Router router = Router.router(vertx) 193 | httpServer.requestHandler(router.&accept).listen(8080) 194 | 195 | router.route('/login').handler { routingContext -> 196 | routingContext.request().bodyHandler { totalBuffer -> 197 | Utils.fireJsonResponse(routingContext.response(), 200, [sub : '13572209183', 198 | name: 'foxgem', role: 'normal']) 199 | } 200 | } 201 | router.route().handler { routingContext -> 202 | routingContext.request().bodyHandler { totalBuffer -> 203 | JsonObject jwt = new JsonObject(requestUtils.getJwtHeader(routingContext.request())) 204 | JsonObject nameOfApiGateway = new JsonObject() 205 | .put("nameOfApiGateway", requestUtils.getAPIGatewayNameHeader(routingContext.request())) 206 | 207 | Utils.fireJsonResponse(routingContext.response(), 200, 208 | [method: routingContext.request().method(), 209 | params: totalBuffer.toJsonObject().mergeIn(jwt).mergeIn(nameOfApiGateway)]) 210 | } 211 | } 212 | 213 | httpServer 214 | } 215 | 216 | private void deployGate() { 217 | ApiGatewayRepository.respository.clear() 218 | ApiGatewayRepository.build(config) 219 | vertx.deployVerticle(new ApiGateway(ApiGatewayRepository.respository[0])) 220 | } 221 | 222 | } 223 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/handler/RelayHandlerWithCacheSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler 2 | 3 | import io.vertx.core.Vertx 4 | import io.vertx.core.VertxOptions 5 | import io.vertx.core.http.HttpMethod 6 | import io.vertx.core.http.HttpServer 7 | import io.vertx.core.json.JsonObject 8 | import io.vertx.ext.web.Router 9 | import org.slf4j.Logger 10 | import org.slf4j.LoggerFactory 11 | import spock.lang.Specification 12 | import top.dteam.dgate.config.ApiGatewayRepository 13 | import top.dteam.dgate.config.MockUrlConfig 14 | import top.dteam.dgate.gateway.ApiGateway 15 | import top.dteam.dgate.gateway.SimpleResponse 16 | import top.dteam.dgate.utils.RequestUtils 17 | import top.dteam.dgate.utils.TestUtils 18 | import top.dteam.dgate.utils.Utils 19 | import top.dteam.dgate.utils.cache.CacheLocator 20 | 21 | import java.time.ZonedDateTime 22 | 23 | class RelayHandlerWithCacheSpec extends Specification { 24 | private static final Logger logger = LoggerFactory.getLogger(RelayHandlerWithCacheSpec.class) 25 | 26 | private static final String CONFIG = ''' 27 | apiGateway1 { 28 | port = 9010 29 | urls { 30 | "/random1" { 31 | expires = 7000 32 | relayTo { 33 | host = 'localhost' 34 | port = 9011 35 | } 36 | } 37 | } 38 | } 39 | 40 | apiGateway2 { 41 | port = 9012 42 | login = "/login" 43 | urls { 44 | "/login" { 45 | required = ["sub", "password"] 46 | methods = ['GET', 'POST'] 47 | upstreamURLs = [ 48 | [host: 'localhost', port: 9013, url: '/login', 49 | after: { simpleResponse -> 50 | Map payload = [ 51 | sub: simpleResponse.payload.getString("sub"), 52 | password: simpleResponse.payload.getString("password") 53 | ] 54 | simpleResponse.payload.put('token', delegate.tokenGenerator.token(payload, 50)) 55 | simpleResponse 56 | }] 57 | ] 58 | } 59 | "/forward" { 60 | expires = 7000 61 | relayTo { 62 | host = 'localhost' 63 | port = 9013 64 | } 65 | } 66 | } 67 | } 68 | ''' 69 | 70 | private static Vertx vertx 71 | private static RequestUtils requestUtils 72 | private static HttpServer mockServer 73 | private static HttpServer destServer 74 | 75 | void setupSpec() { 76 | Vertx.clusteredVertx(new VertxOptions(), { res -> 77 | if (res.succeeded()) { 78 | vertx = res.result() 79 | requestUtils = new RequestUtils(vertx) 80 | mockServer = createMock() 81 | destServer = createDest() 82 | ApiGatewayRepository.respository.clear() 83 | ApiGatewayRepository.build(CONFIG) 84 | ApiGatewayRepository.respository.each { 85 | vertx.deployVerticle(new ApiGateway(it)) 86 | } 87 | 88 | CacheLocator.init(vertx) 89 | } else { 90 | throw new RuntimeException("Starting up cluster vertx failed.") 91 | } 92 | }) 93 | 94 | TestUtils.waitResult(mockServer, 10000) 95 | TestUtils.waitResult(destServer, 10000) 96 | } 97 | 98 | void cleanupSpec() { 99 | mockServer.close() 100 | destServer.close() 101 | CacheLocator.close() 102 | vertx.close() 103 | } 104 | 105 | def "expires should be working"() { 106 | setup: 107 | SimpleResponse result 108 | ZonedDateTime now = ZonedDateTime.now() 109 | 110 | when: 111 | sleep(100) 112 | requestUtils.get("localhost", 9010, "/random1", new JsonObject()) { simpleResponse -> 113 | result = simpleResponse 114 | } 115 | TestUtils.waitResult(result, 2000) 116 | 117 | then: 118 | 1.upto(8) { 119 | SimpleResponse result1 120 | requestUtils.get("localhost", 9010, "/random1" 121 | , new JsonObject()) { simpleResponse -> 122 | result1 = simpleResponse 123 | } 124 | TestUtils.waitResult(result1, 2000) 125 | 126 | logger.debug("result = ${result.toJsonObject()}") 127 | logger.debug("result1 = ${result1.toJsonObject()}") 128 | 129 | if (ZonedDateTime.now() < now.plusSeconds(7)) { 130 | result == result1 131 | } else { 132 | result != result1 133 | } 134 | 135 | sleep(1000) 136 | } 137 | } 138 | 139 | def "different token should get different caches"() { 140 | setup: 141 | SimpleResponse result1 142 | SimpleResponse result2 143 | String token1 144 | String token2 145 | ZonedDateTime now = ZonedDateTime.now() 146 | 147 | when: 148 | requestUtils.get('localhost', 9012, '/login?sub=a&password=a' 149 | , new JsonObject()) { simpleResponse -> 150 | token1 = simpleResponse.payload.getString("token") 151 | 152 | requestUtils.requestWithJwtToken(HttpMethod.GET, 'localhost' 153 | , 9012, '/forward', new JsonObject(), token1) { response -> 154 | result1 = response 155 | } 156 | } 157 | requestUtils.get('localhost', 9012, '/login?sub=b&password=b' 158 | , new JsonObject()) { simpleResponse -> 159 | token2 = simpleResponse.payload.getString("token") 160 | 161 | requestUtils.requestWithJwtToken(HttpMethod.GET, 'localhost' 162 | , 9012, '/forward', new JsonObject(), token2) { response -> 163 | result2 = response 164 | } 165 | } 166 | TestUtils.waitResult(result1, 2000) 167 | TestUtils.waitResult(result2, 2000) 168 | 169 | then: 170 | token1 != token2 171 | result1 != result2 172 | 173 | 1.upto(8) { 174 | SimpleResponse result3 175 | SimpleResponse result4 176 | 177 | requestUtils.requestWithJwtToken(HttpMethod.GET, 'localhost', 9012 178 | , '/forward', new JsonObject(), token1) { response -> 179 | result3 = response 180 | } 181 | requestUtils.requestWithJwtToken(HttpMethod.GET, 'localhost', 9012 182 | , '/forward', new JsonObject(), token2) { response -> 183 | result4 = response 184 | } 185 | 186 | TestUtils.waitResult(result3, 2000) 187 | TestUtils.waitResult(result4, 2000) 188 | 189 | result3 != result4 190 | 191 | if (ZonedDateTime.now() < now.plusSeconds(7)) { 192 | result3 == result1 193 | result4 == result2 194 | } 195 | } 196 | } 197 | 198 | private static HttpServer createMock() { 199 | HttpServer httpServer = vertx.createHttpServer() 200 | Router router = Router.router(vertx) 201 | httpServer.requestHandler(router.&accept).listen(9011) 202 | 203 | router.route("/random1").handler(new MockHandler(vertx, 204 | new MockUrlConfig(expected: [ 205 | statusCode: 200, payload: { [random1: new Random().nextInt(100)] }] 206 | ) 207 | )) 208 | 209 | httpServer 210 | } 211 | 212 | private static HttpServer createDest() { 213 | HttpServer httpServer = vertx.createHttpServer() 214 | Router router = Router.router(vertx) 215 | httpServer.requestHandler(router.&accept).listen(9013) 216 | 217 | router.route('/login').handler { routingContext -> 218 | routingContext.request().bodyHandler { totalBuffer -> 219 | Utils.fireJsonResponse(routingContext.response() 220 | , 200 221 | , [sub : totalBuffer.toJsonObject().getString("sub"), 222 | password: totalBuffer.toJsonObject().getString("password")]) 223 | } 224 | } 225 | router.route().handler { routingContext -> 226 | routingContext.request().bodyHandler { totalBuffer -> 227 | JsonObject jwt = new JsonObject(requestUtils.getJwtHeader(routingContext.request())) 228 | JsonObject nameOfApiGateway = new JsonObject() 229 | .put("nameOfApiGateway", requestUtils.getAPIGatewayNameHeader(routingContext.request())) 230 | 231 | Utils.fireJsonResponse(routingContext.response() 232 | , 200 233 | , [method: routingContext.request().method(), 234 | params: new JsonObject().mergeIn(jwt).mergeIn(nameOfApiGateway)]) 235 | } 236 | } 237 | 238 | httpServer 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/utils/RequestUtils.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.utils; 2 | 3 | import io.vertx.core.Handler; 4 | import io.vertx.core.Vertx; 5 | import io.vertx.core.buffer.Buffer; 6 | import io.vertx.core.http.*; 7 | import io.vertx.core.json.JsonObject; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | import top.dteam.dgate.gateway.SimpleResponse; 11 | 12 | import java.io.IOException; 13 | import java.io.UnsupportedEncodingException; 14 | import java.net.URLEncoder; 15 | import java.nio.file.Files; 16 | import java.nio.file.Paths; 17 | import java.util.Base64; 18 | import java.util.Map; 19 | 20 | public class RequestUtils { 21 | 22 | public static final String JWT_HEADER = "dgate-jwt-token"; 23 | public static final String API_GATEWAY_NAME_HEADER = "dgate-gateway"; 24 | 25 | private static final Logger logger = LoggerFactory.getLogger(RequestUtils.class); 26 | 27 | private HttpClient httpClient; 28 | 29 | public RequestUtils(Vertx vertx) { 30 | httpClient = vertx.createHttpClient(); 31 | } 32 | 33 | public void get(String host, int port, String url, JsonObject data, Handler handler) { 34 | request(HttpMethod.GET, host, port, url, data, handler); 35 | } 36 | 37 | public void post(String host, int port, String url, JsonObject data, Handler handler) { 38 | request(HttpMethod.POST, host, port, url, data, handler); 39 | } 40 | 41 | public void delete(String host, int port, String url, JsonObject data, Handler handler) { 42 | request(HttpMethod.DELETE, host, port, url, data, handler); 43 | } 44 | 45 | public void request(HttpMethod method, String host, int port, String url 46 | , JsonObject data, Handler handler) { 47 | request(method, host, port, url, data, null, handler); 48 | } 49 | 50 | public void request(HttpMethod method, String host, int port, String url 51 | , JsonObject data, HttpServerRequest clientRequest, Handler handler) { 52 | HttpClientRequest request = httpClient.request(method, port, host, url, defaultResponseHandler(handler)) 53 | .setChunked(true) 54 | .setFollowRedirects(true) 55 | .putHeader("content-type", "application/json"); 56 | 57 | putProxyHeaders(request, clientRequest); 58 | 59 | if (data.getJsonObject("token") != null) { 60 | request.putHeader(JWT_HEADER, Base64.getEncoder().encodeToString(data.getJsonObject("token").toString().getBytes())); 61 | } 62 | 63 | if (data.getString("nameOfApiGateway") != null) { 64 | request.putHeader(API_GATEWAY_NAME_HEADER, Base64.getEncoder().encodeToString(data.getString("nameOfApiGateway").getBytes())); 65 | } 66 | 67 | request.end(data.toString()); 68 | } 69 | 70 | public void requestWithJwtToken(HttpMethod method, String host, int port, String url, JsonObject data, String token, 71 | Handler handler) { 72 | httpClient.request(method, port, host, url, defaultResponseHandler(handler)) 73 | .setChunked(true) 74 | .setFollowRedirects(true) 75 | .putHeader("content-type", "application/json") 76 | .putHeader("Authorization", String.format("Bearer %s", token)) 77 | .end(data.toString()); 78 | } 79 | 80 | public static void putProxyHeaders(HttpClientRequest proxyRequest, HttpServerRequest clientRequest) { 81 | if (clientRequest != null) { 82 | if (clientRequest.getHeader("User-Agent") != null) { 83 | proxyRequest.putHeader("User-Agent" 84 | , clientRequest.getHeader("User-Agent")); 85 | } 86 | 87 | if (clientRequest.getHeader("X-Real-IP") != null) { 88 | proxyRequest.putHeader("X-Real-IP", clientRequest.getHeader("X-Real-IP")); 89 | } else { 90 | proxyRequest.putHeader("X-Real-IP" 91 | , clientRequest.remoteAddress().host()); 92 | } 93 | 94 | if (clientRequest.getHeader("X-Forwarded-For") != null) { 95 | proxyRequest.putHeader("X-Forwarded-For" 96 | , clientRequest.getHeader("X-Forwarded-For") 97 | + "," + clientRequest.remoteAddress().host()); 98 | } else { 99 | proxyRequest.putHeader("X-Forwarded-For", clientRequest.remoteAddress().host()); 100 | } 101 | 102 | proxyRequest.putHeader("X-Forwarded-Host", clientRequest.host()) 103 | .putHeader("X-Forwarded-Proto", clientRequest.scheme()); 104 | } 105 | } 106 | 107 | public HttpClientRequest relay(HttpMethod method, String host, int port, String url, Handler handler) { 108 | return httpClient.request(method, port, host, url, defaultResponseHandler(handler)); 109 | } 110 | 111 | public void upload(String file, String host, int port, String url, Handler handler) { 112 | HttpClientRequest request = httpClient.post(port, host, url, defaultResponseHandler(handler)); 113 | try { 114 | Buffer bodyBuffer = getBody(file, "file", "MyBoundary"); 115 | request.putHeader("Content-Type", "multipart/form-data;boundary=MyBoundary") 116 | .putHeader("Content-Length", String.valueOf(bodyBuffer.length())); 117 | request.end(bodyBuffer); 118 | } catch (IOException e) { 119 | logger.error(e.getMessage()); 120 | throw new RuntimeException(e); 121 | } 122 | } 123 | 124 | public void form(JsonObject form, String host, int port, String url, Handler handler) { 125 | HttpClientRequest request = httpClient.post(port, host, url, defaultResponseHandler(handler)); 126 | Buffer bodyBuffer = formData(form); 127 | request.putHeader("Content-Type", "application/x-www-form-urlencoded") 128 | .putHeader("Content-Length", String.valueOf(bodyBuffer.length())); 129 | request.end(bodyBuffer); 130 | } 131 | 132 | public String getJwtHeader(HttpServerRequest request) { 133 | if (request.headers().contains(RequestUtils.JWT_HEADER)) { 134 | return new String(Base64.getDecoder().decode(request.getHeader(RequestUtils.JWT_HEADER))); 135 | } else { 136 | return ""; 137 | } 138 | } 139 | 140 | public String getAPIGatewayNameHeader(HttpServerRequest request) { 141 | if (request.headers().contains(RequestUtils.API_GATEWAY_NAME_HEADER)) { 142 | return new String(Base64.getDecoder().decode(request.getHeader(RequestUtils.API_GATEWAY_NAME_HEADER))); 143 | } else { 144 | return ""; 145 | } 146 | } 147 | 148 | private Handler defaultResponseHandler(Handler handler) { 149 | return response -> { 150 | SimpleResponse simpleResponse = new SimpleResponse(); 151 | simpleResponse.setStatusCode(response.statusCode()); 152 | response.bodyHandler(totalBuffer -> { 153 | if (totalBuffer.length() > 0) { 154 | simpleResponse.setPayload(totalBuffer.toJsonObject()); 155 | } 156 | handler.handle(simpleResponse); 157 | }); 158 | }; 159 | } 160 | 161 | private Buffer getBody(String filename, String name, String boundary) throws IOException { 162 | Buffer buffer = Buffer.buffer(); 163 | buffer.appendString(String.format("--%s\r\n", boundary)); 164 | buffer.appendString(String.format("Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n", name, filename)); 165 | buffer.appendString("Content-Type: application/octet-stream\r\n"); 166 | buffer.appendString(String.format("Content-Length: %d\r\n", Files.size(Paths.get(filename)))); 167 | buffer.appendString("Content-Transfer-Encoding: binary\r\n"); 168 | buffer.appendString("\r\n"); 169 | try { 170 | buffer.appendBytes(Files.readAllBytes(Paths.get(filename))); 171 | buffer.appendString("\r\n"); 172 | } catch (IOException e) { 173 | e.printStackTrace(); 174 | 175 | } 176 | buffer.appendString(String.format("--%s--\r\n", boundary)); 177 | return buffer; 178 | } 179 | 180 | private Buffer formData(JsonObject payload) { 181 | if (payload.isEmpty()) { 182 | return Buffer.buffer(); 183 | } 184 | 185 | Buffer buffer = Buffer.buffer(); 186 | if (payload.size() == 1) { 187 | Map.Entry entry = payload.iterator().next(); 188 | try { 189 | buffer.appendString(entry.getKey()).appendString("=") 190 | .appendString(URLEncoder.encode(entry.getValue().toString(), "utf-8")); 191 | } catch (UnsupportedEncodingException e) { 192 | logger.error(e.getMessage()); 193 | } 194 | } else { 195 | payload.forEach(entry -> { 196 | try { 197 | buffer.appendString(entry.getKey()).appendString("=") 198 | .appendString(URLEncoder.encode(entry.getValue().toString(), "utf-8")) 199 | .appendString("&"); 200 | } catch (UnsupportedEncodingException e) { 201 | logger.error(e.getMessage()); 202 | } 203 | }); 204 | } 205 | return buffer; 206 | } 207 | 208 | } 209 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/handler/ProxyHandlerWithCacheSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler 2 | 3 | import io.vertx.core.Vertx 4 | import io.vertx.core.VertxOptions 5 | import io.vertx.core.http.HttpMethod 6 | import io.vertx.core.http.HttpServer 7 | import io.vertx.core.json.JsonObject 8 | import io.vertx.ext.web.Router 9 | import spock.lang.Specification 10 | import top.dteam.dgate.config.ApiGatewayRepository 11 | import top.dteam.dgate.config.MockUrlConfig 12 | import top.dteam.dgate.gateway.ApiGateway 13 | import top.dteam.dgate.gateway.SimpleResponse 14 | import top.dteam.dgate.utils.RequestUtils 15 | import top.dteam.dgate.utils.TestUtils 16 | import top.dteam.dgate.utils.Utils 17 | import top.dteam.dgate.utils.cache.CacheLocator 18 | 19 | import java.time.ZonedDateTime 20 | 21 | class ProxyHandlerWithCacheSpec extends Specification { 22 | private static final String CONFIG = ''' 23 | import io.vertx.core.http.HttpMethod 24 | import io.vertx.core.Vertx 25 | import io.vertx.ext.auth.jwt.JWTAuth 26 | import top.dteam.dgate.utils.* 27 | apiGateway1 { 28 | port = 8001 29 | urls { 30 | "/url1" { 31 | expires = 7000 32 | upstreamURLs = [ 33 | [host: 'localhost', port: 9001, url: '/random1'] 34 | ] 35 | } 36 | "/url2" { 37 | upstreamURLs = [ 38 | [host: 'localhost', port: 9001, url: '/random1', expires: 7000], 39 | [host: 'localhost', port: 9001, url: '/random2'] 40 | ] 41 | } 42 | } 43 | } 44 | 45 | apiGateway2 { 46 | port = 8002 47 | login = "/login" 48 | urls { 49 | "/login" { 50 | required = ["sub", "password"] 51 | methods = ['GET', 'POST'] 52 | upstreamURLs = [ 53 | [host: 'localhost', port: 9002, url: '/login', 54 | after: { simpleResponse -> 55 | Map payload = [ 56 | sub: simpleResponse.payload.getString("sub"), 57 | password: simpleResponse.payload.getString("password") 58 | ] 59 | simpleResponse.payload.put('token', delegate.tokenGenerator.token(payload, 50)) 60 | simpleResponse 61 | }] 62 | ] 63 | } 64 | "/forward" { 65 | expires = 7000 66 | upstreamURLs = [ 67 | [host: 'localhost', port: 9002, url: '/forward'] 68 | ] 69 | } 70 | } 71 | } 72 | ''' 73 | 74 | private static Vertx vertx 75 | private static RequestUtils requestUtils 76 | private static HttpServer mockServer 77 | private static HttpServer destServer 78 | 79 | void setupSpec() { 80 | Vertx.clusteredVertx(new VertxOptions(), { res -> 81 | if (res.succeeded()) { 82 | vertx = res.result() 83 | ApiGatewayRepository.respository.clear() 84 | ApiGatewayRepository.build(CONFIG) 85 | ApiGatewayRepository.respository.each { 86 | vertx.deployVerticle(new ApiGateway(it)) 87 | } 88 | 89 | CacheLocator.init(vertx) 90 | requestUtils = new RequestUtils(vertx) 91 | mockServer = createMock() 92 | destServer = createDest() 93 | } else { 94 | throw new RuntimeException("Starting up cluster vertx failed.") 95 | } 96 | }) 97 | 98 | TestUtils.waitResult(mockServer, 10000) 99 | TestUtils.waitResult(destServer, 10000) 100 | } 101 | 102 | void cleanupSpec() { 103 | mockServer.close() 104 | destServer.close() 105 | CacheLocator.close() 106 | vertx.close() 107 | } 108 | 109 | def "expires should be working"() { 110 | setup: 111 | SimpleResponse result1 112 | SimpleResponse result2 113 | SimpleResponse result3 114 | SimpleResponse result4 115 | ZonedDateTime now = ZonedDateTime.now() 116 | 117 | when: 118 | sleep(100) 119 | requestUtils.get("localhost", 8001, "/url1", new JsonObject()) { simpleResponse -> 120 | result1 = simpleResponse 121 | } 122 | requestUtils.get("localhost", 8001, "/url2", new JsonObject()) { simpleResponse -> 123 | result3 = simpleResponse 124 | } 125 | TestUtils.waitResult(result1, 2000) 126 | TestUtils.waitResult(result3, 2000) 127 | 128 | then: 129 | 1.upto(8) { 130 | requestUtils.get("localhost", 8001, "/url1", new JsonObject()) { simpleResponse -> 131 | result2 = simpleResponse 132 | } 133 | requestUtils.get("localhost", 8001, "/url2", new JsonObject()) { simpleResponse -> 134 | result4 = simpleResponse 135 | } 136 | TestUtils.waitResult(result2, 2000) 137 | TestUtils.waitResult(result4, 2000) 138 | 139 | if (ZonedDateTime.now() < now.plusSeconds(7)) { 140 | result1 == result2 141 | result3.payload.getInteger("random1") == result4.payload.getInteger("random1") 142 | } else { 143 | result1 != result2 144 | result3.payload.getInteger("random1") != result4.payload.getInteger("random1") 145 | } 146 | 147 | sleep(1000) 148 | } 149 | } 150 | 151 | def "different token should get different caches"() { 152 | setup: 153 | SimpleResponse result1 154 | SimpleResponse result2 155 | String token1 156 | String token2 157 | ZonedDateTime now = ZonedDateTime.now() 158 | 159 | when: 160 | requestUtils.get('localhost', 8002, '/login?sub=a&password=a' 161 | , new JsonObject()) { simpleResponse -> 162 | token1 = simpleResponse.payload.getString("token") 163 | 164 | requestUtils.requestWithJwtToken(HttpMethod.GET, 'localhost' 165 | , 8002, '/forward', new JsonObject(), token1) { response -> 166 | result1 = response 167 | } 168 | } 169 | requestUtils.get('localhost', 8002, '/login?sub=b&password=b' 170 | , new JsonObject()) { simpleResponse -> 171 | token2 = simpleResponse.payload.getString("token") 172 | 173 | requestUtils.requestWithJwtToken(HttpMethod.GET, 'localhost' 174 | , 8002, '/forward', new JsonObject(), token2) { response -> 175 | result2 = response 176 | } 177 | } 178 | TestUtils.waitResult(result1, 2000) 179 | TestUtils.waitResult(result2, 2000) 180 | 181 | then: 182 | token1 != token2 183 | result1 != result2 184 | 185 | 1.upto(8) { 186 | SimpleResponse result3 187 | SimpleResponse result4 188 | 189 | requestUtils.requestWithJwtToken(HttpMethod.GET, 'localhost', 8002 190 | , '/forward', new JsonObject(), token1) { response -> 191 | result3 = response 192 | } 193 | requestUtils.requestWithJwtToken(HttpMethod.GET, 'localhost', 8002 194 | , '/forward', new JsonObject(), token2) { response -> 195 | result4 = response 196 | } 197 | 198 | TestUtils.waitResult(result3, 2000) 199 | TestUtils.waitResult(result4, 2000) 200 | 201 | result3 != result4 202 | 203 | if (ZonedDateTime.now() < now.plusSeconds(7)) { 204 | result3 == result1 205 | result4 == result2 206 | } 207 | } 208 | } 209 | 210 | private static HttpServer createMock() { 211 | HttpServer httpServer = vertx.createHttpServer() 212 | Router router = Router.router(vertx) 213 | httpServer.requestHandler(router.&accept).listen(9001) 214 | 215 | router.route("/random1").handler(new MockHandler(vertx, 216 | new MockUrlConfig(expected: [ 217 | statusCode: 200, payload: { [random1: new Random().nextInt(100)] }] 218 | ) 219 | )) 220 | router.route("/random2").handler(new MockHandler(vertx, 221 | new MockUrlConfig(expected: [ 222 | statusCode: 200, payload: { [random2: new Random().nextInt(100) + 100] }] 223 | ) 224 | )) 225 | 226 | httpServer 227 | } 228 | 229 | private static HttpServer createDest() { 230 | HttpServer httpServer = vertx.createHttpServer() 231 | Router router = Router.router(vertx) 232 | httpServer.requestHandler(router.&accept).listen(9002) 233 | 234 | router.route('/login').handler { routingContext -> 235 | routingContext.request().bodyHandler { totalBuffer -> 236 | Utils.fireJsonResponse(routingContext.response(), 200 237 | , [sub : totalBuffer.toJsonObject().getString("sub"), 238 | password: totalBuffer.toJsonObject().getString("password")]) 239 | } 240 | } 241 | router.route().handler { routingContext -> 242 | routingContext.request().bodyHandler { totalBuffer -> 243 | JsonObject jwt = new JsonObject(requestUtils.getJwtHeader(routingContext.request())) 244 | JsonObject nameOfApiGateway = new JsonObject() 245 | .put("nameOfApiGateway", requestUtils.getAPIGatewayNameHeader(routingContext.request())) 246 | 247 | Utils.fireJsonResponse(routingContext.response(), 200, 248 | [method: routingContext.request().method(), 249 | params: totalBuffer.toJsonObject().mergeIn(jwt).mergeIn(nameOfApiGateway)]) 250 | } 251 | } 252 | 253 | httpServer 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/handler/CompositeRequestSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler 2 | 3 | import io.vertx.circuitbreaker.CircuitBreakerOptions 4 | import io.vertx.core.Vertx 5 | import io.vertx.core.http.HttpMethod 6 | import io.vertx.core.http.HttpServer 7 | import io.vertx.core.json.JsonObject 8 | import io.vertx.ext.web.Router 9 | import spock.lang.Specification 10 | import spock.lang.Unroll 11 | import top.dteam.dgate.config.ProxyUrlConfig 12 | import top.dteam.dgate.config.UpstreamURL 13 | import top.dteam.dgate.config.UrlConfig 14 | import top.dteam.dgate.gateway.SimpleResponse 15 | import top.dteam.dgate.utils.RequestUtils 16 | import top.dteam.dgate.utils.TestUtils 17 | import top.dteam.dgate.utils.Utils 18 | 19 | class CompositeRequestSpec extends Specification { 20 | 21 | private static final long OP_TIMEOUT = 1800 22 | private static final long RESET_TIMEOUT = 4000 23 | 24 | Vertx vertx 25 | HttpServer gate 26 | HttpServer dest 27 | RequestUtils requestUtils 28 | 29 | void setup() { 30 | vertx = Vertx.vertx() 31 | gate = createGate() 32 | dest = createDest() 33 | requestUtils = new RequestUtils(vertx) 34 | } 35 | 36 | void cleanup() { 37 | gate.close() 38 | dest.close() 39 | vertx.close() 40 | } 41 | 42 | @Unroll 43 | def "#method should work"() { 44 | setup: 45 | SimpleResponse result 46 | 47 | when: 48 | sleep(100) 49 | requestUtils."$method"("localhost", 8081, "/allSuccess", params) { simpleResponse -> 50 | result = simpleResponse 51 | } 52 | TestUtils.waitResult(result, 1500) 53 | 54 | then: 55 | result.statusCode == 200 56 | result.payload.toString() == new JsonObject([method1: httpMethod, params1: params, 57 | method2: httpMethod, params2: params]).toString() 58 | 59 | where: 60 | method | params | httpMethod 61 | "get" | new JsonObject([method: "get"]) | HttpMethod.GET 62 | "post" | new JsonObject([method: "post"]) | HttpMethod.POST 63 | "delete" | new JsonObject([method: "delete"]) | HttpMethod.DELETE 64 | } 65 | 66 | @Unroll 67 | def "[#url] should expect status code: #statusCode"() { 68 | setup: 69 | SimpleResponse result 70 | 71 | when: 72 | sleep(100) 73 | requestUtils."$method"("localhost", 8081, url, new JsonObject([method: method])) { simpleResponse -> 74 | result = simpleResponse 75 | } 76 | TestUtils.waitResult(result, 1500) 77 | 78 | then: 79 | result.statusCode == statusCode 80 | 81 | where: 82 | method | url | statusCode 83 | "get" | '/allSuccess' | 200 84 | "post" | '/allFailure' | 500 85 | "delete" | '/partialSuccess' | 206 86 | } 87 | 88 | @Unroll 89 | def "could set context of before and after closures for [#method]"() { 90 | setup: 91 | SimpleResponse result 92 | 93 | when: 94 | sleep(100) 95 | requestUtils."$method"("localhost", 8081, "/checkHandlerContext", params) { simpleResponse -> 96 | result = simpleResponse 97 | } 98 | TestUtils.waitResult(result, 1500) 99 | 100 | then: 101 | result.statusCode == 200 102 | result.payload.toString() == new JsonObject([method1: httpMethod, params1: params.mergeIn(new JsonObject([before: "before"])), 103 | method2: httpMethod, params2: params, after: "after"]).toString() 104 | 105 | where: 106 | method | params | httpMethod 107 | "get" | new JsonObject([method: "get"]) | HttpMethod.GET 108 | "post" | new JsonObject([method: "post"]) | HttpMethod.POST 109 | "delete" | new JsonObject([method: "delete"]) | HttpMethod.DELETE 110 | } 111 | 112 | 113 | private HttpServer createGate() { 114 | HttpServer httpServer = vertx.createHttpServer() 115 | Router router = Router.router(vertx) 116 | httpServer.requestHandler(router.&accept).listen(8081) 117 | 118 | router.route("/allSuccess").handler(new ProxyHandler(vertx, 119 | new ProxyUrlConfig( 120 | upstreamURLs: Arrays.asList( 121 | new UpstreamURL(host: "localhost", port: 8082, url: "/success1", 122 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 123 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT)), 124 | new UpstreamURL(host: "localhost", port: 8082, url: "/success2", 125 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 126 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT)) 127 | ) 128 | ))) 129 | router.route("/allFailure").handler(new ProxyHandler(vertx, 130 | new ProxyUrlConfig( 131 | upstreamURLs: Arrays.asList( 132 | new UpstreamURL(host: "localhost", port: 8082, url: "/failure1", 133 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 134 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT)), 135 | new UpstreamURL(host: "localhost", port: 8082, url: "/failure2", 136 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 137 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT)) 138 | ) 139 | ))) 140 | router.route("/partialSuccess").handler(new ProxyHandler(vertx, 141 | new ProxyUrlConfig( 142 | upstreamURLs: Arrays.asList( 143 | new UpstreamURL(host: "localhost", port: 8082, url: "/failure1", 144 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 145 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT)), 146 | new UpstreamURL(host: "localhost", port: 8082, url: "/success2", 147 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 148 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT)) 149 | ) 150 | ))) 151 | router.route("/checkHandlerContext").handler(new SubProxyHandler(vertx, 152 | new ProxyUrlConfig( 153 | upstreamURLs: Arrays.asList( 154 | new UpstreamURL(host: "localhost", port: 8082, url: "/success1", before: { params -> 155 | params.put("before", paramForBefore) 156 | params 157 | }, 158 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 159 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT)), 160 | new UpstreamURL(host: "localhost", port: 8082, url: "/success2", after: { simpleResponse -> 161 | simpleResponse.payload.put("after", paramForAfter) 162 | simpleResponse 163 | }, 164 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 165 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT)) 166 | ) 167 | ))) 168 | 169 | httpServer 170 | } 171 | 172 | private HttpServer createDest() { 173 | HttpServer httpServer = vertx.createHttpServer() 174 | Router router = Router.router(vertx) 175 | httpServer.requestHandler(router.&accept).listen(8082) 176 | 177 | router.route("/success1").handler { routingContext -> 178 | routingContext.request().bodyHandler { totalBuffer -> 179 | Utils.fireJsonResponse(routingContext.response(), 200, 180 | [method1: routingContext.request().method(), 181 | params1: totalBuffer.toJsonObject()]) 182 | } 183 | } 184 | 185 | router.route("/success2").handler { routingContext -> 186 | routingContext.request().bodyHandler { totalBuffer -> 187 | Utils.fireJsonResponse(routingContext.response(), 200, 188 | [method2: routingContext.request().method(), 189 | params2: totalBuffer.toJsonObject()]) 190 | } 191 | } 192 | 193 | router.route("/failure1").handler { routingContext -> 194 | routingContext.request().bodyHandler { totalBuffer -> 195 | Utils.fireJsonResponse(routingContext.response(), 500, 196 | [method1: routingContext.request().method(), 197 | params1: totalBuffer.toJsonObject()]) 198 | } 199 | } 200 | 201 | router.route("/failure2").handler { routingContext -> 202 | routingContext.request().bodyHandler { totalBuffer -> 203 | Utils.fireJsonResponse(routingContext.response(), 500, 204 | [method2: routingContext.request().method(), 205 | params2: totalBuffer.toJsonObject()]) 206 | } 207 | } 208 | 209 | 210 | 211 | httpServer 212 | } 213 | 214 | class SubProxyHandler extends ProxyHandler { 215 | 216 | SubProxyHandler(Vertx vertx, UrlConfig urlConfig) { 217 | super(vertx, urlConfig) 218 | } 219 | 220 | @Override 221 | protected Map createBeforeContext() { 222 | [paramForBefore: "before"] 223 | } 224 | 225 | @Override 226 | protected Map createAfterContext() { 227 | [paramForAfter: "after"] 228 | } 229 | } 230 | 231 | } 232 | -------------------------------------------------------------------------------- /src/main/java/top/dteam/dgate/handler/ProxyHandler.java: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler; 2 | 3 | import groovy.lang.Closure; 4 | import io.vertx.circuitbreaker.CircuitBreaker; 5 | import io.vertx.core.Vertx; 6 | import io.vertx.core.http.HttpServerRequest; 7 | import io.vertx.core.http.HttpServerResponse; 8 | import io.vertx.core.json.JsonObject; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | import top.dteam.dgate.config.ProxyUrlConfig; 12 | import top.dteam.dgate.config.UpstreamURL; 13 | import top.dteam.dgate.gateway.SimpleResponse; 14 | import top.dteam.dgate.utils.RequestUtils; 15 | import top.dteam.dgate.utils.Utils; 16 | import top.dteam.dgate.utils.cache.ResponseHolder; 17 | 18 | import java.util.ArrayList; 19 | import java.util.HashMap; 20 | import java.util.List; 21 | import java.util.Map; 22 | import java.util.concurrent.CompletableFuture; 23 | import java.util.concurrent.atomic.AtomicInteger; 24 | 25 | public class ProxyHandler extends RequestHandler { 26 | 27 | private static final Logger logger = LoggerFactory.getLogger(ProxyHandler.class); 28 | 29 | private List upstreamURLs; 30 | private Map circuitBreakers; 31 | private RequestUtils requestUtils; 32 | 33 | public ProxyHandler(Vertx vertx, ProxyUrlConfig urlConfig) { 34 | super(vertx, urlConfig); 35 | 36 | upstreamURLs = urlConfig.getUpstreamURLs(); 37 | requestUtils = new RequestUtils(vertx); 38 | 39 | circuitBreakers = new HashMap<>(); 40 | upstreamURLs.forEach(upStreamURL -> { 41 | if (upStreamURL.getCircuitBreaker() != null) { 42 | circuitBreakers.put(upStreamURL.toString(), 43 | CircuitBreaker.create(String.format("cb-%s-%s", urlConfig.getUrl(), 44 | upStreamURL.toString()), vertx, upStreamURL.getCircuitBreaker())); 45 | } else { 46 | circuitBreakers.put(upStreamURL.toString(), 47 | CircuitBreaker.create(String.format("cb-%s-%s", urlConfig.getUrl(), 48 | upStreamURL.toString()), vertx)); 49 | } 50 | } 51 | ); 52 | } 53 | 54 | @Override 55 | protected void processRequestBody(HttpServerRequest request, HttpServerResponse response, JsonObject body) { 56 | List> completableFutures = new ArrayList<>(); 57 | upstreamURLs.forEach(upStreamURL -> { 58 | CompletableFuture completableFuture = new CompletableFuture<>(); 59 | partialRequest(request, upStreamURL, body, completableFuture); 60 | completableFutures.add(completableFuture); 61 | }); 62 | 63 | List statusCodes = new ArrayList<>(); 64 | AtomicInteger count = new AtomicInteger(completableFutures.size()); 65 | JsonObject payload = new JsonObject(); 66 | for (int i = 0; i < completableFutures.size(); i++) { 67 | UpstreamURL upStream = upstreamURLs.get(i); 68 | completableFutures.get(i).thenAccept(simpleResponse -> { 69 | payload.mergeIn(simpleResponse.getPayload()); 70 | statusCodes.add(simpleResponse.getStatusCode()); 71 | if (count.decrementAndGet() == 0) { 72 | Utils.fireJsonResponse(response, finalStatusCode(statusCodes) 73 | , payload.getMap()); 74 | } 75 | }).exceptionally(throwable -> { 76 | payload.put(upStream.toString(), throwable); 77 | statusCodes.add(500); 78 | if (count.decrementAndGet() == 0) { 79 | Utils.fireJsonResponse(response, finalStatusCode(statusCodes), payload.getMap()); 80 | } 81 | return null; 82 | }); 83 | } 84 | } 85 | 86 | private void partialRequest(HttpServerRequest clientRequest, UpstreamURL upstreamURL, JsonObject params, 87 | CompletableFuture completableFuture) { 88 | try { 89 | String requestURI = upstreamURL.resolve(params); 90 | 91 | if (isResponseCached(requestURI, upstreamURL, params.getJsonObject("token"))) { 92 | logger.info("Found response cache for {}{}{}" 93 | , nameOfApiGateway, urlConfig.getUrl(), requestURI); 94 | SimpleResponse simpleResponse = new SimpleResponse(); 95 | simpleResponse.setStatusCode(200); 96 | simpleResponse.setPayload(getResponseFromCache(requestURI 97 | , upstreamURL, params.getJsonObject("token"))); 98 | 99 | completableFuture.complete(simpleResponse); 100 | 101 | return; 102 | } 103 | 104 | CircuitBreaker circuitBreaker = circuitBreakers.get(upstreamURL.toString()); 105 | circuitBreaker.execute(future -> { 106 | Map beforeContext = createBeforeContext(); 107 | if (upstreamURL.getBefore() != null && beforeContext != null) { 108 | upstreamURL.getBefore().setDelegate(beforeContext); 109 | } 110 | requestUtils.request(clientRequest.method(), 111 | upstreamURL.getHost(), upstreamURL.getPort(), requestURI, 112 | processParamsIfBeforeHandlerExists(upstreamURL.getBefore(), params), clientRequest, 113 | simpleResponse -> { 114 | Map afterContext = createAfterContext(); 115 | if (upstreamURL.getAfter() != null && afterContext != null) { 116 | upstreamURL.getAfter().setDelegate(afterContext); 117 | } 118 | future.complete(processResponseIfAfterHandlerExists(upstreamURL.getAfter(), simpleResponse)); 119 | }); 120 | }).setHandler(result -> { 121 | if (result.succeeded()) { 122 | SimpleResponse simpleResponse = (SimpleResponse) result.result(); 123 | completableFuture.complete(simpleResponse); 124 | 125 | if (upstreamURL.getExpires() > 0) { 126 | logger.info("Put response cache for {}/{}{}" 127 | , nameOfApiGateway, urlConfig.getUrl(), requestURI); 128 | putResponseToCache(requestURI, upstreamURL 129 | , params.getJsonObject("token") 130 | , simpleResponse.getPayload() 131 | , upstreamURL.getExpires()); 132 | } 133 | } else { 134 | logger.error("CB[{}] execution failed, cause: ", circuitBreaker.name(), result.cause()); 135 | 136 | SimpleResponse simpleResponse = new SimpleResponse(); 137 | JsonObject error = new JsonObject(); 138 | error.put("error", result.cause().getMessage()); 139 | simpleResponse.setPayload(error); 140 | simpleResponse.setStatusCode(500); 141 | 142 | completableFuture.complete(simpleResponse); 143 | } 144 | }); 145 | } catch (Exception e) { 146 | logger.error("Request to upstream failed: ", e); 147 | 148 | SimpleResponse simpleResponse = new SimpleResponse(); 149 | JsonObject error = new JsonObject(); 150 | error.put("error", e.getMessage()); 151 | simpleResponse.setPayload(error); 152 | simpleResponse.setStatusCode(500); 153 | 154 | completableFuture.complete(simpleResponse); 155 | } 156 | } 157 | 158 | protected Map createBeforeContext() { 159 | return null; 160 | } 161 | 162 | protected Map createAfterContext() { 163 | return null; 164 | } 165 | 166 | private JsonObject processParamsIfBeforeHandlerExists(Closure before, JsonObject defaultValue) { 167 | JsonObject params = defaultValue; 168 | if (before != null) { 169 | try { 170 | params = before.call(params); 171 | } catch (Exception e) { 172 | logger.error("Before handler got exception: ", e); 173 | throw e; 174 | } 175 | } 176 | return params; 177 | } 178 | 179 | private SimpleResponse processResponseIfAfterHandlerExists(Closure after, SimpleResponse defaultValue) { 180 | SimpleResponse result = defaultValue; 181 | if (after != null) { 182 | try { 183 | result = after.call(result); 184 | } catch (Exception e) { 185 | logger.error("After handler got exception: ", e); 186 | throw e; 187 | } 188 | } 189 | return result; 190 | } 191 | 192 | private int finalStatusCode(List statusCodes) { 193 | if (statusCodes.stream().allMatch(statusCode -> statusCode >= 200 && statusCode < 300)) { 194 | return 200; 195 | } else if (statusCodes.stream().allMatch(statusCode -> statusCode >= 400)) { 196 | return 500; 197 | } else { 198 | return 206; 199 | } 200 | } 201 | 202 | private void putResponseToCache(String requestURI, UpstreamURL upstreamURL 203 | , JsonObject token, JsonObject payload, int expires) { 204 | ResponseHolder.put(nameOfApiGateway, urlConfig.getUrl() 205 | , upstreamURL.getHost(), upstreamURL.getPort(), upstreamURL.getUrl() 206 | , requestURI, token, payload, expires); 207 | } 208 | 209 | private boolean isResponseCached(String requestURI 210 | , UpstreamURL upstreamURL, JsonObject token) { 211 | return upstreamURL.getExpires() > 0 && 212 | ResponseHolder.containsCacheEntry(nameOfApiGateway, urlConfig.getUrl() 213 | , upstreamURL.getHost(), upstreamURL.getPort() 214 | , upstreamURL.getUrl(), requestURI, token); 215 | } 216 | 217 | private JsonObject getResponseFromCache(String requestURI 218 | , UpstreamURL upstreamURL, JsonObject token) { 219 | return ResponseHolder.get(nameOfApiGateway, urlConfig.getUrl() 220 | , upstreamURL.getHost(), upstreamURL.getPort() 221 | , upstreamURL.getUrl(), requestURI, token); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/gateway/ApiGatewaySpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.gateway 2 | 3 | import io.vertx.core.Vertx 4 | import io.vertx.core.http.HttpMethod 5 | import io.vertx.core.http.HttpServer 6 | import io.vertx.core.json.JsonObject 7 | import io.vertx.ext.auth.jwt.JWTAuth 8 | import io.vertx.ext.web.Router 9 | import spock.lang.Specification 10 | import spock.lang.Unroll 11 | import top.dteam.dgate.config.* 12 | import top.dteam.dgate.utils.JWTTokenGenerator 13 | import top.dteam.dgate.utils.RequestUtils 14 | import top.dteam.dgate.utils.TestUtils 15 | import top.dteam.dgate.utils.Utils 16 | 17 | class ApiGatewaySpec extends Specification { 18 | 19 | private static final int GATEWAY_PORT = 7000 20 | private static final int GATEWAY_PORT_WITH_LOGIN = 7001 21 | private static final int GATEWAY_PORT_WITH_LOGIN_IGNORE = 7002 22 | private static final int GATEWAY_PORT_WITH_LOGIN_ONLY = 7003 23 | 24 | Vertx vertx 25 | HttpServer destServer 26 | RequestUtils requestUtils 27 | 28 | void setup() { 29 | vertx = Vertx.vertx() 30 | destServer = createDestServer() 31 | requestUtils = new RequestUtils(vertx) 32 | vertx.deployVerticle(new ApiGateway(prepareConfig())) 33 | vertx.deployVerticle(new ApiGateway(prepareConfigWithMockLogin())) 34 | vertx.deployVerticle(new ApiGateway(prepareConfigWithLoginConfigConatainingIgnore())) 35 | vertx.deployVerticle(new ApiGateway(prepareConfigWithLoginConfigConatainingOnly())) 36 | } 37 | 38 | void cleanup() { 39 | vertx.close() 40 | destServer.close() 41 | } 42 | 43 | @Unroll 44 | def "[#url] should work"() { 45 | setup: 46 | SimpleResponse result 47 | 48 | when: 49 | sleep(100) 50 | requestUtils.get("localhost", GATEWAY_PORT, url, new JsonObject([method: "get"])) { simpleResponse -> 51 | result = simpleResponse 52 | } 53 | TestUtils.waitResult(result, 1500) 54 | 55 | then: 56 | result.statusCode == statusCode 57 | result.payload.toString() == new JsonObject(response).toString() 58 | 59 | where: 60 | url | statusCode | response 61 | '/mock' | prepareConfig().urlConfigs[0].expected.statusCode | prepareConfig().urlConfigs[0].expected.payload 62 | '/mock-get' | 200 | [method: 'get'] 63 | '/forward' | 200 | [method1: HttpMethod.GET, params1: [method: "get", nameOfApiGateway: "testGateway"]] 64 | '/composite' | 200 | [method1: HttpMethod.GET, params1: [method: "get", nameOfApiGateway: "testGateway"], 65 | method2: HttpMethod.GET, params2: [method: "get", nameOfApiGateway: "testGateway"]] 66 | } 67 | 68 | def "could mock login and get a jwt token"() { 69 | setup: 70 | SimpleResponse result 71 | 72 | when: 73 | sleep(100) 74 | requestUtils.get("localhost", GATEWAY_PORT_WITH_LOGIN, '/login', new JsonObject()) { simpleResponse -> 75 | result = simpleResponse 76 | } 77 | TestUtils.waitResult(result, 1500) 78 | 79 | then: 80 | result.statusCode == 200 81 | result.payload.getString('token').split(/\./).size() == 3 82 | } 83 | 84 | @Unroll 85 | def "could only access those urls in ignore list in login block directly: #url"() { 86 | setup: 87 | SimpleResponse result 88 | 89 | when: 90 | sleep(100) 91 | requestUtils.get("localhost", GATEWAY_PORT_WITH_LOGIN_IGNORE, url, new JsonObject()) { simpleResponse -> 92 | result = simpleResponse 93 | } 94 | TestUtils.waitResult(result, 1500) 95 | 96 | then: 97 | result.statusCode == statusCode 98 | 99 | where: 100 | url | statusCode 101 | '/mock-ignore' | 200 102 | '/mock' | 401 103 | '/login' | 200 104 | } 105 | 106 | @Unroll 107 | def "could only access those urls not in only list in login block directly: #url"() { 108 | setup: 109 | SimpleResponse result 110 | 111 | when: 112 | sleep(100) 113 | requestUtils.get("localhost", GATEWAY_PORT_WITH_LOGIN_ONLY, url, new JsonObject()) { simpleResponse -> 114 | result = simpleResponse 115 | } 116 | TestUtils.waitResult(result, 1500) 117 | 118 | then: 119 | result.statusCode == statusCode 120 | 121 | where: 122 | url | statusCode 123 | '/mock-only' | 401 124 | '/mock' | 200 125 | '/login' | 200 126 | } 127 | 128 | private HttpServer createDestServer() { 129 | HttpServer httpServer = vertx.createHttpServer() 130 | Router destRouter = Router.router(vertx) 131 | 132 | destRouter.route("/test1").handler { routingContext -> 133 | routingContext.request().bodyHandler { totalBuffer -> 134 | Utils.fireJsonResponse(routingContext.response(), 200, 135 | [method1: routingContext.request().method(), 136 | params1: totalBuffer.toJsonObject()]) 137 | } 138 | } 139 | 140 | destRouter.route("/test2").handler { routingContext -> 141 | routingContext.request().bodyHandler { totalBuffer -> 142 | Utils.fireJsonResponse(routingContext.response(), 200, 143 | [method2: routingContext.request().method(), 144 | params2: totalBuffer.toJsonObject()]) 145 | } 146 | } 147 | 148 | httpServer.requestHandler(destRouter.&accept).listen(8082) 149 | 150 | httpServer 151 | } 152 | 153 | private ApiGatewayConfig prepareConfig() { 154 | new ApiGatewayConfig( 155 | name: 'testGateway', 156 | port: GATEWAY_PORT, 157 | urlConfigs: [new MockUrlConfig(url: "/mock", expected: [statusCode: 200, 158 | payload : [ 159 | eqLocations : [], 160 | opRateInLast30Days: [], 161 | myOrgs : [ 162 | [ 163 | "name" : "org1", 164 | "admin": false 165 | ] 166 | ] 167 | ]]), 168 | new MockUrlConfig(url: "/mock-get", expected: [get: [statusCode: 200, payload: [method: 'get']]]), 169 | new ProxyUrlConfig(url: "/forward", upstreamURLs: [ 170 | new UpstreamURL(host: "localhost", port: 8082, url: "/test1")]), 171 | new ProxyUrlConfig(url: "/composite", upstreamURLs: [ 172 | new UpstreamURL(host: "localhost", port: 8082, url: "/test1"), 173 | new UpstreamURL(host: "localhost", port: 8082, url: "/test2")])] 174 | ) 175 | } 176 | 177 | private ApiGatewayConfig prepareConfigWithMockLogin() { 178 | new ApiGatewayConfig( 179 | name: 'testGateway', 180 | port: GATEWAY_PORT_WITH_LOGIN, 181 | login: new LoginConfig('/login'), 182 | urlConfigs: [new MockUrlConfig(url: "/login", 183 | expected: [statusCode: 200, 184 | payload : { 185 | JWTAuth jwtAuth = Utils.createAuthProvider(vertx) 186 | JWTTokenGenerator tokenGenerator = new JWTTokenGenerator(jwtAuth) 187 | [token: tokenGenerator.token(["sub" : "13572209183", "name": "foxgem", 188 | "role": "normal"], 200)] 189 | }() 190 | ])] 191 | ) 192 | } 193 | 194 | private ApiGatewayConfig prepareConfigWithLoginConfigConatainingIgnore() { 195 | new ApiGatewayConfig( 196 | name: 'testGateway', 197 | port: GATEWAY_PORT_WITH_LOGIN_IGNORE, 198 | login: new LoginConfig([url: '/login', ignore: ['/mock-ignore']]), 199 | urlConfigs: [new MockUrlConfig(url: "/mock-ignore", expected: [statusCode: 200, 200 | payload : [ignore: true]]), 201 | new MockUrlConfig(url: "/mock", expected: [statusCode: 200, 202 | payload : [ignore: false]]), 203 | new MockUrlConfig(url: "/login", 204 | expected: [statusCode: 200, 205 | payload : { 206 | JWTAuth jwtAuth = Utils.createAuthProvider(vertx) 207 | JWTTokenGenerator tokenGenerator = new JWTTokenGenerator(jwtAuth) 208 | [token: tokenGenerator.token(["sub" : "13572209183", "name": "foxgem", 209 | "role": "normal"], 200)] 210 | }()]) 211 | ] 212 | ) 213 | } 214 | 215 | private ApiGatewayConfig prepareConfigWithLoginConfigConatainingOnly() { 216 | new ApiGatewayConfig( 217 | name: 'testGateway', 218 | port: GATEWAY_PORT_WITH_LOGIN_ONLY, 219 | login: new LoginConfig([url: '/login', only: ['/mock-only']]), 220 | urlConfigs: [new MockUrlConfig(url: "/login", 221 | expected: [statusCode: 200, 222 | payload : { 223 | JWTAuth jwtAuth = Utils.createAuthProvider(vertx) 224 | JWTTokenGenerator tokenGenerator = new JWTTokenGenerator(jwtAuth) 225 | [token: tokenGenerator.token(["sub" : "13572209183", "name": "foxgem", 226 | "role": "normal"], 200)] 227 | }()]), 228 | new MockUrlConfig(url: "/mock-only", expected: [statusCode: 200, 229 | payload : [only: true]]), 230 | new MockUrlConfig(url: "/mock", expected: [statusCode: 200, 231 | payload : [only: false]]), 232 | ] 233 | ) 234 | } 235 | 236 | } 237 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/test/groovy/top/dteam/dgate/handler/ForwardRequestSpec.groovy: -------------------------------------------------------------------------------- 1 | package top.dteam.dgate.handler 2 | 3 | import io.vertx.circuitbreaker.CircuitBreakerOptions 4 | import io.vertx.core.Vertx 5 | import io.vertx.core.http.HttpMethod 6 | import io.vertx.core.http.HttpServer 7 | import io.vertx.core.json.JsonObject 8 | import io.vertx.ext.web.Router 9 | import spock.lang.Specification 10 | import spock.lang.Unroll 11 | import top.dteam.dgate.config.ProxyUrlConfig 12 | import top.dteam.dgate.config.UpstreamURL 13 | import top.dteam.dgate.gateway.SimpleResponse 14 | import top.dteam.dgate.utils.RequestUtils 15 | import top.dteam.dgate.utils.TestUtils 16 | import top.dteam.dgate.utils.Utils 17 | 18 | class ForwardRequestSpec extends Specification { 19 | 20 | private static final long OP_TIMEOUT = 1800 21 | private static final long RESET_TIMEOUT = 4000 22 | 23 | Vertx vertx 24 | HttpServer gate 25 | HttpServer dest 26 | RequestUtils requestUtils 27 | 28 | void setup() { 29 | vertx = Vertx.vertx() 30 | gate = createGate() 31 | dest = createDest() 32 | requestUtils = new RequestUtils(vertx) 33 | } 34 | 35 | void cleanup() { 36 | gate.close() 37 | dest.close() 38 | vertx.close() 39 | } 40 | 41 | @Unroll 42 | def "#method should be forwarded correctly"() { 43 | setup: 44 | SimpleResponse result 45 | 46 | when: 47 | sleep(100) 48 | requestUtils."$method"("localhost", 8081, "/withoutHandler", params) { simpleResponse -> 49 | result = simpleResponse 50 | } 51 | TestUtils.waitResult(result, 1500) 52 | 53 | then: 54 | result.statusCode == 200 55 | result.payload.toString() == new JsonObject([method: httpMethod, params: params]).toString() 56 | 57 | where: 58 | method | params | httpMethod 59 | "get" | new JsonObject([method: "get"]) | HttpMethod.GET 60 | "post" | new JsonObject([method: "post"]) | HttpMethod.POST 61 | "delete" | new JsonObject([method: "delete"]) | HttpMethod.DELETE 62 | } 63 | 64 | def "should get 400【Bad Request】for unsupported method"() { 65 | setup: 66 | SimpleResponse result 67 | 68 | when: 69 | sleep(100) 70 | requestUtils.request(HttpMethod.PUT, "localhost", 8081, "/withoutHandler", new JsonObject()) { simpleResponse -> 71 | result = simpleResponse 72 | } 73 | TestUtils.waitResult(result, 1500) 74 | 75 | then: 76 | result.statusCode == 400 77 | } 78 | 79 | def "should get 500 for 'operation timeout'"() { 80 | setup: 81 | SimpleResponse result 82 | 83 | when: 84 | sleep(100) 85 | requestUtils.post("localhost", 8081, "/timeout", new JsonObject()) { simpleResponse -> 86 | result = simpleResponse 87 | } 88 | TestUtils.waitResult(result, OP_TIMEOUT + 500) 89 | 90 | then: 91 | result.statusCode == 500 92 | result.payload.map.error == "operation timeout" 93 | } 94 | 95 | def "should return immediately if Circuit Breaker is opened"() { 96 | setup: 97 | SimpleResponse result 98 | requestsMakingCBOpen() 99 | 100 | when: 101 | sleep(2000) 102 | requestUtils.post("localhost", 8081, "/unknown", new JsonObject()) { simpleResponse -> 103 | result = simpleResponse 104 | } 105 | TestUtils.waitResult(result, OP_TIMEOUT - 500) 106 | 107 | then: 108 | result.statusCode == 500 109 | result.payload.map.error == "open circuit" 110 | } 111 | 112 | @Unroll 113 | def "before handler and after handler in UpstreamURL should work. (#method)"() { 114 | setup: 115 | SimpleResponse result 116 | 117 | when: 118 | sleep(100) 119 | requestUtils."$method"("localhost", 8081, "/withHandler", params) { simpleResponse -> 120 | result = simpleResponse 121 | } 122 | TestUtils.waitResult(result, 1500) 123 | 124 | then: 125 | result.statusCode == 200 126 | result.payload.toString() == new JsonObject([method : httpMethod, 127 | params : params.mergeIn(new JsonObject([addedByBefore: 'addedByBefore'])), 128 | addedByAfter: 'addedByAfter']).toString() 129 | 130 | where: 131 | method | params | httpMethod 132 | "get" | new JsonObject([method: "get"]) | HttpMethod.GET 133 | "post" | new JsonObject([method: "post"]) | HttpMethod.POST 134 | "delete" | new JsonObject([method: "delete"]) | HttpMethod.DELETE 135 | } 136 | 137 | @Unroll 138 | def "url template in UpstreamURL should work: #url(#params)"() { 139 | setup: 140 | SimpleResponse result 141 | 142 | when: 143 | sleep(100) 144 | requestUtils."$method"("localhost", 8081, url, params) { simpleResponse -> 145 | result = simpleResponse 146 | } 147 | TestUtils.waitResult(result, 1500) 148 | 149 | then: 150 | result.statusCode == 200 151 | result.payload.toString() == new JsonObject([method: httpMethod, 152 | uri : uri]).toString() 153 | 154 | where: 155 | method | url | params | httpMethod | uri 156 | "get" | '/url-template?x=1&&y=2&&z=3' | new JsonObject([:]) | HttpMethod.GET | '/url-template/1/2/3' 157 | "get" | '/url-template?x=1&&y=2' | new JsonObject([z: 3]) | HttpMethod.GET | '/url-template/1/2/3' 158 | "post" | '/url-template' | new JsonObject([x: 1, y: 2]) | HttpMethod.POST | '/url-template/1/2' 159 | "delete" | '/url-template' | new JsonObject([x: 1]) | HttpMethod.DELETE | '/url-template/1' 160 | } 161 | 162 | @Unroll 163 | def "should get 500 if pathParams required by upstream not exist: #url(#params)"() { 164 | setup: 165 | SimpleResponse result 166 | 167 | when: 168 | sleep(100) 169 | requestUtils.get("localhost", 8081, '/url-template', new JsonObject()) { simpleResponse -> 170 | result = simpleResponse 171 | } 172 | TestUtils.waitResult(result, 1500) 173 | 174 | then: 175 | result.statusCode == 500 176 | } 177 | 178 | def "should support pathParams"() { 179 | setup: 180 | SimpleResponse result 181 | 182 | when: 183 | sleep(100) 184 | requestUtils.get("localhost", 8081, '/path-params/1', new JsonObject()) { simpleResponse -> 185 | result = simpleResponse 186 | } 187 | TestUtils.waitResult(result, 1500) 188 | 189 | then: 190 | result.statusCode == 200 191 | result.payload.toString() == new JsonObject([method: HttpMethod.GET, params: [id: '1']]).toString() 192 | } 193 | 194 | private HttpServer createGate() { 195 | HttpServer httpServer = vertx.createHttpServer() 196 | Router router = Router.router(vertx) 197 | httpServer.requestHandler(router.&accept).listen(8081) 198 | 199 | router.route("/withoutHandler").handler(new ProxyHandler(vertx, 200 | new ProxyUrlConfig(upstreamURLs: [new UpstreamURL(host: "localhost", port: 8082, url: "/normal", 201 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 202 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT))], 203 | methods: [HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE]))) 204 | router.route("/timeout").handler(new ProxyHandler(vertx, 205 | new ProxyUrlConfig(upstreamURLs: [new UpstreamURL(host: "localhost", port: 8082, url: "/timeout", 206 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 207 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT))], 208 | methods: [HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE]))) 209 | router.route("/withHandler").handler(new ProxyHandler(vertx, 210 | new ProxyUrlConfig(upstreamURLs: [ 211 | new UpstreamURL(host: "localhost", port: 8082, url: "/normal", 212 | before: { jsonObject -> 213 | jsonObject.put('addedByBefore', 'addedByBefore') 214 | jsonObject 215 | }, 216 | after: { simpleResponse -> 217 | simpleResponse.payload.put('addedByAfter', 'addedByAfter') 218 | simpleResponse 219 | }, 220 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 221 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT))], 222 | methods: [HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE]))) 223 | router.route("/unknown").handler(new ProxyHandler(vertx, 224 | new ProxyUrlConfig(upstreamURLs: [new UpstreamURL(host: "localhost", port: 8082, url: "/unknown", 225 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 226 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT))], 227 | methods: [HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE]))) 228 | router.route("/url-template").handler(new ProxyHandler(vertx, 229 | new ProxyUrlConfig(upstreamURLs: [new UpstreamURL(host: "localhost", port: 8082, url: "/url-template/:x/:y?/:z?", 230 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 231 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT))], 232 | methods: [HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE]))) 233 | router.route("/path-params/:id").handler(new ProxyHandler(vertx, 234 | new ProxyUrlConfig(upstreamURLs: [new UpstreamURL(host: "localhost", port: 8082, url: "/normal", 235 | circuitBreaker: new CircuitBreakerOptions().setMaxFailures(3) 236 | .setTimeout(OP_TIMEOUT).setResetTimeout(RESET_TIMEOUT))]))) 237 | httpServer 238 | } 239 | 240 | private HttpServer createDest() { 241 | HttpServer httpServer = vertx.createHttpServer() 242 | Router router = Router.router(vertx) 243 | httpServer.requestHandler(router.&accept).listen(8082) 244 | 245 | router.route("/normal").handler { routingContext -> 246 | routingContext.request().bodyHandler { totalBuffer -> 247 | Utils.fireJsonResponse(routingContext.response(), 200, 248 | [method: routingContext.request().method(), 249 | params: totalBuffer.toJsonObject()]) 250 | } 251 | } 252 | router.route("/timeout").handler { routingContext -> 253 | routingContext.request().bodyHandler { totalBuffer -> 254 | sleep(OP_TIMEOUT + 200) 255 | Utils.fireJsonResponse(routingContext.response(), 200, 256 | [method: routingContext.request().method(), 257 | params: totalBuffer.toJsonObject()]) 258 | } 259 | } 260 | router.route().pathRegex("/url-template/.*").handler { routingContext -> 261 | routingContext.request().bodyHandler { totalBuffer -> 262 | Utils.fireJsonResponse(routingContext.response(), 200, 263 | [method: routingContext.request().method(), 264 | uri : routingContext.request().uri()]) 265 | } 266 | } 267 | 268 | httpServer 269 | } 270 | 271 | private void requestsMakingCBOpen() { 272 | requestUtils.post("localhost", 8081, "/unknown", new JsonObject()) { simpleResponse -> } 273 | requestUtils.post("localhost", 8081, "/unknown", new JsonObject()) { simpleResponse -> } 274 | requestUtils.post("localhost", 8081, "/unknown", new JsonObject()) { simpleResponse -> } 275 | } 276 | } 277 | --------------------------------------------------------------------------------