diff options
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 |