/* * Copyright 2016 LINE Corporation * * LINE Corporation 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 com.linecorp.armeria.server.http; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertThat; import java.util.EnumSet; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpHead; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.common.Request; import com.linecorp.armeria.common.RequestContext; import com.linecorp.armeria.common.http.HttpMethod; import com.linecorp.armeria.common.http.HttpRequest; import com.linecorp.armeria.common.http.HttpResponseWriter; import com.linecorp.armeria.common.http.HttpSessionProtocols; import com.linecorp.armeria.common.http.HttpStatus; import com.linecorp.armeria.server.PathMapping; import com.linecorp.armeria.server.Server; import com.linecorp.armeria.server.ServerBuilder; import com.linecorp.armeria.server.ServiceRequestContext; import com.linecorp.armeria.server.http.TestConverters.NaiveIntConverter; import com.linecorp.armeria.server.http.TestConverters.NaiveNumberConverter; import com.linecorp.armeria.server.http.TestConverters.NaiveStringConverter; import com.linecorp.armeria.server.http.TestConverters.TypedNumberConverter; import com.linecorp.armeria.server.http.TestConverters.TypedStringConverter; import com.linecorp.armeria.server.http.dynamic.Converter; import com.linecorp.armeria.server.http.dynamic.DynamicHttpService; import com.linecorp.armeria.server.http.dynamic.DynamicHttpServiceBuilder; import com.linecorp.armeria.server.http.dynamic.Get; import com.linecorp.armeria.server.http.dynamic.Path; import com.linecorp.armeria.server.http.dynamic.PathParam; import com.linecorp.armeria.server.http.dynamic.Post; import com.linecorp.armeria.server.logging.LoggingService; public class HttpServiceTest { private static final Server server; private static int httpPort; static { final ServerBuilder sb = new ServerBuilder(); try { sb.service( PathMapping.ofGlob("/hello/*").stripPrefix(1), new AbstractHttpService() { @Override protected void doGet( ServiceRequestContext ctx, HttpRequest req, HttpResponseWriter res) { final String name = ctx.mappedPath().substring(1); res.respond(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "Hello, %s!", name); } }.decorate(LoggingService::new)) .serviceAt( "/200", new AbstractHttpService() { @Override protected void doHead( ServiceRequestContext ctx, HttpRequest req, HttpResponseWriter res) { res.respond(HttpStatus.OK); } @Override protected void doGet( ServiceRequestContext ctx, HttpRequest req, HttpResponseWriter res) { res.respond(HttpStatus.OK); } }.decorate(LoggingService::new)) .serviceAt( "/204", new AbstractHttpService() { @Override protected void doGet( ServiceRequestContext ctx, HttpRequest req, HttpResponseWriter res) { res.respond(HttpStatus.NO_CONTENT); } }.decorate(LoggingService::new)); // Dynamic Service with DynamicHttpServiceBuilder and direct mappings sb.service(PathMapping.ofPrefix("/dynamic1"), new DynamicHttpServiceBuilder() // Default ResponseConverter for Integer .addConverter(Integer.class, new NaiveIntConverter()) // Default ResponseConverter for Number .addConverter(Number.class, new NaiveNumberConverter()) // Default ResponseConverter for String .addConverter(String.class, new NaiveStringConverter()) // Case 1: returns Integer type and handled by default Integer -> HttpResponse converter. .addMapping(HttpMethod.GET, "/int/{var}", (ctx, req, args) -> Integer.parseInt(args.get("var")) ) .addMapping(HttpMethod.GET, "/int-async/{var}", (ctx, req, args) -> CompletableFuture.completedFuture(Integer.parseInt(args.get("var"))) .thenApply(n -> n + 1) ) // Case 2: returns Long type and handled by default Number -> HttpResponse converter. .addMapping(HttpMethod.POST, "/long/{var}", (ctx, req, args) -> Long.parseLong(args.get("var")) ) // Case 3: returns String type and handled by custom String -> HttpResponse converter. .addMapping(EnumSet.of(HttpMethod.GET), "/string/{var}", (ctx, req, args) -> args.get("var"), new TypedStringConverter() ) .build().decorate(LoggingService::new)); // Dynamic Service with DynamicHttpServiceBuilder and direct mappings sb.service(PathMapping.ofPrefix("/dynamic2"), new DynamicHttpServiceBuilder() // Default ResponseConverter for Integer .addConverter(Integer.class, new NaiveIntConverter()) // Case 4, 5, 6 .addMappings(new ResponseStrategy()) .build().decorate(LoggingService::new)); // Dynamic Service with inheritance // Case 7, 8, 9 sb.service(PathMapping.ofPrefix("/dynamic3"), new DynamicService()); sb.serviceUnder("/dynamic4", new DynamicHttpServiceBuilder() .addMappings(new SimpleDynamicService1()) .addMappings(new SimpleDynamicService2()) .build() .orElse(new AbstractHttpService() {})); } catch (Exception e) { throw new Error(e); } server = sb.build(); } @Converter(target = Number.class, value = TypedNumberConverter.class) @Converter(target = String.class, value = TypedStringConverter.class) public static class ResponseStrategy { // Case 4: returns Integer type and handled by builder-default Integer -> HttpResponse converter. @Get @Path("/int/:var") public int returnInt(@PathParam("var") int var) { return var; } // Case 5: returns Long type and handled by class-default Number -> HttpResponse converter. @Post @Path("/long/{var}") public CompletionStage<Long> returnLong(@PathParam("var") long var) { return CompletableFuture.supplyAsync(() -> var); } // Case 6: returns String type and handled by custom String -> HttpResponse converter. @Get @Path("/string/:var") @Converter(NaiveStringConverter.class) public CompletionStage<String> returnString(@PathParam("var") String var) { return CompletableFuture.supplyAsync(() -> var); } // Asynchronously returns Integer type and handled by builder-default Integer -> HttpResponse converter. @Get @Path("/int-async/:var") public CompletableFuture<Integer> returnIntAsync(@PathParam("var") int var) { return CompletableFuture.completedFuture(var).thenApply(n -> n + 1); } @Get @Path("/path/ctx/async/:var") public CompletableFuture<String> returnPathCtxAsync(@PathParam("var") int var, ServiceRequestContext ctx, Request req) { validateContextAndRequest(ctx, req); return CompletableFuture.completedFuture(ctx.path()); } @Get @Path("/path/req/async/:var") public CompletableFuture<String> returnPathReqAsync(@PathParam("var") int var, HttpRequest req, ServiceRequestContext ctx) { validateContextAndRequest(ctx, req); return CompletableFuture.completedFuture(req.path()); } @Get @Path("/path/ctx/sync/:var") public String returnPathCtxSync(@PathParam("var") int var, RequestContext ctx, Request req) { validateContextAndRequest(ctx, req); return ctx.path(); } @Get @Path("/path/req/sync/:var") public String returnPathReqSync(@PathParam("var") int var, HttpRequest req, RequestContext ctx) { validateContextAndRequest(ctx, req); return req.path(); } private void validateContextAndRequest(RequestContext ctx, Request req) { if (RequestContext.current() != ctx) { throw new RuntimeException("ServiceRequestContext instances are not same!"); } if (RequestContext.current().request() != req) { throw new RuntimeException("HttpRequest instances are not same!"); } } // Throws an exception synchronously @Get @Path("/exception/:var") public int exception(@PathParam("var") int var) { throw new IllegalArgumentException("bad var!"); } // Throws an exception asynchronously @Get @Path("/exception-async/:var") public CompletableFuture<Integer> exceptionAsync(@PathParam("var") int var) { CompletableFuture<Integer> future = new CompletableFuture<>(); future.completeExceptionally(new IllegalArgumentException("bad var!")); return future; } } @Converter(target = Number.class, value = TypedNumberConverter.class) @Converter(target = String.class, value = TypedStringConverter.class) public static class DynamicService extends DynamicHttpService { // Case 7: returns Integer type and handled by class-default Number -> HttpResponse converter. @Get @Path("/int/{var}") public CompletionStage<Integer> returnInt(@PathParam("var") int var) { return CompletableFuture.supplyAsync(() -> var); } // Case 8: returns Long type and handled by class-default Number -> HttpResponse converter. @Post @Path("/long/:var") public Long returnLong(@PathParam("var") long var) { return var; } // Case 9: returns String type and handled by custom String -> HttpResponse converter. @Get @Path("/string/{var}") @Converter(NaiveStringConverter.class) public String returnString(@PathParam("var") String var) { return var; } @Get @Path("/boolean/{var}") public String returnBoolean(@PathParam("var") boolean var) { return Boolean.toString(var); } } @Converter(target = Number.class, value = TypedNumberConverter.class) public static class SimpleDynamicService1 { @Get @Path("/int/{var}") public CompletionStage<Integer> returnInt(@PathParam("var") int var) { return CompletableFuture.supplyAsync(() -> var); } } @Converter(target = String.class, value = TypedStringConverter.class) public static class SimpleDynamicService2 { @Get @Path("/string/{var}") public String returnString(@PathParam("var") String var) { return var; } } @BeforeClass public static void init() throws Exception { server.start().get(); httpPort = server.activePorts().values().stream() .filter(p -> p.protocol() == HttpSessionProtocols.HTTP).findAny().get().localAddress() .getPort(); } @AfterClass public static void destroy() throws Exception { server.stop(); } @Test public void testHello() throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal()) { try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/hello/foo")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("Hello, foo!")); } try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/hello/foo/bar")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 404 Not Found")); } try (CloseableHttpResponse res = hc.execute(new HttpDelete(newUri("/hello/bar")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 405 Method Not Allowed")); assertThat(EntityUtils.toString(res.getEntity()), is("405 Method Not Allowed")); } } } @Test public void testDynamicHttpServices() throws Exception { try (CloseableHttpClient hc = HttpClients.createMinimal()) { // Run case 1. try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic1/int/42")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("Integer: 42")); } // Run asynchronous case 1. try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic1/int-async/42")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("Integer: 43")); } // Run case 2. try (CloseableHttpResponse res = hc.execute(new HttpPost(newUri("/dynamic1/long/42")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("Number: 42")); } // Run case 3. try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic1/string/blah")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("String[blah]")); } // Run case 1 but illegal parameter. try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic2/int/fourty-two")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 500 Internal Server Error")); } // Run case 2 but without parameter (non-existing url). try (CloseableHttpResponse res = hc.execute(new HttpPost(newUri("/dynamic1/long/")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 404 Not Found")); } // Run case 3 but with not-mapped HTTP method (Post). try (CloseableHttpResponse res = hc.execute(new HttpPost(newUri("/dynamic1/string/blah")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 404 Not Found")); } // Run case 4. try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic2/int/42")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("Integer: 42")); } try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic2/int-async/42")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("Integer: 43")); } // Run case 5. try (CloseableHttpResponse res = hc.execute(new HttpPost(newUri("/dynamic2/long/42")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("Number[42]")); } // Run case 6. try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic2/string/blah")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("String: blah")); } // Run case 4 but illegal parameter. try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic2/int/fourty-two")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 500 Internal Server Error")); } // Run case 5 but without parameter (non-existing url). try (CloseableHttpResponse res = hc.execute(new HttpPost(newUri("/dynamic2/long/")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 404 Not Found")); } // Run case 6 but with not-mapped HTTP method (Post). try (CloseableHttpResponse res = hc.execute(new HttpPost(newUri("/dynamic2/string/blah")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 404 Not Found")); } // Get a requested path as typed string from ServiceRequestContext or HttpRequest try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic2/path/ctx/async/1")))) { assertThat(EntityUtils.toString(res.getEntity()), is("String[/dynamic2/path/ctx/async/1]")); } try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic2/path/req/async/1")))) { assertThat(EntityUtils.toString(res.getEntity()), is("String[/dynamic2/path/req/async/1]")); } try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic2/path/ctx/sync/1")))) { assertThat(EntityUtils.toString(res.getEntity()), is("String[/dynamic2/path/ctx/sync/1]")); } try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic2/path/req/sync/1")))) { assertThat(EntityUtils.toString(res.getEntity()), is("String[/dynamic2/path/req/sync/1]")); } // Exceptions in business logic try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic2/exception/42")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 500 Internal Server Error")); } try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic2/exception-async/1")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 500 Internal Server Error")); } // Run case 7. try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic3/int/42")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("Number[42]")); } // Run case 8. try (CloseableHttpResponse res = hc.execute(new HttpPost(newUri("/dynamic3/long/42")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("Number[42]")); } // Run case 9. try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic3/string/blah")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("String: blah")); } try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic3/boolean/true")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("String[true]")); } // Run case 7 but illegal parameter. try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic3/int/fourty-two")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 500 Internal Server Error")); } // Run case 8 but without parameter (non-existing url). try (CloseableHttpResponse res = hc.execute(new HttpPost(newUri("/dynamic3/long/")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 404 Not Found")); } // Run case 9 but with not-mapped HTTP method (Post). try (CloseableHttpResponse res = hc.execute(new HttpPost(newUri("/dynamic3/string/blah")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 404 Not Found")); } // Test OrElseHttpService try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic4/int/42")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("Number[42]")); } try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic4/string/blah")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(EntityUtils.toString(res.getEntity()), is("String[blah]")); } try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/dynamic4/undefined")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 405 Method Not Allowed")); } } } @Test public void testContentLength() throws Exception { // Test if the server responds with the 'content-length' header // even if it is the last response of the connection. try (CloseableHttpClient hc = HttpClients.createMinimal()) { HttpUriRequest req = new HttpGet(newUri("/200")); req.setHeader("Connection", "Close"); try (CloseableHttpResponse res = hc.execute(req)) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(res.containsHeader("Content-Length"), is(true)); assertThat(res.getHeaders("Content-Length").length, is(1)); assertThat(res.getHeaders("Content-Length")[0].getValue(), is("6")); assertThat(EntityUtils.toString(res.getEntity()), is("200 OK")); } } try (CloseableHttpClient hc = HttpClients.createMinimal()) { // Ensure the HEAD response does not have content. try (CloseableHttpResponse res = hc.execute(new HttpHead(newUri("/200")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 200 OK")); assertThat(res.getEntity(), is(nullValue())); } // Ensure the 204 response does not have content. try (CloseableHttpResponse res = hc.execute(new HttpGet(newUri("/204")))) { assertThat(res.getStatusLine().toString(), is("HTTP/1.1 204 No Content")); assertThat(res.getEntity(), is(nullValue())); } } } private static String newUri(String path) { return "http://127.0.0.1:" + httpPort + path; } }