summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBjørn Christian Seime <bjorncs@verizonmedia.com>2021-03-22 15:08:59 +0100
committerGitHub <noreply@github.com>2021-03-22 15:08:59 +0100
commit81871d3f99f2155b0d81b347b61ac685a7bbc13e (patch)
tree7a7405fb49db2f95812c3a5612d492bd7c9528ce
parentdf817de66efaff56e2032fc4723a77af445b0aad (diff)
parent78878a6cdd41406d0b950611e88ac15ee5180faf (diff)
Merge pull request #16766 from vespa-engine/bjorncs/jersey-replacement
WIP
-rw-r--r--container-core/pom.xml10
-rw-r--r--container-core/src/main/java/com/yahoo/restapi/JacksonJsonMapper.java22
-rw-r--r--container-core/src/main/java/com/yahoo/restapi/JacksonJsonResponse.java24
-rw-r--r--container-core/src/main/java/com/yahoo/restapi/MessageResponse.java4
-rw-r--r--container-core/src/main/java/com/yahoo/restapi/RestApi.java115
-rw-r--r--container-core/src/main/java/com/yahoo/restapi/RestApiException.java68
-rw-r--r--container-core/src/main/java/com/yahoo/restapi/RestApiImpl.java392
-rw-r--r--container-core/src/main/java/com/yahoo/restapi/RestApiRequestHandler.java34
-rw-r--r--container-core/src/test/java/com/yahoo/restapi/RestApiImplTest.java125
9 files changed, 786 insertions, 8 deletions
diff --git a/container-core/pom.xml b/container-core/pom.xml
index 138a68a7c0f..ea6e7d32310 100644
--- a/container-core/pom.xml
+++ b/container-core/pom.xml
@@ -150,6 +150,16 @@
<scope>test</scope>
</dependency>
<dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.junit.vintage</groupId>
+ <artifactId>junit-vintage-engine</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
<!-- TODO Vespa 8: stop providing org.json:json -->
<groupId>org.json</groupId>
<artifactId>json</artifactId>
diff --git a/container-core/src/main/java/com/yahoo/restapi/JacksonJsonMapper.java b/container-core/src/main/java/com/yahoo/restapi/JacksonJsonMapper.java
new file mode 100644
index 00000000000..5a5a990737c
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/restapi/JacksonJsonMapper.java
@@ -0,0 +1,22 @@
+// 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.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+
+/**
+ * Default Jackson {@link ObjectMapper} instance shared by {@link com.yahoo.restapi}.
+ *
+ * @author bjorncs
+ */
+class JacksonJsonMapper {
+
+ static final ObjectMapper instance = new ObjectMapper()
+ .registerModule(new JavaTimeModule())
+ .registerModule(new Jdk8Module())
+ .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
+
+ private JacksonJsonMapper() {}
+}
diff --git a/container-core/src/main/java/com/yahoo/restapi/JacksonJsonResponse.java b/container-core/src/main/java/com/yahoo/restapi/JacksonJsonResponse.java
index 0a2c08530aa..1b356b3b459 100644
--- a/container-core/src/main/java/com/yahoo/restapi/JacksonJsonResponse.java
+++ b/container-core/src/main/java/com/yahoo/restapi/JacksonJsonResponse.java
@@ -2,13 +2,12 @@
package com.yahoo.restapi;
import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
-import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.fasterxml.jackson.databind.ObjectWriter;
import com.yahoo.container.jdisc.HttpResponse;
-import java.util.logging.Level;
import java.io.IOException;
import java.io.OutputStream;
+import java.util.logging.Level;
import java.util.logging.Logger;
/**
@@ -19,30 +18,39 @@ import java.util.logging.Logger;
public class JacksonJsonResponse<T> extends HttpResponse {
private static final Logger log = Logger.getLogger(JacksonJsonResponse.class.getName());
- private static final ObjectMapper defaultJsonMapper =
- new ObjectMapper().registerModule(new JavaTimeModule()).registerModule(new Jdk8Module());
private final ObjectMapper jsonMapper;
+ private final boolean prettyPrint;
private final T entity;
public JacksonJsonResponse(int statusCode, T entity) {
- this(statusCode, entity, defaultJsonMapper);
+ this(statusCode, entity, false);
+ }
+
+ public JacksonJsonResponse(int statusCode, T entity, boolean prettyPrint) {
+ this(statusCode, entity, JacksonJsonMapper.instance, prettyPrint);
}
public JacksonJsonResponse(int statusCode, T entity, ObjectMapper jsonMapper) {
+ this(statusCode, entity, jsonMapper, false);
+ }
+
+ public JacksonJsonResponse(int statusCode, T entity, ObjectMapper jsonMapper, boolean prettyPrint) {
super(statusCode);
this.entity = entity;
this.jsonMapper = jsonMapper;
+ this.prettyPrint = prettyPrint;
}
@Override
public void render(OutputStream outputStream) throws IOException {
+ ObjectWriter writer = prettyPrint ? jsonMapper.writerWithDefaultPrettyPrinter() : jsonMapper.writer();
if (log.isLoggable(Level.FINE)) {
- String json = jsonMapper.writeValueAsString(entity);
+ String json = writer.writeValueAsString(entity);
log.log(Level.FINE, "Writing the following JSON to response output stream:\n" + json);
outputStream.write(json.getBytes());
} else {
- jsonMapper.writeValue(outputStream, entity);
+ writer.writeValue(outputStream, entity);
}
}
diff --git a/container-core/src/main/java/com/yahoo/restapi/MessageResponse.java b/container-core/src/main/java/com/yahoo/restapi/MessageResponse.java
index 32ea3ae708f..43ca0dab29e 100644
--- a/container-core/src/main/java/com/yahoo/restapi/MessageResponse.java
+++ b/container-core/src/main/java/com/yahoo/restapi/MessageResponse.java
@@ -14,6 +14,10 @@ public class MessageResponse extends SlimeJsonResponse {
super(slime(message));
}
+ public MessageResponse(int statusCode, String message) {
+ super(statusCode, slime(message));
+ }
+
private static Slime slime(String message) {
var slime = new Slime();
slime.setObject().setString("message", message);
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..08bac710001
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/restapi/RestApi.java
@@ -0,0 +1,115 @@
+// 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.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();
+ RequestContent requestContentOrThrow();
+
+ 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();
+ byte[] consumeByteArray();
+ String consumeString();
+ JsonNode consumeJsonNode();
+ Slime consumeSlime();
+ <T extends JacksonRequestEntity> T consumeJacksonEntity(Class<T> type);
+ }
+ }
+
+ interface FilterContext {
+ RequestContext requestContext();
+ String route();
+ HttpResponse executeNext();
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/restapi/RestApiException.java b/container-core/src/main/java/com/yahoo/restapi/RestApiException.java
new file mode 100644
index 00000000000..ac3aa647b87
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/restapi/RestApiException.java
@@ -0,0 +1,68 @@
+// 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 java.util.function.Function;
+
+/**
+ * A {@link RuntimeException} that represents a http response.
+ *
+ * @author bjorncs
+ */
+public class RestApiException extends RuntimeException {
+ private final int statusCode;
+ private final HttpResponse response;
+
+ public RestApiException(int statusCode, String errorType, String message) {
+ this(new ErrorResponse(statusCode, errorType, message), message, null);
+ }
+
+ public RestApiException(HttpResponse response, String message) {
+ this(response, message, null);
+ }
+
+ public RestApiException(int statusCode, String errorType, String message, Throwable cause) {
+ this(new ErrorResponse(statusCode, errorType, message), message, cause);
+ }
+
+ public RestApiException(HttpResponse response, String message, Throwable cause) {
+ super(message, cause);
+ this.statusCode = response.getStatus();
+ this.response = response;
+ }
+
+ private RestApiException(Function<String, HttpResponse> responseFromMessage, String message, Throwable cause) {
+ this(responseFromMessage.apply(message), message, cause);
+ }
+
+ public int statusCode() { return statusCode; }
+ public HttpResponse response() { return response; }
+
+ public static class NotFoundException extends RestApiException {
+ public NotFoundException() { super(ErrorResponse::notFoundError, "Not Found", null); }
+ }
+
+ public static class MethodNotAllowed extends RestApiException {
+ public MethodNotAllowed() { super(ErrorResponse::methodNotAllowed, "Method not allowed", null); }
+ public MethodNotAllowed(HttpRequest request) {
+ super(ErrorResponse::methodNotAllowed, "Method '" + request.getMethod().name() + "' is not allowed", null);
+ }
+ }
+
+ public static class BadRequest extends RestApiException {
+ public BadRequest(String message) { super(ErrorResponse::badRequest, message, null); }
+ public BadRequest(String message, Throwable cause) { super(ErrorResponse::badRequest, message, cause); }
+ }
+
+ public static class InternalServerError extends RestApiException {
+ public InternalServerError(String message) { super(ErrorResponse::internalServerError, message, null); }
+ public InternalServerError(String message, Throwable cause) { super(ErrorResponse::internalServerError, message, cause); }
+ }
+
+ public static class Forbidden extends RestApiException {
+ public Forbidden(String message) { super(ErrorResponse::forbidden, message, null); }
+ public Forbidden(String message, Throwable cause) { super(ErrorResponse::forbidden, message, cause); }
+ }
+}
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..eeabcc3fc74
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/restapi/RestApiImpl.java
@@ -0,0 +1,392 @@
+// 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.JsonMappingException;
+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 com.yahoo.yolean.Exceptions;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Map;
+import java.util.Optional;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * @author bjorncs
+ */
+class RestApiImpl implements RestApi {
+
+ private static final Logger log = Logger.getLogger(RestApiImpl.class.getName());
+
+ 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); }
+ @Override public RequestContent requestContentOrThrow() {
+ return requestContent().orElseThrow(() -> new RestApiException.BadRequest("Request content missing"));
+ }
+
+ 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; }
+ @Override public byte[] consumeByteArray() { return convertIoException(() -> inputStream().readAllBytes()); }
+ @Override public String consumeString() { return new String(consumeByteArray(), StandardCharsets.UTF_8); }
+
+ @Override
+ public JsonNode consumeJsonNode() {
+ return convertIoException(() -> {
+ try {
+ if (log.isLoggable(Level.FINE)) {
+ String content = consumeString();
+ log.fine(() -> "Request content: " + content);
+ return jacksonJsonMapper.readTree(content);
+ } else {
+ return jacksonJsonMapper.readTree(request.getData());
+ }
+ } catch (com.fasterxml.jackson.core.JsonParseException e) {
+ log.log(Level.FINE, e.getMessage(), e);
+ throw new RestApiException.BadRequest("Invalid json request content: " + Exceptions.toMessageString(e), e);
+ }
+ });
+ }
+
+ @Override
+ public Slime consumeSlime() {
+ try {
+ String content = consumeString();
+ log.fine(() -> "Request content: " + content);
+ return SlimeUtils.jsonToSlimeOrThrow(content);
+ } catch (com.yahoo.slime.JsonParseException e) {
+ log.log(Level.FINE, e.getMessage(), e);
+ throw new RestApiException.BadRequest("Invalid json request content: " + Exceptions.toMessageString(e), e);
+ }
+ }
+
+ @Override
+ public <T extends JacksonRequestEntity> T consumeJacksonEntity(Class<T> type) {
+ return convertIoException(() -> {
+ try {
+ if (log.isLoggable(Level.FINE)) {
+ String content = consumeString();
+ log.fine(() -> "Request content: " + content);
+ return jacksonJsonMapper.readValue(content, type);
+ } else {
+ return jacksonJsonMapper.readValue(request.getData(), type);
+ }
+ } catch (com.fasterxml.jackson.core.JsonParseException | JsonMappingException e) {
+ log.log(Level.FINE, e.getMessage(), e);
+ throw new RestApiException.BadRequest("Invalid json request content: " + Exceptions.toMessageString(e), e);
+ }
+ });
+ }
+ }
+
+ 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); }
+ }
+
+ @FunctionalInterface private interface SupplierThrowingIoException<T> { T get() throws IOException; }
+ private static <T> T convertIoException(SupplierThrowingIoException<T> supplier) {
+ try {
+ return supplier.get();
+ } catch (IOException e) {
+ throw new RestApiException.InternalServerError("Failed to read request content: " + Exceptions.toMessageString(e), e);
+ }
+ }
+ }
+
+ 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..16cc2353986
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/restapi/RestApiImplTest.java
@@ -0,0 +1,125 @@
+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.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 -> ctx.requestContent().get().consumeJacksonEntity(TestEntity.class)))
+ .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