diff options
Diffstat (limited to 'container-core/src')
4 files changed, 607 insertions, 0 deletions
diff --git a/container-core/src/main/java/com/yahoo/restapi/RestApi.java b/container-core/src/main/java/com/yahoo/restapi/RestApi.java new file mode 100644 index 00000000000..1d875db7adc --- /dev/null +++ b/container-core/src/main/java/com/yahoo/restapi/RestApi.java @@ -0,0 +1,119 @@ +// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.restapi; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yahoo.container.jdisc.HttpRequest; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.slime.Slime; +import com.yahoo.slime.SlimeUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.OptionalDouble; +import java.util.OptionalLong; + +/** + * Rest API routing and response serialization + * + * @author bjorncs + */ +public interface RestApi { + + static Builder builder() { return new RestApiImpl.BuilderImpl(); } + static RouteBuilder route(String pathPattern) { return new RestApiImpl.RouteBuilderImpl(pathPattern); } + + HttpResponse handleRequest(HttpRequest request); + ObjectMapper jacksonJsonMapper(); + + interface Builder { + Builder setObjectMapper(ObjectMapper mapper); + Builder setDefaultRoute(RouteBuilder route); + Builder addRoute(RouteBuilder route); + Builder addFilter(Filter filter); + <EXCEPTION extends RuntimeException> Builder addExceptionMapper(Class<EXCEPTION> type, ExceptionMapper<EXCEPTION> mapper); + <ENTITY> Builder addResponseMapper(Class<ENTITY> type, ResponseMapper<ENTITY> mapper); + Builder disableDefaultExceptionMappers(); + Builder disableDefaultResponseMappers(); + RestApi build(); + } + + interface RouteBuilder { + RouteBuilder name(String name); + RouteBuilder get(MethodHandler<?> handler); + RouteBuilder post(MethodHandler<?> handler); + RouteBuilder put(MethodHandler<?> handler); + RouteBuilder delete(MethodHandler<?> handler); + RouteBuilder patch(MethodHandler<?> handler); + RouteBuilder defaultHandler(MethodHandler<?> handler); + RouteBuilder addFilter(Filter filter); + } + + @FunctionalInterface interface ExceptionMapper<EXCEPTION extends RuntimeException> { HttpResponse toResponse(EXCEPTION exception, RequestContext context); } + + @FunctionalInterface interface MethodHandler<ENTITY> { ENTITY handleRequest(RequestContext context) throws RestApiException; } + + @FunctionalInterface interface ResponseMapper<ENTITY> { HttpResponse toHttpResponse(ENTITY responseEntity, RequestContext context); } + + @FunctionalInterface interface Filter { HttpResponse filterRequest(FilterContext context); } + + /** Marker interface required for automatic serialization of Jackson response entities */ + interface JacksonResponseEntity {} + + /** Marker interface required for automatic serialization of Jackson request entities */ + interface JacksonRequestEntity {} + + interface RequestContext { + HttpRequest request(); + PathParameters pathParameters(); + QueryParameters queryParameters(); + Headers headers(); + Attributes attributes(); + Optional<RequestContent> requestContent(); + + interface Parameters { + Optional<String> getString(String name); + String getStringOrThrow(String name); + default Optional<Boolean> getBoolean(String name) { return getString(name).map(Boolean::valueOf);} + default boolean getBooleanOrThrow(String name) { return Boolean.parseBoolean(getStringOrThrow(name)); } + default OptionalLong getLong(String name) { + return getString(name).map(Long::parseLong).map(OptionalLong::of).orElseGet(OptionalLong::empty); + } + default long getLongOrThrow(String name) { return Long.parseLong(getStringOrThrow(name)); } + default OptionalDouble getDouble(String name) { + return getString(name).map(Double::parseDouble).map(OptionalDouble::of).orElseGet(OptionalDouble::empty); + } + default double getDoubleOrThrow(String name) { return Double.parseDouble(getStringOrThrow(name)); } + } + + interface PathParameters extends Parameters {} + interface QueryParameters extends Parameters {} + interface Headers extends Parameters {} + + interface Attributes { + Optional<Object> get(String name); + void set(String name, Object value); + } + + interface RequestContent { + String contentType(); + InputStream inputStream(); + ObjectMapper jacksonJsonMapper(); + default byte[] consumeByteArray() throws IOException { return inputStream().readAllBytes(); } + default String consumeString() throws IOException { return new String(consumeByteArray(), StandardCharsets.UTF_8); } + default JsonNode consumeJsonNode() throws IOException { return jacksonJsonMapper().readTree(inputStream()); } + default Slime consumeSlime() throws IOException { return SlimeUtils.jsonToSlime(consumeByteArray()); } + default <T extends JacksonRequestEntity> T consumeJacksonEntity(Class<T> type) throws IOException { + return jacksonJsonMapper().readValue(inputStream(), type); + } + } + } + + interface FilterContext { + RequestContext requestContext(); + String route(); + HttpResponse executeNext(); + } +} diff --git a/container-core/src/main/java/com/yahoo/restapi/RestApiImpl.java b/container-core/src/main/java/com/yahoo/restapi/RestApiImpl.java new file mode 100644 index 00000000000..b2da9db9458 --- /dev/null +++ b/container-core/src/main/java/com/yahoo/restapi/RestApiImpl.java @@ -0,0 +1,321 @@ +// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.restapi; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yahoo.container.jdisc.HttpRequest; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.slime.Slime; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.ListIterator; +import java.util.Map; +import java.util.Optional; + +/** + * @author bjorncs + */ +class RestApiImpl implements RestApi { + + private final Route defaultRoute; + private final List<Route> routes; + private final List<ExceptionMapperHolder<?>> exceptionMappers; + private final List<ResponseMapperHolder<?>> responseMappers; + private final List<Filter> filters; + private final ObjectMapper jacksonJsonMapper; + + private RestApiImpl(RestApi.Builder builder) { + BuilderImpl builderImpl = (BuilderImpl) builder; + ObjectMapper jacksonJsonMapper = builderImpl.jacksonJsonMapper != null ? builderImpl.jacksonJsonMapper : JacksonJsonMapper.instance; + this.defaultRoute = builderImpl.defaultRoute != null ? builderImpl.defaultRoute : createDefaultRoute(); + this.routes = List.copyOf(builderImpl.routes); + this.exceptionMappers = combineWithDefaultExceptionMappers( + builderImpl.exceptionMappers, Boolean.TRUE.equals(builderImpl.disableDefaultExceptionMappers)); + this.responseMappers = combineWithDefaultResponseMappers( + builderImpl.responseMappers, jacksonJsonMapper, Boolean.TRUE.equals(builderImpl.disableDefaultResponseMappers)); + this.filters = List.copyOf(builderImpl.filters); + this.jacksonJsonMapper = jacksonJsonMapper; + } + + @Override + public HttpResponse handleRequest(HttpRequest request) { + Path pathMatcher = new Path(request.getUri()); + Route resolvedRoute = resolveRoute(pathMatcher); + RequestContextImpl requestContext = new RequestContextImpl(request, pathMatcher, jacksonJsonMapper); + FilterContextImpl filterContext = + createFilterContextRecursive( + resolvedRoute, requestContext, filters, + createFilterContextRecursive(resolvedRoute, requestContext, resolvedRoute.filters, null)); + if (filterContext != null) { + return filterContext.executeFirst(); + } else { + return dispatchToRoute(resolvedRoute, requestContext); + } + } + + @Override public ObjectMapper jacksonJsonMapper() { return jacksonJsonMapper; } + + private HttpResponse dispatchToRoute(Route route, RequestContextImpl context) { + RestApi.MethodHandler<?> resolvedHandler = route.handlerPerMethod.get(context.request().getMethod()); + if (resolvedHandler == null) { + resolvedHandler = route.defaultHandler; + } + Object entity; + try { + entity = resolvedHandler.handleRequest(context); + } catch (RuntimeException e) { + ExceptionMapperHolder<?> mapper = exceptionMappers.stream() + .filter(holder -> holder.matches(e)) + .findFirst().orElseThrow(() -> e); + return mapper.toResponse(e, context); + } + if (entity == null) throw new NullPointerException("Handler must return non-null value"); + ResponseMapperHolder<?> mapper = responseMappers.stream() + .filter(holder -> holder.matches(entity)) + .findFirst().orElseThrow(() -> new IllegalStateException("No mapper configured for " + entity.getClass())); + return mapper.toHttpResponse(entity, context); + } + + private Route resolveRoute(Path pathMatcher) { + Route matchingRoute = routes.stream() + .filter(route -> pathMatcher.matches(route.pathPattern)) + .findFirst() + .orElse(null); + if (matchingRoute != null) return matchingRoute; + pathMatcher.matches(defaultRoute.pathPattern); // to populate any path parameters + return defaultRoute; + } + + private FilterContextImpl createFilterContextRecursive( + Route route, RequestContextImpl requestContext, List<Filter> filters, FilterContextImpl previousContext) { + FilterContextImpl filterContext = previousContext; + ListIterator<Filter> iterator = filters.listIterator(filters.size()); + while (iterator.hasPrevious()) { + filterContext = new FilterContextImpl(route, iterator.previous(), requestContext, filterContext); + } + return filterContext; + } + + private static Route createDefaultRoute() { + RouteBuilder routeBuilder = new RouteBuilderImpl("{*}") + .defaultHandler(context -> { + throw new RestApiException.NotFoundException(); + }); + return ((RouteBuilderImpl)routeBuilder).build(); + } + + private static List<ExceptionMapperHolder<?>> combineWithDefaultExceptionMappers( + List<ExceptionMapperHolder<?>> configuredExceptionMappers, boolean disableDefaultMappers) { + List<ExceptionMapperHolder<?>> exceptionMappers = new ArrayList<>(configuredExceptionMappers); + if (!disableDefaultMappers){ + exceptionMappers.add(new ExceptionMapperHolder<>(RestApiException.class, (exception, context) -> exception.response())); + } + return exceptionMappers; + } + + private static List<ResponseMapperHolder<?>> combineWithDefaultResponseMappers( + List<ResponseMapperHolder<?>> configuredResponseMappers, ObjectMapper jacksonJsonMapper, boolean disableDefaultMappers) { + List<ResponseMapperHolder<?>> responseMappers = new ArrayList<>(configuredResponseMappers); + if (!disableDefaultMappers) { + responseMappers.add(new ResponseMapperHolder<>(HttpResponse.class, (entity, context) -> entity)); + responseMappers.add(new ResponseMapperHolder<>(String.class, (entity, context) -> new MessageResponse(entity))); + responseMappers.add(new ResponseMapperHolder<>(Slime.class, (entity, context) -> new SlimeJsonResponse(entity))); + responseMappers.add(new ResponseMapperHolder<>(JsonNode.class, (entity, context) -> new JacksonJsonResponse<>(200, entity, jacksonJsonMapper, true))); + responseMappers.add(new ResponseMapperHolder<>(RestApi.JacksonResponseEntity.class, (entity, context) -> new JacksonJsonResponse<>(200, entity, jacksonJsonMapper, true))); + } + return responseMappers; + } + + static class BuilderImpl implements RestApi.Builder { + private final List<Route> routes = new ArrayList<>(); + private final List<ExceptionMapperHolder<?>> exceptionMappers = new ArrayList<>(); + private final List<ResponseMapperHolder<?>> responseMappers = new ArrayList<>(); + private final List<RestApi.Filter> filters = new ArrayList<>(); + private Route defaultRoute; + private ObjectMapper jacksonJsonMapper; + private Boolean disableDefaultExceptionMappers; + private Boolean disableDefaultResponseMappers; + + @Override public RestApi.Builder setObjectMapper(ObjectMapper mapper) { this.jacksonJsonMapper = mapper; return this; } + @Override public RestApi.Builder setDefaultRoute(RestApi.RouteBuilder route) { this.defaultRoute = ((RouteBuilderImpl)route).build(); return this; } + @Override public RestApi.Builder addRoute(RestApi.RouteBuilder route) { routes.add(((RouteBuilderImpl)route).build()); return this; } + @Override public RestApi.Builder addFilter(RestApi.Filter filter) { filters.add(filter); return this; } + + @Override public <EXCEPTION extends RuntimeException> RestApi.Builder addExceptionMapper(Class<EXCEPTION> type, RestApi.ExceptionMapper<EXCEPTION> mapper) { + exceptionMappers.add(new ExceptionMapperHolder<>(type, mapper)); return this; + } + + @Override public <ENTITY> RestApi.Builder addResponseMapper(Class<ENTITY> type, RestApi.ResponseMapper<ENTITY> mapper) { + responseMappers.add(new ResponseMapperHolder<>(type, mapper)); return this; + } + + @Override public Builder disableDefaultExceptionMappers() { this.disableDefaultExceptionMappers = true; return this; } + @Override public Builder disableDefaultResponseMappers() { this.disableDefaultResponseMappers = true; return this; } + @Override public RestApi build() { return new RestApiImpl(this); } + } + + public static class RouteBuilderImpl implements RestApi.RouteBuilder { + private final String pathPattern; + private String name; + private final Map<com.yahoo.jdisc.http.HttpRequest.Method, RestApi.MethodHandler<?>> handlerPerMethod = new HashMap<>(); + private final List<RestApi.Filter> filters = new ArrayList<>(); + private RestApi.MethodHandler<?> defaultHandler; + + RouteBuilderImpl(String pathPattern) { this.pathPattern = pathPattern; } + + @Override public RestApi.RouteBuilder name(String name) { this.name = name; return this; } + @Override public RestApi.RouteBuilder get(RestApi.MethodHandler<?> handler) { return addHandler(com.yahoo.jdisc.http.HttpRequest.Method.GET, handler); } + @Override public RestApi.RouteBuilder post(RestApi.MethodHandler<?> handler) { return addHandler(com.yahoo.jdisc.http.HttpRequest.Method.POST, handler); } + @Override public RestApi.RouteBuilder put(RestApi.MethodHandler<?> handler) { return addHandler(com.yahoo.jdisc.http.HttpRequest.Method.PUT, handler); } + @Override public RestApi.RouteBuilder delete(RestApi.MethodHandler<?> handler) { return addHandler(com.yahoo.jdisc.http.HttpRequest.Method.DELETE, handler); } + @Override public RestApi.RouteBuilder patch(RestApi.MethodHandler<?> handler) { return addHandler(com.yahoo.jdisc.http.HttpRequest.Method.PATCH, handler); } + @Override public RestApi.RouteBuilder defaultHandler(RestApi.MethodHandler<?> handler) { defaultHandler = handler; return this; } + @Override public RestApi.RouteBuilder addFilter(RestApi.Filter filter) { filters.add(filter); return this; } + + private RestApi.RouteBuilder addHandler(com.yahoo.jdisc.http.HttpRequest.Method method, RestApi.MethodHandler<?> handler) { + handlerPerMethod.put(method, handler); return this; + } + + private Route build() { return new Route(this); } + } + + private static class RequestContextImpl implements RestApi.RequestContext { + final HttpRequest request; + final Path pathMatcher; + final ObjectMapper jacksonJsonMapper; + final PathParameters pathParameters = new PathParametersImpl(); + final QueryParameters queryParameters = new QueryParametersImpl(); + final Headers headers = new HeadersImpl(); + final Attributes attributes = new AttributesImpl(); + final RequestContent requestContent; + + RequestContextImpl(HttpRequest request, Path pathMatcher, ObjectMapper jacksonJsonMapper) { + this.request = request; + this.pathMatcher = pathMatcher; + this.jacksonJsonMapper = jacksonJsonMapper; + this.requestContent = request.getData() != null ? new RequestContentImpl() : null; + } + + @Override public HttpRequest request() { return request; } + @Override public PathParameters pathParameters() { return pathParameters; } + @Override public QueryParameters queryParameters() { return queryParameters; } + @Override public Headers headers() { return headers; } + @Override public Attributes attributes() { return attributes; } + @Override public Optional<RequestContent> requestContent() { return Optional.ofNullable(requestContent); } + + private class PathParametersImpl implements RestApi.RequestContext.PathParameters { + @Override public Optional<String> getString(String name) { return Optional.ofNullable(pathMatcher.get(name)); } + @Override public String getStringOrThrow(String name) { + return getString(name) + .orElseThrow(() -> new RestApiException.BadRequest("Path parameter '" + name + "' is missing")); + } + } + + private class QueryParametersImpl implements RestApi.RequestContext.QueryParameters { + @Override public Optional<String> getString(String name) { return Optional.ofNullable(request.getProperty(name)); } + @Override public String getStringOrThrow(String name) { + return getString(name) + .orElseThrow(() -> new RestApiException.BadRequest("Query parameter '" + name + "' is missing")); + } + } + + private class HeadersImpl implements RestApi.RequestContext.Headers { + @Override public Optional<String> getString(String name) { return Optional.ofNullable(request.getHeader(name)); } + @Override public String getStringOrThrow(String name) { + return getString(name) + .orElseThrow(() -> new RestApiException.BadRequest("Header '" + name + "' missing")); + } + } + + private class RequestContentImpl implements RestApi.RequestContext.RequestContent { + @Override public String contentType() { return request.getHeader("Content-Type"); } + @Override public InputStream inputStream() { return request.getData(); } + @Override public ObjectMapper jacksonJsonMapper() { return jacksonJsonMapper; } + } + + private class AttributesImpl implements RestApi.RequestContext.Attributes { + @Override public Optional<Object> get(String name) { return Optional.ofNullable(request.getJDiscRequest().context().get(name)); } + @Override public void set(String name, Object value) { request.getJDiscRequest().context().put(name, value); } + } + } + + private class FilterContextImpl implements RestApi.FilterContext { + final Route route; + final RestApi.Filter filter; + final RequestContextImpl requestContext; + final FilterContextImpl next; + + FilterContextImpl(Route route, RestApi.Filter filter, RequestContextImpl requestContext, FilterContextImpl next) { + this.route = route; + this.filter = filter; + this.requestContext = requestContext; + this.next = next; + } + + @Override public RestApi.RequestContext requestContext() { return requestContext; } + @Override public String route() { return route.name != null ? route.name : route.pathPattern; } + + HttpResponse executeFirst() { return filter.filterRequest(this); } + + @Override + public HttpResponse executeNext() { + if (next != null) { + return next.filter.filterRequest(next); + } else { + return dispatchToRoute(route, requestContext); + } + } + } + + private static class ExceptionMapperHolder<EXCEPTION extends RuntimeException> { + final Class<EXCEPTION> type; + final RestApi.ExceptionMapper<EXCEPTION> mapper; + + ExceptionMapperHolder(Class<EXCEPTION> type, RestApi.ExceptionMapper<EXCEPTION> mapper) { + this.type = type; + this.mapper = mapper; + } + + boolean matches(RuntimeException e) { return type.isAssignableFrom(e.getClass()); } + HttpResponse toResponse(RuntimeException e, RestApi.RequestContext context) { return mapper.toResponse(type.cast(e), context); } + } + + private static class ResponseMapperHolder<ENTITY> { + final Class<ENTITY> type; + final RestApi.ResponseMapper<ENTITY> mapper; + + ResponseMapperHolder(Class<ENTITY> type, RestApi.ResponseMapper<ENTITY> mapper) { + this.type = type; + this.mapper = mapper; + } + + boolean matches(Object entity) { return type.isAssignableFrom(entity.getClass()); } + HttpResponse toHttpResponse(Object entity, RestApi.RequestContext context) { return mapper.toHttpResponse(type.cast(entity), context); } + } + + + static class Route { + private final String pathPattern; + private final String name; + private final Map<com.yahoo.jdisc.http.HttpRequest.Method, RestApi.MethodHandler<?>> handlerPerMethod; + private final RestApi.MethodHandler<?> defaultHandler; + private final List<Filter> filters; + + private Route(RestApi.RouteBuilder builder) { + RouteBuilderImpl builderImpl = (RouteBuilderImpl)builder; + this.pathPattern = builderImpl.pathPattern; + this.name = builderImpl.name; + this.handlerPerMethod = Map.copyOf(builderImpl.handlerPerMethod); + this.defaultHandler = builderImpl.defaultHandler != null ? builderImpl.defaultHandler : createDefaultMethodHandler(); + this.filters = List.copyOf(builderImpl.filters); + } + + private RestApi.MethodHandler<?> createDefaultMethodHandler() { + return context -> { throw new RestApiException.MethodNotAllowed(context.request()); }; + } + } + +} diff --git a/container-core/src/main/java/com/yahoo/restapi/RestApiRequestHandler.java b/container-core/src/main/java/com/yahoo/restapi/RestApiRequestHandler.java new file mode 100644 index 00000000000..6a24fcf648c --- /dev/null +++ b/container-core/src/main/java/com/yahoo/restapi/RestApiRequestHandler.java @@ -0,0 +1,34 @@ +// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.restapi; + +import com.yahoo.container.jdisc.HttpRequest; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.container.jdisc.LoggingRequestHandler; + +/** + * @author bjorncs + */ +public abstract class RestApiRequestHandler<T extends RestApiRequestHandler<T>> extends LoggingRequestHandler { + + private final RestApi restApi; + + @FunctionalInterface public interface RestApiProvider<T> { RestApi createRestApi(T self); } + + /** + * RestApi will usually refer to handler methods of subclass, which are not accessible before super constructor has completed. + * This is hack to leak reference to subclass instance's "this" reference. + * Caller must ensure that provider instance does not try to access any uninitialized fields. + */ + @SuppressWarnings("unchecked") + protected RestApiRequestHandler(LoggingRequestHandler.Context context, RestApiProvider<T> provider) { + super(context); + this.restApi = provider.createRestApi((T)this); + } + + protected RestApiRequestHandler(LoggingRequestHandler.Context context, RestApi restApi) { + super(context); + this.restApi = restApi; + } + + @Override public final HttpResponse handle(HttpRequest request) { return restApi.handleRequest(request); } +} diff --git a/container-core/src/test/java/com/yahoo/restapi/RestApiImplTest.java b/container-core/src/test/java/com/yahoo/restapi/RestApiImplTest.java new file mode 100644 index 00000000000..628c25b23db --- /dev/null +++ b/container-core/src/test/java/com/yahoo/restapi/RestApiImplTest.java @@ -0,0 +1,133 @@ +package com.yahoo.restapi;// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.yahoo.container.jdisc.HttpRequest; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.test.json.JsonTestHelper; +import com.yahoo.yolean.Exceptions; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static com.yahoo.jdisc.http.HttpRequest.Method; +import static com.yahoo.restapi.RestApi.route; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * @author bjorncs + */ +class RestApiImplTest { + + @Test + void routes_requests_to_correct_handler() { + RestApi restApi = RestApi.builder() + .addRoute(route("/api1/{*}").get(ctx -> new MessageResponse("get-method-response"))) + .addRoute(route("/api2/{*}").post(ctx -> new MessageResponse("post-method-response"))) + .setDefaultRoute(route("{*}").defaultHandler(ctx -> ErrorResponse.notFoundError("default-method-response"))) + .build(); + verifyJsonResponse(restApi, Method.GET, "/api1/subpath", null, 200, "{\"message\":\"get-method-response\"}"); + verifyJsonResponse(restApi, Method.POST, "/api1/subpath", "{}", 405, null); + verifyJsonResponse(restApi, Method.GET, "/api2/subpath", null, 405, null); + verifyJsonResponse(restApi, Method.POST, "/api2/subpath", "{}", 200, "{\"message\":\"post-method-response\"}"); + verifyJsonResponse(restApi, Method.PUT, "/api2/subpath", "{}", 405, null); + verifyJsonResponse(restApi, Method.GET, "/unknown/subpath", null, 404, "{\"error-code\":\"NOT_FOUND\",\"message\":\"default-method-response\"}"); + verifyJsonResponse(restApi, Method.DELETE, "/unknown/subpath", "{}", 404, "{\"error-code\":\"NOT_FOUND\",\"message\":\"default-method-response\"}"); + } + + @Test + void executes_filters_and_handler_in_correct_order() { + List<String> actualEvaluationOrdering = new ArrayList<>(); + RestApi.MethodHandler<HttpResponse> handler = context -> { + actualEvaluationOrdering.add("handler"); + return new MessageResponse("get-method-response"); + }; + class NamedTestFilter implements RestApi.Filter { + final String name; + NamedTestFilter(String name) { this.name = name; } + + @Override + public HttpResponse filterRequest(RestApi.FilterContext context) { + actualEvaluationOrdering.add("pre-" + name); + HttpResponse response = context.executeNext(); + actualEvaluationOrdering.add("post-" + name); + return response; + } + } + RestApi restApi = RestApi.builder() + .setDefaultRoute(route("{*}") + .defaultHandler(handler) + .addFilter(new NamedTestFilter("route-filter-1")) + .addFilter(new NamedTestFilter("route-filter-2"))) + .addFilter(new NamedTestFilter("global-filter-1")) + .addFilter(new NamedTestFilter("global-filter-2")) + .build(); + verifyJsonResponse(restApi, Method.GET, "/", null, 200, "{\"message\":\"get-method-response\"}"); + List<String> expectedOrdering = List.of( + "pre-global-filter-1", "pre-global-filter-2", "pre-route-filter-1", "pre-route-filter-2", + "handler", + "post-route-filter-2", "post-route-filter-1", "post-global-filter-2", "post-global-filter-1"); + assertEquals(expectedOrdering, actualEvaluationOrdering); + } + + @SuppressWarnings("divzero") + @Test + void handles_custom_response_and_exception_mapper() { + RestApi restApi = RestApi.builder() + .disableDefaultExceptionMappers() + .disableDefaultResponseMappers() + .addRoute(route("/long").get(ctx -> 123456L)) + .addRoute(route("/exception").get(ctx -> 123L / 0L)) + .addResponseMapper(Long.class, (entity, ctx) -> new MessageResponse("long value is " + entity)) + .addExceptionMapper(ArithmeticException.class, (exception, ctx) -> ErrorResponse.internalServerError("oops division by zero")) + .build(); + verifyJsonResponse(restApi, Method.GET, "/long", null, 200, "{\"message\":\"long value is 123456\"}"); + verifyJsonResponse(restApi, Method.GET, "/exception", null, 500, "{\"message\":\"oops division by zero\", \"error-code\":\"INTERNAL_SERVER_ERROR\"}"); + } + + @Test + void method_handler_can_consume_and_produce_json() { + RestApi restApi = RestApi.builder() + .addRoute(route("/api").post( + ctx -> { + try { + return ctx.requestContent().get().consumeJacksonEntity(TestEntity.class); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + })) + .build(); + String rawJson = "{\"mystring\":\"my-string-value\", \"myinstant\":\"2000-01-01T00:00:00Z\"}"; + verifyJsonResponse(restApi, Method.POST, "/api", rawJson, 200, rawJson); + } + + private static void verifyJsonResponse(RestApi restApi, Method method, String path, String requestContent, int expectedStatusCode, String expectedJson) { + HttpRequest testRequest = requestContent != null ? + HttpRequest.createTestRequest( + path, method, + new ByteArrayInputStream(requestContent.getBytes(StandardCharsets.UTF_8)), + Map.of("Content-Type", "application/json")) : + HttpRequest.createTestRequest(path, method); + HttpResponse response = restApi.handleRequest(testRequest); + assertEquals(expectedStatusCode, response.getStatus()); + if (expectedJson != null) { + assertEquals("application/json", response.getContentType()); + var outputStream = new ByteArrayOutputStream(); + Exceptions.uncheck(() -> response.render(outputStream)); + String content = outputStream.toString(StandardCharsets.UTF_8); + JsonTestHelper.assertJsonEquals(content, expectedJson); + } + } + + public static class TestEntity implements RestApi.JacksonRequestEntity, RestApi.JacksonResponseEntity { + @JsonProperty("mystring") public String stringValue; + @JsonProperty("myinstant") public Instant instantValue; + } +}
\ No newline at end of file |