-
Notifications
You must be signed in to change notification settings - Fork 79
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open a RESTful API for Styx Object Store #406
Changes from all commits
2fcc216
74b8d1c
4cceb8e
2f47b1e
95b86ad
08b00e2
e709d5f
ab7c34f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
/* | ||
Copyright (C) 2013-2019 Expedia Inc. | ||
|
||
Licensed under the Apache License, Version 2.0 (the "License"); | ||
you may not use this file except in compliance with the License. | ||
You may obtain a copy of the License at | ||
|
||
http://www.apache.org/licenses/LICENSE-2.0 | ||
|
||
Unless required by applicable law or agreed to in writing, software | ||
distributed under the License is distributed on an "AS IS" BASIS, | ||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
See the License for the specific language governing permissions and | ||
limitations under the License. | ||
*/ | ||
package com.hotels.styx.admin.handlers; | ||
|
||
import com.fasterxml.jackson.annotation.JsonProperty; | ||
import com.fasterxml.jackson.core.JsonProcessingException; | ||
import com.fasterxml.jackson.databind.JsonNode; | ||
import com.fasterxml.jackson.databind.ObjectMapper; | ||
import com.fasterxml.jackson.databind.node.ObjectNode; | ||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; | ||
import com.hotels.styx.api.Eventual; | ||
import com.hotels.styx.api.HttpHandler; | ||
import com.hotels.styx.api.HttpInterceptor; | ||
import com.hotels.styx.api.HttpRequest; | ||
import com.hotels.styx.api.HttpResponse; | ||
import com.hotels.styx.api.LiveHttpRequest; | ||
import com.hotels.styx.api.LiveHttpResponse; | ||
import com.hotels.styx.routing.RoutingObjectRecord; | ||
import com.hotels.styx.routing.config.RoutingObjectDefinition; | ||
import com.hotels.styx.routing.config.RoutingObjectFactory; | ||
import com.hotels.styx.routing.db.StyxObjectStore; | ||
import org.slf4j.Logger; | ||
|
||
import java.io.IOException; | ||
import java.util.List; | ||
|
||
import static com.fasterxml.jackson.core.JsonParser.Feature.AUTO_CLOSE_SOURCE; | ||
import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; | ||
import static com.hotels.styx.admin.handlers.UrlPatternRouter.placeholders; | ||
import static com.hotels.styx.api.HttpResponse.response; | ||
import static com.hotels.styx.api.HttpResponseStatus.BAD_REQUEST; | ||
import static com.hotels.styx.api.HttpResponseStatus.CREATED; | ||
import static com.hotels.styx.api.HttpResponseStatus.NOT_FOUND; | ||
import static com.hotels.styx.api.HttpResponseStatus.OK; | ||
import static com.hotels.styx.infrastructure.configuration.json.ObjectMappers.addStyxMixins; | ||
import static java.nio.charset.StandardCharsets.UTF_8; | ||
import static java.util.Collections.emptyList; | ||
import static java.util.stream.Collectors.joining; | ||
import static org.slf4j.LoggerFactory.getLogger; | ||
|
||
/** | ||
* Provides admin interface access to Styx routing configuration. | ||
*/ | ||
public class RoutingObjectHandler implements HttpHandler { | ||
private static final Logger LOGGER = getLogger(RoutingObjectHandler.class); | ||
|
||
private static final ObjectMapper YAML_MAPPER = addStyxMixins(new ObjectMapper(new YAMLFactory())) | ||
.configure(FAIL_ON_UNKNOWN_PROPERTIES, false) | ||
.configure(AUTO_CLOSE_SOURCE, true); | ||
|
||
private final UrlPatternRouter urlRouter; | ||
|
||
|
||
public RoutingObjectHandler(StyxObjectStore<RoutingObjectRecord> routeDatabase, RoutingObjectFactory objectFactory) { | ||
urlRouter = new UrlPatternRouter.Builder() | ||
.get("/admin/routing/objects", httpHandler((request, context) -> { | ||
String output = routeDatabase.entrySet() | ||
.stream() | ||
.map(entry -> serialise(entry.getFirst(), entry.getSecond())) | ||
.collect(joining("\n")); | ||
|
||
return Eventual.of(response(OK) | ||
.body(output, UTF_8) | ||
.build()); | ||
})) | ||
.get("/admin/routing/objects/:objectName", httpHandler((request, context) -> { | ||
String name = placeholders(context).get("objectName"); | ||
|
||
try { | ||
String object = routeDatabase.get(name) | ||
.map(record -> serialise(name, record)) | ||
.orElseThrow(ResourceNotFoundException::new); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are we logging the name of the resource that couldn't be found anywhere? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No. |
||
|
||
return Eventual.of(response(OK).body(object, UTF_8).build()); | ||
} catch (ResourceNotFoundException e) { | ||
return Eventual.of(response(NOT_FOUND).build()); | ||
} | ||
})) | ||
.put("/admin/routing/objects/:objectName", httpHandler((request, context) -> { | ||
String body = request.bodyAs(UTF_8); | ||
String name = placeholders(context).get("objectName"); | ||
|
||
try { | ||
RoutingObjectDefinition payload = YAML_MAPPER.readValue(body, RoutingObjectDefinition.class); | ||
HttpHandler httpHandler = objectFactory.build(emptyList(), payload); | ||
|
||
routeDatabase.insert(name, new RoutingObjectRecord(payload.type(), payload.config(), httpHandler)); | ||
|
||
return Eventual.of(response(CREATED).build()); | ||
} catch (IOException | RuntimeException cause) { | ||
return Eventual.of(response(BAD_REQUEST).body(cause.toString(), UTF_8).build()); | ||
} | ||
})) | ||
.delete("/admin/routing/objects/:objectName", httpHandler((request, context) -> { | ||
String name = placeholders(context).get("objectName"); | ||
|
||
if (!routeDatabase.get(name).isPresent()) { | ||
return Eventual.of(response(NOT_FOUND).build()); | ||
} | ||
|
||
routeDatabase.remove(name); | ||
return Eventual.of(response(OK).build()); | ||
})) | ||
.build(); | ||
} | ||
|
||
private static String serialise(String name, RoutingObjectRecord app) { | ||
JsonNode node = YAML_MAPPER | ||
.addMixIn(RoutingObjectDefinition.class, RoutingObjectDefMixin.class) | ||
.valueToTree(new RoutingObjectDefinition(name, app.getType(), emptyList(), app.getConfig())); | ||
|
||
((ObjectNode) node).set("config", app.getConfig()); | ||
|
||
try { | ||
return YAML_MAPPER.writeValueAsString(node); | ||
} catch (JsonProcessingException e) { | ||
throw new RuntimeException(e); | ||
} | ||
} | ||
|
||
private static HttpHandler httpHandler(FullHttpHandler delegate) { | ||
return (request, context) -> request.aggregate(1000000) | ||
.flatMap(fullRequest -> delegate.handle(fullRequest, context)) | ||
.map(HttpResponse::stream); | ||
} | ||
|
||
@Override | ||
public Eventual<LiveHttpResponse> handle(LiveHttpRequest request, HttpInterceptor.Context context) { | ||
return urlRouter.handle(request, context); | ||
} | ||
|
||
private interface FullHttpHandler { | ||
Eventual<HttpResponse> handle(HttpRequest request, HttpInterceptor.Context context); | ||
} | ||
|
||
private static class ResourceNotFoundException extends RuntimeException { } | ||
|
||
private abstract static class RoutingObjectDefMixin { | ||
@JsonProperty("name") | ||
public abstract String name(); | ||
|
||
@JsonProperty("type") | ||
public abstract String type(); | ||
|
||
@JsonProperty("tags") | ||
public abstract List<String> tags(); | ||
} | ||
|
||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,169 @@ | ||
/* | ||
Copyright (C) 2013-2019 Expedia Inc. | ||
|
||
Licensed under the Apache License, Version 2.0 (the "License"); | ||
you may not use this file except in compliance with the License. | ||
You may obtain a copy of the License at | ||
|
||
http://www.apache.org/licenses/LICENSE-2.0 | ||
|
||
Unless required by applicable law or agreed to in writing, software | ||
distributed under the License is distributed on an "AS IS" BASIS, | ||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
See the License for the specific language governing permissions and | ||
limitations under the License. | ||
*/ | ||
package com.hotels.styx.admin.handlers; | ||
|
||
import com.google.common.collect.ImmutableList; | ||
import com.hotels.styx.api.Eventual; | ||
import com.hotels.styx.api.HttpHandler; | ||
import com.hotels.styx.api.HttpInterceptor; | ||
import com.hotels.styx.api.HttpMethod; | ||
import com.hotels.styx.api.LiveHttpRequest; | ||
import com.hotels.styx.api.LiveHttpResponse; | ||
import org.slf4j.Logger; | ||
|
||
import java.util.ArrayList; | ||
import java.util.LinkedList; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.regex.Matcher; | ||
import java.util.regex.Pattern; | ||
|
||
import static com.hotels.styx.api.HttpMethod.DELETE; | ||
import static com.hotels.styx.api.HttpMethod.GET; | ||
import static com.hotels.styx.api.HttpMethod.POST; | ||
import static com.hotels.styx.api.HttpMethod.PUT; | ||
import static com.hotels.styx.api.HttpResponseStatus.INTERNAL_SERVER_ERROR; | ||
import static com.hotels.styx.api.HttpResponseStatus.NOT_FOUND; | ||
import static com.hotels.styx.api.LiveHttpResponse.response; | ||
import static java.util.stream.Collectors.toMap; | ||
import static org.slf4j.LoggerFactory.getLogger; | ||
|
||
/** | ||
* A configurable router. | ||
*/ | ||
public class UrlPatternRouter implements HttpHandler { | ||
private static final Logger LOGGER = getLogger(UrlPatternRouter.class); | ||
private static final String PLACEHOLDERS_KEY = "UrlRouter.placeholders"; | ||
private final List<RouteDescriptor> alternatives; | ||
|
||
private UrlPatternRouter(List<RouteDescriptor> alternatives) { | ||
this.alternatives = ImmutableList.copyOf(alternatives); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. static import? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Normally There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To clarify, we try to use static imports when the key information is in the method name, which makes the class name just clutter. In this case, the method name alone is insufficient to demonstrate that we are instantiating an immutable list. |
||
} | ||
|
||
public static Map<String, String> placeholders(HttpInterceptor.Context context) { | ||
return context.getIfAvailable(PLACEHOLDERS_KEY, Map.class).get(); | ||
} | ||
|
||
@Override | ||
public Eventual<LiveHttpResponse> handle(LiveHttpRequest request, HttpInterceptor.Context context) { | ||
for (RouteDescriptor route : alternatives) { | ||
if (request.method().equals(route.method())) { | ||
Matcher match = route.uriPattern().matcher(request.path()); | ||
|
||
LOGGER.debug("Request path '{}' matching against route pattern '{}' matches: {}", new Object[] { | ||
request.path(), route.uriPattern(), match.matches()}); | ||
|
||
if (match.matches()) { | ||
Map<String, String> placeholders = route.placeholderNames().stream() | ||
.collect(toMap(name -> name, match::group)); | ||
|
||
context.add(PLACEHOLDERS_KEY, placeholders); | ||
|
||
try { | ||
return route.handler().handle(request, context); | ||
} catch (Exception cause) { | ||
LOGGER.error("ERROR: {} {}", new Object[] {request.method(), request.path(), cause}); | ||
return Eventual.of(response(INTERNAL_SERVER_ERROR).build()); | ||
} | ||
} | ||
} | ||
} | ||
|
||
return Eventual.of(response(NOT_FOUND).build()); | ||
} | ||
|
||
/** | ||
* A builder class. | ||
*/ | ||
public static class Builder { | ||
private final List<RouteDescriptor> alternatives = new LinkedList<>(); | ||
|
||
public Builder get(String uriPattern, HttpHandler handler) { | ||
alternatives.add(new RouteDescriptor(GET, uriPattern, handler)); | ||
return this; | ||
} | ||
|
||
public Builder post(String uriPattern, HttpHandler handler) { | ||
alternatives.add(new RouteDescriptor(POST, uriPattern, handler)); | ||
return this; | ||
} | ||
|
||
public Builder put(String uriPattern, HttpHandler handler) { | ||
alternatives.add(new RouteDescriptor(PUT, uriPattern, handler)); | ||
return this; | ||
} | ||
|
||
public Builder delete(String uriPattern, HttpHandler handler) { | ||
alternatives.add(new RouteDescriptor(DELETE, uriPattern, handler)); | ||
return this; | ||
} | ||
|
||
mikkokar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
public UrlPatternRouter build() { | ||
return new UrlPatternRouter(alternatives); | ||
} | ||
} | ||
|
||
private static class RouteDescriptor { | ||
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile(":([a-zA-Z0-9-_]+)"); | ||
|
||
private final HttpMethod method; | ||
private final Pattern uriPattern; | ||
private final HttpHandler handler; | ||
private final List<String> placeholderNames; | ||
|
||
public RouteDescriptor(HttpMethod method, String uriPattern, HttpHandler handler) { | ||
this.method = method; | ||
this.handler = handler; | ||
this.placeholderNames = placeholders(uriPattern); | ||
this.uriPattern = compilePattern(uriPattern); | ||
} | ||
|
||
public HttpMethod method() { | ||
return method; | ||
} | ||
|
||
public Pattern uriPattern() { | ||
return uriPattern; | ||
} | ||
|
||
public HttpHandler handler() { | ||
return handler; | ||
} | ||
|
||
public List<String> placeholderNames() { | ||
return placeholderNames; | ||
} | ||
|
||
private static Pattern compilePattern(String pattern) { | ||
Matcher matcher = PLACEHOLDER_PATTERN.matcher(pattern); | ||
|
||
return Pattern.compile(matcher.replaceAll("(?<$1>[a-zA-Z0-9-_]+)")); | ||
dvlato marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
private static List<String> placeholders(String pattern) { | ||
Matcher matcher = PLACEHOLDER_PATTERN.matcher(pattern); | ||
|
||
List<String> outcome = new ArrayList<>(); | ||
|
||
while (matcher.find()) { | ||
outcome.add(matcher.group(1)); | ||
} | ||
|
||
return outcome; | ||
} | ||
|
||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
/admin/routing
and/admin/routing/
are not considered equivalent, we should make a change to thehttpRouter
instead of duplicating code.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is necessary because
StandardHttpRouter
treats the two endpoints differently. But I'll have another look.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see we have this also for the metrics handler, but not for any others (
httpRouter.add("/admin/metrics", metricsHandler);
httpRouter.add("/admin/metrics/", metricsHandler);
)
I think this needs reviewing to identify any fixes we might need. I would say fixes can go in another PR.