diff options
author | Bjørn Christian Seime <bjorncs@verizonmedia.com> | 2021-03-04 17:30:50 +0100 |
---|---|---|
committer | Bjørn Christian Seime <bjorncs@verizonmedia.com> | 2021-03-19 15:52:31 +0100 |
commit | 7022e97b38833a10ff729c00cc2e2f6b91941245 (patch) | |
tree | 1cceabc55c2a0479fbde196ed179a18dced323df /container-core/src/main/java/com/yahoo | |
parent | 71b91595e873fe7b066e007a2b055788249f3418 (diff) |
Introduce simplified Rest API builder (for replacing Jax-rs ressources)
Diffstat (limited to 'container-core/src/main/java/com/yahoo')
3 files changed, 474 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); } +} |