You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@shardingsphere.apache.org by te...@apache.org on 2020/08/25 11:04:22 UTC

[shardingsphere-elasticjob] branch restful-api updated: Improve trailing slash handling (#1408)

This is an automated email from the ASF dual-hosted git repository.

technoboy pushed a commit to branch restful-api
in repository https://gitbox.apache.org/repos/asf/shardingsphere-elasticjob.git


The following commit(s) were added to refs/heads/restful-api by this push:
     new f306017  Improve trailing slash handling (#1408)
f306017 is described below

commit f3060171ba974604d18aafcadf414a1e5471c512
Author: 吴伟杰 <ro...@me.com>
AuthorDate: Tue Aug 25 19:04:10 2020 +0800

    Improve trailing slash handling (#1408)
---
 elasticjob-infra/elasticjob-restful/pom.xml        |  13 ++-
 .../restful/NettyRestfulServiceConfiguration.java  |   6 ++
 .../elasticjob/restful/annotation/Mapping.java     |   2 +-
 .../restful/mapping/RegexPathMatcher.java          |   2 +-
 .../restful/pipeline/HandlerParameterDecoder.java  |   4 -
 .../restful/pipeline/HttpRequestDispatcher.java    |  30 +++++-
 .../pipeline/RestfulServiceChannelInitializer.java |   2 +-
 .../elasticjob/restful/RegexPathMatcherTest.java   |   5 +
 .../restful/controller/IndexController.java}       |  35 +++---
 .../controller/TrailingSlashTestController.java    |  50 +++++++++
 .../pipeline/HandlerParameterDecoderTest.java      | 117 +++++++++++++++++++++
 .../elasticjob/restful/pipeline/HttpClient.java    |   8 +-
 .../pipeline/HttpRequestDispatcherTest.java        |   2 +-
 .../restful/pipeline/NettyRestfulServiceTest.java  |  41 ++++++--
 ...RestfulServiceTrailingSlashInsensitiveTest.java |  40 +++++++
 ...tyRestfulServiceTrailingSlashSensitiveTest.java |  85 +++++++++++++++
 .../CustomTextPlainResponseBodySerializer.java     |  44 ++++++++
 ...icjob.restful.serializer.ResponseBodySerializer |  18 ++++
 18 files changed, 457 insertions(+), 47 deletions(-)

diff --git a/elasticjob-infra/elasticjob-restful/pom.xml b/elasticjob-infra/elasticjob-restful/pom.xml
index fad0128..87266a7 100644
--- a/elasticjob-infra/elasticjob-restful/pom.xml
+++ b/elasticjob-infra/elasticjob-restful/pom.xml
@@ -55,5 +55,16 @@
             <scope>test</scope>
         </dependency>
     </dependencies>
-
+    <build>
+        <resources>
+            <resource>
+                <directory>src/main/resources</directory>
+            </resource>
+        </resources>
+        <testResources>
+            <testResource>
+                <directory>src/test/resources</directory>
+            </testResource>
+        </testResources>
+    </build>
 </project>
diff --git a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/NettyRestfulServiceConfiguration.java b/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/NettyRestfulServiceConfiguration.java
index d40bfcc..bc35204 100644
--- a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/NettyRestfulServiceConfiguration.java
+++ b/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/NettyRestfulServiceConfiguration.java
@@ -41,6 +41,12 @@ public final class NettyRestfulServiceConfiguration {
     @Setter
     private String host;
     
+    /**
+     * If trailing slash sensitive, <code>/foo/bar</code> is not equals to <code>/foo/bar/</code>.
+     */
+    @Setter
+    private boolean trailingSlashSensitive;
+    
     private final List<RestfulController> controllerInstances = new ArrayList<>();
     
     private final Map<Class<? extends Throwable>, ExceptionHandler<? extends Throwable>> exceptionHandlers = new HashMap<>();
diff --git a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/annotation/Mapping.java b/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/annotation/Mapping.java
index ee23a16..0d0ac90 100644
--- a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/annotation/Mapping.java
+++ b/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/annotation/Mapping.java
@@ -37,7 +37,7 @@ public @interface Mapping {
     String method();
     
     /**
-     * Path pattern of this handler.
+     * Path pattern of this handler. Starts with '/'.
      * Such as <code>/app/{jobName}/enable</code>.
      *
      * @return Path pattern
diff --git a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/mapping/RegexPathMatcher.java b/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/mapping/RegexPathMatcher.java
index 55e29a2..53c2e55 100644
--- a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/mapping/RegexPathMatcher.java
+++ b/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/mapping/RegexPathMatcher.java
@@ -33,7 +33,7 @@ public final class RegexPathMatcher implements PathMatcher {
     
     private static final String PATH_SEPARATOR = "/";
     
-    private static final Pattern PATH_PATTERN = Pattern.compile("^(?:/|(/[^/{}?]+|/\\{[^/{}?]+})+)$");
+    private static final Pattern PATH_PATTERN = Pattern.compile("^/(([^/{}]+|\\{[^/{}]+})(/([^/{}]+|\\{[^/{}]+}))*/?)?$");
     
     private static final Pattern TEMPLATE_PATTERN = Pattern.compile("\\{(?<template>[^/]+)}");
     
diff --git a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HandlerParameterDecoder.java b/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HandlerParameterDecoder.java
index 1fd80f7..0616141 100644
--- a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HandlerParameterDecoder.java
+++ b/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HandlerParameterDecoder.java
@@ -22,7 +22,6 @@ import io.netty.buffer.ByteBufUtil;
 import io.netty.channel.ChannelHandler.Sharable;
 import io.netty.channel.ChannelHandlerContext;
 import io.netty.channel.ChannelInboundHandlerAdapter;
-import io.netty.handler.codec.UnsupportedMessageTypeException;
 import io.netty.handler.codec.http.FullHttpRequest;
 import io.netty.handler.codec.http.HttpUtil;
 import io.netty.handler.codec.http.QueryStringDecoder;
@@ -102,9 +101,6 @@ public final class HandlerParameterDecoder extends ChannelInboundHandlerAdapter
                     String mimeType = Optional.ofNullable(HttpUtil.getMimeType(httpRequest))
                             .orElseGet(() -> HttpUtil.getMimeType(Http.DEFAULT_CONTENT_TYPE)).toString();
                     RequestBodyDeserializer deserializer = RequestBodyDeserializerFactory.getRequestBodyDeserializer(mimeType);
-                    if (null == deserializer) {
-                        throw new UnsupportedMessageTypeException(MessageFormat.format("Unsupported MIME type [{0}]", mimeType));
-                    }
                     Object parsedBodyValue = deserializer.deserialize(targetType, bytes);
                     parsedValue = parsedBodyValue;
                     Preconditions.checkArgument(nullable || null != parsedBodyValue, "Missing request body");
diff --git a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HttpRequestDispatcher.java b/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HttpRequestDispatcher.java
index 0fa26f1..52a8e2e 100644
--- a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HttpRequestDispatcher.java
+++ b/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HttpRequestDispatcher.java
@@ -44,9 +44,14 @@ import java.util.Optional;
 @Slf4j
 public final class HttpRequestDispatcher extends ChannelInboundHandlerAdapter {
     
+    private static final String TRAILING_SLASH = "/";
+    
     private final HandlerMappingRegistry mappingRegistry = new HandlerMappingRegistry();
     
-    public HttpRequestDispatcher(final List<RestfulController> restfulControllers) {
+    private final boolean trailingSlashSensitive;
+    
+    public HttpRequestDispatcher(final List<RestfulController> restfulControllers, final boolean trailingSlashSensitive) {
+        this.trailingSlashSensitive = trailingSlashSensitive;
         initMappingRegistry(restfulControllers);
     }
     
@@ -54,6 +59,9 @@ public final class HttpRequestDispatcher extends ChannelInboundHandlerAdapter {
     public void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception {
         log.debug("{}", msg);
         FullHttpRequest request = (FullHttpRequest) msg;
+        if (!trailingSlashSensitive) {
+            request.setUri(appendTrailingSlashIfAbsent(request.uri()));
+        }
         MappingContext<Handler> mappingContext = mappingRegistry.getMappingContext(request);
         if (null == mappingContext) {
             throw new HandlerNotFoundException(request.uri());
@@ -72,10 +80,26 @@ public final class HttpRequestDispatcher extends ChannelInboundHandlerAdapter {
                     continue;
                 }
                 HttpMethod httpMethod = HttpMethod.valueOf(mapping.method());
-                String pattern = mapping.path();
-                String fullPathPattern = contextPath + pattern;
+                String path = mapping.path();
+                String fullPathPattern = resolveFullPath(contextPath, path);
+                if (!trailingSlashSensitive) {
+                    fullPathPattern = appendTrailingSlashIfAbsent(fullPathPattern);
+                }
                 mappingRegistry.addMapping(httpMethod, fullPathPattern, new Handler(restfulController, method));
             }
         }
     }
+    
+    private String resolveFullPath(final String contextPath, final String pattern) {
+        return Optional.ofNullable(contextPath).orElse("") + pattern;
+    }
+    
+    private String appendTrailingSlashIfAbsent(final String uri) {
+        String[] split = uri.split("\\?");
+        if (1 == split.length) {
+            return uri.endsWith(TRAILING_SLASH) ? uri : uri + TRAILING_SLASH;
+        }
+        String path = split[0];
+        return path.endsWith(TRAILING_SLASH) ? uri : path + TRAILING_SLASH + "?" + split[1];
+    }
 }
diff --git a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/pipeline/RestfulServiceChannelInitializer.java b/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/pipeline/RestfulServiceChannelInitializer.java
index bd261a9..831df2c 100644
--- a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/pipeline/RestfulServiceChannelInitializer.java
+++ b/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/pipeline/RestfulServiceChannelInitializer.java
@@ -38,7 +38,7 @@ public final class RestfulServiceChannelInitializer extends ChannelInitializer<C
     private final ExceptionHandling exceptionHandling;
     
     public RestfulServiceChannelInitializer(final NettyRestfulServiceConfiguration configuration) {
-        httpRequestDispatcher = new HttpRequestDispatcher(configuration.getControllerInstances());
+        httpRequestDispatcher = new HttpRequestDispatcher(configuration.getControllerInstances(), configuration.isTrailingSlashSensitive());
         handlerParameterDecoder = new HandlerParameterDecoder();
         handleMethodExecutor = new HandleMethodExecutor();
         exceptionHandling = new ExceptionHandling(configuration.getExceptionHandlers());
diff --git a/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/RegexPathMatcherTest.java b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/RegexPathMatcherTest.java
index 68b0777..6c1286a 100644
--- a/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/RegexPathMatcherTest.java
+++ b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/RegexPathMatcherTest.java
@@ -59,12 +59,17 @@ public class RegexPathMatcherTest {
     public void assertValidatePathPattern() {
         PathMatcher pathMatcher = new RegexPathMatcher();
         assertTrue(pathMatcher.isValidPathPattern("/"));
+        assertTrue(pathMatcher.isValidPathPattern("/app"));
         assertTrue(pathMatcher.isValidPathPattern("/app/job"));
+        assertTrue(pathMatcher.isValidPathPattern("/app/job/"));
         assertTrue(pathMatcher.isValidPathPattern("/app/{jobName}"));
         assertTrue(pathMatcher.isValidPathPattern("/{appName}/{jobName}/status"));
         assertFalse(pathMatcher.isValidPathPattern("/app/jobName}"));
         assertFalse(pathMatcher.isValidPathPattern("/app/{jobName"));
         assertFalse(pathMatcher.isValidPathPattern("/app/{job}Name"));
+        assertFalse(pathMatcher.isValidPathPattern("/app//jobName"));
+        assertFalse(pathMatcher.isValidPathPattern("//app/jobName"));
+        assertFalse(pathMatcher.isValidPathPattern("app/jobName"));
         assertFalse(pathMatcher.isValidPathPattern(""));
     }
 }
diff --git a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/annotation/Mapping.java b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/controller/IndexController.java
similarity index 56%
copy from elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/annotation/Mapping.java
copy to elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/controller/IndexController.java
index ee23a16..3c9f3bc 100644
--- a/elasticjob-infra/elasticjob-restful/src/main/java/org/apache/shardingsphere/elasticjob/restful/annotation/Mapping.java
+++ b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/controller/IndexController.java
@@ -15,32 +15,23 @@
  * limitations under the License.
  */
 
-package org.apache.shardingsphere.elasticjob.restful.annotation;
+package org.apache.shardingsphere.elasticjob.restful.controller;
 
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.shardingsphere.elasticjob.restful.Http;
+import org.apache.shardingsphere.elasticjob.restful.RestfulController;
+import org.apache.shardingsphere.elasticjob.restful.annotation.Mapping;
 
-/**
- * Declare what HTTP method and path is used to invoke the handler.
- */
-@Target(ElementType.METHOD)
-@Retention(RetentionPolicy.RUNTIME)
-public @interface Mapping {
-    
-    /**
-     * Http method.
-     *
-     * @return Http method
-     */
-    String method();
+@Slf4j
+public class IndexController implements RestfulController {
     
     /**
-     * Path pattern of this handler.
-     * Such as <code>/app/{jobName}/enable</code>.
+     * A mapping declare path implicit, meaning it mapped index.
      *
-     * @return Path pattern
+     * @return a string
      */
-    String path() default "";
+    @Mapping(method = Http.GET)
+    public String index() {
+        return "hello, elastic-job";
+    }
 }
diff --git a/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/controller/TrailingSlashTestController.java b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/controller/TrailingSlashTestController.java
new file mode 100644
index 0000000..c98d2b6
--- /dev/null
+++ b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/controller/TrailingSlashTestController.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.elasticjob.restful.controller;
+
+import org.apache.shardingsphere.elasticjob.restful.Http;
+import org.apache.shardingsphere.elasticjob.restful.RestfulController;
+import org.apache.shardingsphere.elasticjob.restful.annotation.ContextPath;
+import org.apache.shardingsphere.elasticjob.restful.annotation.Mapping;
+import org.apache.shardingsphere.elasticjob.restful.annotation.Returning;
+
+@ContextPath("/trailing")
+public final class TrailingSlashTestController implements RestfulController {
+    
+    /**
+     * A mapping without trailing slash.
+     *
+     * @return a string
+     */
+    @Mapping(method = Http.GET, path = "/slash")
+    @Returning(contentType = "text/plain; charset=utf-8")
+    public String withoutTrailingSlash() {
+        return "without trailing slash";
+    }
+    
+    /**
+     * A mapping with trailing slash.
+     *
+     * @return a string
+     */
+    @Mapping(method = Http.GET, path = "/slash/")
+    @Returning(contentType = "text/plain; charset=utf-8")
+    public String withTrailingSlash() {
+        return "with trailing slash";
+    }
+}
diff --git a/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HandlerParameterDecoderTest.java b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HandlerParameterDecoderTest.java
new file mode 100644
index 0000000..afeb004
--- /dev/null
+++ b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HandlerParameterDecoderTest.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.elasticjob.restful.pipeline;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.ByteBufUtil;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.handler.codec.http.DefaultFullHttpRequest;
+import io.netty.handler.codec.http.DefaultHttpHeaders;
+import io.netty.handler.codec.http.FullHttpRequest;
+import io.netty.handler.codec.http.FullHttpResponse;
+import io.netty.handler.codec.http.HttpHeaders;
+import io.netty.handler.codec.http.HttpMethod;
+import io.netty.handler.codec.http.HttpVersion;
+import io.netty.handler.codec.http.QueryStringEncoder;
+import org.apache.shardingsphere.elasticjob.restful.Http;
+import org.apache.shardingsphere.elasticjob.restful.RestfulController;
+import org.apache.shardingsphere.elasticjob.restful.annotation.Mapping;
+import org.apache.shardingsphere.elasticjob.restful.annotation.Param;
+import org.apache.shardingsphere.elasticjob.restful.annotation.ParamSource;
+import org.apache.shardingsphere.elasticjob.restful.annotation.RequestBody;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Collections;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.junit.Assert.assertThat;
+
+public class HandlerParameterDecoderTest {
+    
+    private EmbeddedChannel channel;
+    
+    @Before
+    public void setUp() {
+        HttpRequestDispatcher httpRequestDispatcher = new HttpRequestDispatcher(Collections.singletonList(new DecoderTestController()), false);
+        HandlerParameterDecoder handlerParameterDecoder = new HandlerParameterDecoder();
+        HandleMethodExecutor handleMethodExecutor = new HandleMethodExecutor();
+        channel = new EmbeddedChannel(httpRequestDispatcher, handlerParameterDecoder, handleMethodExecutor);
+    }
+    
+    @Test
+    public void assertDecodeParameters() {
+        QueryStringEncoder queryStringEncoder = new QueryStringEncoder("/myApp/C");
+        queryStringEncoder.addParam("cron", "0 * * * * ?");
+        queryStringEncoder.addParam("integer", "30");
+        queryStringEncoder.addParam("bool", "true");
+        queryStringEncoder.addParam("long", "3000");
+        queryStringEncoder.addParam("double", "23.33");
+        String uri = queryStringEncoder.toString();
+        ByteBuf body = Unpooled.wrappedBuffer("BODY".getBytes());
+        HttpHeaders headers = new DefaultHttpHeaders();
+        headers.set("Message", "some_message");
+        FullHttpRequest httpRequest = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, uri, body, headers, headers);
+        channel.writeInbound(httpRequest);
+        FullHttpResponse httpResponse = channel.readOutbound();
+        assertThat(httpResponse.status().code(), is(200));
+        assertThat(new String(ByteBufUtil.getBytes(httpResponse.content())), is("ok"));
+    }
+    
+    public static class DecoderTestController implements RestfulController {
+        
+        /**
+         * A handle method for decode testing.
+         *
+         * @param appName     string from path
+         * @param ch          character from path
+         * @param cron        cron from query
+         * @param message     message from header
+         * @param body        from request body
+         * @param integer     integer from query
+         * @param bool        boolean from query
+         * @param longValue   long from query
+         * @param doubleValue double from query
+         * @return OK
+         */
+        @Mapping(method = Http.GET, path = "/{appName}/{ch}")
+        public String handle(
+                final @Param(source = ParamSource.PATH, name = "appName") String appName,
+                final @Param(source = ParamSource.PATH, name = "ch") char ch,
+                final @Param(source = ParamSource.QUERY, name = "cron") String cron,
+                final @Param(source = ParamSource.HEADER, name = "Message") String message,
+                final @RequestBody String body,
+                final @Param(source = ParamSource.QUERY, name = "integer") int integer,
+                final @Param(source = ParamSource.QUERY, name = "bool") Boolean bool,
+                final @Param(source = ParamSource.QUERY, name = "long") Long longValue,
+                final @Param(source = ParamSource.QUERY, name = "double") double doubleValue
+        ) {
+            assertThat(appName, is("myApp"));
+            assertThat(ch, is('C'));
+            assertThat(cron, is("0 * * * * ?"));
+            assertThat(message, is("some_message"));
+            assertThat(body, is("BODY"));
+            assertThat(integer, is(30));
+            assertThat(bool, is(true));
+            assertThat(longValue, is(3000L));
+            assertThat(doubleValue, is(23.33));
+            return "ok";
+        }
+    }
+}
diff --git a/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HttpClient.java b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HttpClient.java
index 410c1b3..98fd65d 100644
--- a/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HttpClient.java
+++ b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HttpClient.java
@@ -48,6 +48,7 @@ public class HttpClient {
      * @param request        HTTP request
      * @param consumer       HTTP response consumer
      * @param timeoutSeconds Wait for consume
+     * @throws InterruptedException interrupted
      */
     @SneakyThrows
     public static void request(final String host, final int port, final FullHttpRequest request, final Consumer<FullHttpResponse> consumer, final Long timeoutSeconds) {
@@ -66,8 +67,11 @@ public class HttpClient {
                                 .addLast(new SimpleChannelInboundHandler<FullHttpResponse>() {
                                     @Override
                                     protected void channelRead0(final ChannelHandlerContext ctx, final FullHttpResponse httpResponse) throws Exception {
-                                        consumer.accept(httpResponse);
-                                        countDownLatch.countDown();
+                                        try {
+                                            consumer.accept(httpResponse);
+                                        } finally {
+                                            countDownLatch.countDown();
+                                        }
                                     }
                                 });
                     }
diff --git a/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HttpRequestDispatcherTest.java b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HttpRequestDispatcherTest.java
index e55fcf9..adfc21f 100644
--- a/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HttpRequestDispatcherTest.java
+++ b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/HttpRequestDispatcherTest.java
@@ -31,7 +31,7 @@ public class HttpRequestDispatcherTest {
     
     @Test(expected = HandlerNotFoundException.class)
     public void assertDispatcherHandlerNotFound() {
-        EmbeddedChannel channel = new EmbeddedChannel(new HttpRequestDispatcher(Lists.newArrayList(new JobController())));
+        EmbeddedChannel channel = new EmbeddedChannel(new HttpRequestDispatcher(Lists.newArrayList(new JobController()), false));
         FullHttpRequest fullHttpRequest = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/hello/myJob/myCron");
         channel.writeInbound(fullHttpRequest);
     }
diff --git a/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/NettyRestfulServiceTest.java b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/NettyRestfulServiceTest.java
index 713f178..1e3927f 100644
--- a/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/NettyRestfulServiceTest.java
+++ b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/NettyRestfulServiceTest.java
@@ -30,6 +30,7 @@ import lombok.SneakyThrows;
 import org.apache.shardingsphere.elasticjob.restful.NettyRestfulService;
 import org.apache.shardingsphere.elasticjob.restful.NettyRestfulServiceConfiguration;
 import org.apache.shardingsphere.elasticjob.restful.RestfulService;
+import org.apache.shardingsphere.elasticjob.restful.controller.IndexController;
 import org.apache.shardingsphere.elasticjob.restful.controller.JobController;
 import org.apache.shardingsphere.elasticjob.restful.handler.CustomIllegalStateExceptionHandler;
 import org.apache.shardingsphere.elasticjob.restful.pojo.JobPojo;
@@ -46,6 +47,8 @@ import static org.junit.Assert.assertThat;
 
 public class NettyRestfulServiceTest {
     
+    private static final long TESTCASE_TIMEOUT = 10000L;
+    
     private static final String HOST = "localhost";
     
     private static final int PORT = 18080;
@@ -56,14 +59,14 @@ public class NettyRestfulServiceTest {
     public static void init() {
         NettyRestfulServiceConfiguration configuration = new NettyRestfulServiceConfiguration(PORT);
         configuration.setHost(HOST);
-        configuration.addControllerInstance(new JobController());
+        configuration.addControllerInstance(new JobController(), new IndexController());
         configuration.addExceptionHandler(IllegalStateException.class, new CustomIllegalStateExceptionHandler());
         restfulService = new NettyRestfulService(configuration);
         restfulService.startup();
     }
     
     @SneakyThrows
-    @Test(timeout = 10000L)
+    @Test(timeout = TESTCASE_TIMEOUT)
     public void assertRequestWithParameters() {
         String cron = "0 * * * * ?";
         String uri = String.format("/job/myGroup/myJob?cron=%s", URLEncoder.encode(cron, "UTF-8"));
@@ -82,43 +85,59 @@ public class NettyRestfulServiceTest {
             assertThat(jobPojo.getGroup(), is("myGroup"));
             assertThat(jobPojo.getName(), is("myJob"));
             assertThat(jobPojo.getDescription(), is(description));
-        }, 10000L);
+        }, TESTCASE_TIMEOUT);
     }
     
-    @Test(timeout = 10000L)
+    @Test(timeout = TESTCASE_TIMEOUT)
     public void assertCustomExceptionHandler() {
         DefaultFullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/job/throw/IllegalState");
         request.headers().set("Exception-Message", "An illegal state exception message.");
         HttpClient.request(HOST, PORT, request, httpResponse -> {
             // Handle by CustomExceptionHandler
             assertThat(httpResponse.status().code(), is(403));
-        }, 10000L);
+        }, TESTCASE_TIMEOUT);
     }
     
-    @Test(timeout = 10000L)
+    @Test(timeout = TESTCASE_TIMEOUT)
     public void assertUsingDefaultExceptionHandler() {
         DefaultFullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/job/throw/IllegalArgument");
         request.headers().set("Exception-Message", "An illegal argument exception message.");
         HttpClient.request(HOST, PORT, request, httpResponse -> {
             // Handle by DefaultExceptionHandler
             assertThat(httpResponse.status().code(), is(500));
-        }, 10000L);
+        }, TESTCASE_TIMEOUT);
     }
     
-    @Test(timeout = 10000L)
+    @Test(timeout = TESTCASE_TIMEOUT)
     public void assertReturnStatusCode() {
         DefaultFullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/job/code/204");
         HttpClient.request(HOST, PORT, request, httpResponse -> {
             assertThat(httpResponse.status().code(), is(204));
-        }, 10000L);
+        }, TESTCASE_TIMEOUT);
     }
     
-    @Test(timeout = 10000L)
+    @Test(timeout = TESTCASE_TIMEOUT)
     public void assertHandlerNotFound() {
         DefaultFullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/not/found");
         HttpClient.request(HOST, PORT, request, httpResponse -> {
             assertThat(httpResponse.status().code(), is(404));
-        }, 10000L);
+        }, TESTCASE_TIMEOUT);
+    }
+    
+    @Test(timeout = TESTCASE_TIMEOUT)
+    public void assertRequestIndexWithSlash() {
+        DefaultFullHttpRequest requestWithSlash = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/");
+        HttpClient.request(HOST, PORT, requestWithSlash, httpResponse -> {
+            assertThat(httpResponse.status().code(), is(200));
+        }, TESTCASE_TIMEOUT);
+    }
+    
+    @Test(timeout = TESTCASE_TIMEOUT)
+    public void assertRequestIndexWithoutSlash() {
+        DefaultFullHttpRequest requestWithoutSlash = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "");
+        HttpClient.request(HOST, PORT, requestWithoutSlash, httpResponse -> {
+            assertThat(httpResponse.status().code(), is(200));
+        }, TESTCASE_TIMEOUT);
     }
     
     @AfterClass
diff --git a/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/NettyRestfulServiceTrailingSlashInsensitiveTest.java b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/NettyRestfulServiceTrailingSlashInsensitiveTest.java
new file mode 100644
index 0000000..8ba384c
--- /dev/null
+++ b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/NettyRestfulServiceTrailingSlashInsensitiveTest.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.elasticjob.restful.pipeline;
+
+import org.apache.shardingsphere.elasticjob.restful.NettyRestfulService;
+import org.apache.shardingsphere.elasticjob.restful.NettyRestfulServiceConfiguration;
+import org.apache.shardingsphere.elasticjob.restful.RestfulService;
+import org.apache.shardingsphere.elasticjob.restful.controller.TrailingSlashTestController;
+import org.junit.Test;
+
+public class NettyRestfulServiceTrailingSlashInsensitiveTest {
+    
+    private static final String HOST = "localhost";
+    
+    private static final int PORT = 18082;
+    
+    @Test(expected = IllegalArgumentException.class)
+    public void assertPathDuplicateWhenTrailingSlashInsensitive() {
+        NettyRestfulServiceConfiguration configuration = new NettyRestfulServiceConfiguration(PORT);
+        configuration.setHost(HOST);
+        configuration.addControllerInstance(new TrailingSlashTestController());
+        RestfulService restfulService = new NettyRestfulService(configuration);
+        restfulService.startup();
+    }
+}
diff --git a/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/NettyRestfulServiceTrailingSlashSensitiveTest.java b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/NettyRestfulServiceTrailingSlashSensitiveTest.java
new file mode 100644
index 0000000..f175aa4
--- /dev/null
+++ b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/pipeline/NettyRestfulServiceTrailingSlashSensitiveTest.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.elasticjob.restful.pipeline;
+
+import io.netty.buffer.ByteBufUtil;
+import io.netty.handler.codec.http.DefaultFullHttpRequest;
+import io.netty.handler.codec.http.HttpMethod;
+import io.netty.handler.codec.http.HttpVersion;
+import org.apache.shardingsphere.elasticjob.restful.NettyRestfulService;
+import org.apache.shardingsphere.elasticjob.restful.NettyRestfulServiceConfiguration;
+import org.apache.shardingsphere.elasticjob.restful.RestfulService;
+import org.apache.shardingsphere.elasticjob.restful.controller.TrailingSlashTestController;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.junit.Assert.assertThat;
+
+public class NettyRestfulServiceTrailingSlashSensitiveTest {
+    
+    private static final long TESTCASE_TIMEOUT = 10000L;
+    
+    private static final String HOST = "localhost";
+    
+    private static final int PORT = 18081;
+    
+    private static RestfulService restfulService;
+    
+    @BeforeClass
+    public static void init() {
+        NettyRestfulServiceConfiguration configuration = new NettyRestfulServiceConfiguration(PORT);
+        configuration.setHost(HOST);
+        configuration.setTrailingSlashSensitive(true);
+        configuration.addControllerInstance(new TrailingSlashTestController());
+        restfulService = new NettyRestfulService(configuration);
+        restfulService.startup();
+    }
+    
+    @Test(timeout = TESTCASE_TIMEOUT)
+    public void assertWithoutTrailingSlash() {
+        DefaultFullHttpRequest requestWithSlash = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/trailing/slash");
+        HttpClient.request(HOST, PORT, requestWithSlash, httpResponse -> {
+            assertThat(httpResponse.status().code(), is(200));
+            byte[] bytes = ByteBufUtil.getBytes(httpResponse.content());
+            String body = new String(bytes, StandardCharsets.UTF_8);
+            assertThat(body, is("without trailing slash"));
+        }, TESTCASE_TIMEOUT);
+    }
+    
+    @Test(timeout = TESTCASE_TIMEOUT)
+    public void assertWithTrailingSlash() {
+        DefaultFullHttpRequest requestWithoutSlash = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/trailing/slash/");
+        HttpClient.request(HOST, PORT, requestWithoutSlash, httpResponse -> {
+            assertThat(httpResponse.status().code(), is(200));
+            byte[] bytes = ByteBufUtil.getBytes(httpResponse.content());
+            String body = new String(bytes, StandardCharsets.UTF_8);
+            assertThat(body, is("with trailing slash"));
+        }, TESTCASE_TIMEOUT);
+    }
+    
+    @AfterClass
+    public static void tearDown() {
+        if (null != restfulService) {
+            restfulService.shutdown();
+        }
+    }
+}
diff --git a/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/serializer/CustomTextPlainResponseBodySerializer.java b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/serializer/CustomTextPlainResponseBodySerializer.java
new file mode 100644
index 0000000..032f923
--- /dev/null
+++ b/elasticjob-infra/elasticjob-restful/src/test/java/org/apache/shardingsphere/elasticjob/restful/serializer/CustomTextPlainResponseBodySerializer.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.elasticjob.restful.serializer;
+
+import io.netty.handler.codec.http.HttpHeaderValues;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Serializer for <code>text/plain</code>. Serialize String to bytes.
+ */
+public final class CustomTextPlainResponseBodySerializer implements ResponseBodySerializer {
+    
+    @Override
+    public String mimeType() {
+        return HttpHeaderValues.TEXT_PLAIN.toString();
+    }
+    
+    @Override
+    public byte[] serialize(final Object responseBody) {
+        if (responseBody instanceof String) {
+            return ((String) responseBody).getBytes(StandardCharsets.UTF_8);
+        }
+        if (responseBody instanceof byte[]) {
+            return (byte[]) responseBody;
+        }
+        throw new UnsupportedOperationException("Can not deserialize" + responseBody.getClass());
+    }
+}
diff --git a/elasticjob-infra/elasticjob-restful/src/test/resources/META-INF/services/org.apache.shardingsphere.elasticjob.restful.serializer.ResponseBodySerializer b/elasticjob-infra/elasticjob-restful/src/test/resources/META-INF/services/org.apache.shardingsphere.elasticjob.restful.serializer.ResponseBodySerializer
new file mode 100644
index 0000000..5d294f8
--- /dev/null
+++ b/elasticjob-infra/elasticjob-restful/src/test/resources/META-INF/services/org.apache.shardingsphere.elasticjob.restful.serializer.ResponseBodySerializer
@@ -0,0 +1,18 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+org.apache.shardingsphere.elasticjob.restful.serializer.CustomTextPlainResponseBodySerializer