diff options
author | Martin Polden <mpolden@mpolden.no> | 2019-07-11 14:13:23 +0200 |
---|---|---|
committer | Martin Polden <mpolden@mpolden.no> | 2019-07-12 09:37:46 +0200 |
commit | b81b21546cdff92d360cbdf7dda27e6ed7bc7170 (patch) | |
tree | 1eead4f28363772088c56fbb7a89c76360c6fc7c /flags | |
parent | 1c79079945c56fa91de8427fbc8f2170eec9ed8c (diff) |
Decouple flags REST API from config server
Diffstat (limited to 'flags')
14 files changed, 832 insertions, 1 deletions
diff --git a/flags/pom.xml b/flags/pom.xml index c1e9eca20ab..7ef082cc1bc 100644 --- a/flags/pom.xml +++ b/flags/pom.xml @@ -59,7 +59,18 @@ <classifier>no_aop</classifier> <scope>provided</scope> </dependency> - + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>container-dev</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>zkfacade</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> diff --git a/flags/src/main/java/com/yahoo/vespa/flags/http/DefinedFlag.java b/flags/src/main/java/com/yahoo/vespa/flags/http/DefinedFlag.java new file mode 100644 index 00000000000..8234e9df725 --- /dev/null +++ b/flags/src/main/java/com/yahoo/vespa/flags/http/DefinedFlag.java @@ -0,0 +1,47 @@ +// Copyright 2019 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.flags.http; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.jdisc.Response; +import com.yahoo.vespa.flags.FlagDefinition; +import com.yahoo.vespa.flags.json.DimensionHelper; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * @author hakonhall + */ +public class DefinedFlag extends HttpResponse { + private static ObjectMapper mapper = new ObjectMapper(); + + private final FlagDefinition flagDefinition; + + public DefinedFlag(FlagDefinition flagDefinition) { + super(Response.Status.OK); + this.flagDefinition = flagDefinition; + } + + @Override + public void render(OutputStream outputStream) throws IOException { + ObjectNode rootNode = mapper.createObjectNode(); + renderFlagDefinition(flagDefinition, rootNode); + mapper.writeValue(outputStream, rootNode); + } + + static void renderFlagDefinition(FlagDefinition flagDefinition, ObjectNode definitionNode) { + definitionNode.put("description", flagDefinition.getDescription()); + definitionNode.put("modification-effect", flagDefinition.getModificationEffect()); + ArrayNode dimensionsNode = definitionNode.putArray("dimensions"); + flagDefinition.getDimensions().forEach(dimension -> dimensionsNode.add(DimensionHelper.toWire(dimension))); + } + + @Override + public String getContentType() { + return "application/json"; + } + +} diff --git a/flags/src/main/java/com/yahoo/vespa/flags/http/DefinedFlags.java b/flags/src/main/java/com/yahoo/vespa/flags/http/DefinedFlags.java new file mode 100644 index 00000000000..e1db7dda6e0 --- /dev/null +++ b/flags/src/main/java/com/yahoo/vespa/flags/http/DefinedFlags.java @@ -0,0 +1,43 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.flags.http; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.jdisc.Response; +import com.yahoo.vespa.flags.FlagDefinition; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Comparator; +import java.util.List; + +/** + * @author hakonhall + */ +public class DefinedFlags extends HttpResponse { + private static ObjectMapper mapper = new ObjectMapper(); + private static final Comparator<FlagDefinition> sortByFlagId = Comparator.comparing(flagDefinition -> flagDefinition.getUnboundFlag().id()); + + private final List<FlagDefinition> flags; + + public DefinedFlags(List<FlagDefinition> flags) { + super(Response.Status.OK); + this.flags = flags; + } + + @Override + public void render(OutputStream outputStream) throws IOException { + ObjectNode rootNode = mapper.createObjectNode(); + flags.stream().sorted(sortByFlagId).forEach(flagDefinition -> { + ObjectNode definitionNode = rootNode.putObject(flagDefinition.getUnboundFlag().id().toString()); + DefinedFlag.renderFlagDefinition(flagDefinition, definitionNode); + }); + mapper.writeValue(outputStream, rootNode); + } + + @Override + public String getContentType() { + return "application/json"; + } +} diff --git a/flags/src/main/java/com/yahoo/vespa/flags/http/ErrorResponse.java b/flags/src/main/java/com/yahoo/vespa/flags/http/ErrorResponse.java new file mode 100644 index 00000000000..969903093a4 --- /dev/null +++ b/flags/src/main/java/com/yahoo/vespa/flags/http/ErrorResponse.java @@ -0,0 +1,66 @@ +// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.flags.http; + +import com.yahoo.slime.Cursor; +import com.yahoo.slime.Slime; + +import static com.yahoo.jdisc.Response.Status.BAD_REQUEST; +import static com.yahoo.jdisc.Response.Status.FORBIDDEN; +import static com.yahoo.jdisc.Response.Status.INTERNAL_SERVER_ERROR; +import static com.yahoo.jdisc.Response.Status.METHOD_NOT_ALLOWED; +import static com.yahoo.jdisc.Response.Status.NOT_FOUND; +import static com.yahoo.jdisc.Response.Status.UNAUTHORIZED; + +/** + * A HTTP JSON response containing an error code and a message + * + * @author bratseth + */ +public class ErrorResponse extends SlimeJsonResponse { + + public enum errorCodes { + NOT_FOUND, + BAD_REQUEST, + FORBIDDEN, + METHOD_NOT_ALLOWED, + INTERNAL_SERVER_ERROR, + UNAUTHORIZED + } + + public ErrorResponse(int statusCode, String errorType, String message) { + super(statusCode, asSlimeMessage(errorType, message)); + } + + private static Slime asSlimeMessage(String errorType, String message) { + Slime slime = new Slime(); + Cursor root = slime.setObject(); + root.setString("error-code", errorType); + root.setString("message", message); + return slime; + } + + public static ErrorResponse notFoundError(String message) { + return new ErrorResponse(NOT_FOUND, errorCodes.NOT_FOUND.name(), message); + } + + public static ErrorResponse internalServerError(String message) { + return new ErrorResponse(INTERNAL_SERVER_ERROR, errorCodes.INTERNAL_SERVER_ERROR.name(), message); + } + + public static ErrorResponse badRequest(String message) { + return new ErrorResponse(BAD_REQUEST, errorCodes.BAD_REQUEST.name(), message); + } + + public static ErrorResponse forbidden(String message) { + return new ErrorResponse(FORBIDDEN, errorCodes.FORBIDDEN.name(), message); + } + + public static ErrorResponse unauthorized(String message) { + return new ErrorResponse(UNAUTHORIZED, errorCodes.UNAUTHORIZED.name(), message); + } + + public static ErrorResponse methodNotAllowed(String message) { + return new ErrorResponse(METHOD_NOT_ALLOWED, errorCodes.METHOD_NOT_ALLOWED.name(), message); + } + +} diff --git a/flags/src/main/java/com/yahoo/vespa/flags/http/FlagDataListResponse.java b/flags/src/main/java/com/yahoo/vespa/flags/http/FlagDataListResponse.java new file mode 100644 index 00000000000..5af97007997 --- /dev/null +++ b/flags/src/main/java/com/yahoo/vespa/flags/http/FlagDataListResponse.java @@ -0,0 +1,58 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.flags.http; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.jdisc.Response; +import com.yahoo.vespa.flags.FlagId; +import com.yahoo.vespa.flags.json.FlagData; +import com.yahoo.vespa.flags.json.wire.WireFlagDataList; + +import java.io.OutputStream; +import java.util.Map; +import java.util.TreeMap; + +import static com.yahoo.yolean.Exceptions.uncheck; + +/** + * @author hakonhall + */ +public class FlagDataListResponse extends HttpResponse { + private static ObjectMapper mapper = new ObjectMapper(); + + private final String flagsV1Uri; + private final TreeMap<FlagId, FlagData> flags; + private final boolean recursive; + + public FlagDataListResponse(String flagsV1Uri, Map<FlagId, FlagData> flags, boolean recursive) { + super(Response.Status.OK); + this.flagsV1Uri = flagsV1Uri; + this.flags = new TreeMap<>(flags); + this.recursive = recursive; + } + + @Override + public void render(OutputStream outputStream) { + if (recursive) { + WireFlagDataList list = new WireFlagDataList(); + flags.values().forEach(flagData -> list.flags.add(flagData.toWire())); + list.serializeToOutputStream(outputStream); + } else { + ObjectNode rootNode = mapper.createObjectNode(); + ArrayNode flagsArray = rootNode.putArray("flags"); + flags.forEach((flagId, flagData) -> { + ObjectNode object = flagsArray.addObject(); + object.put("id", flagId.toString()); + object.put("url", flagsV1Uri + "/data/" + flagId.toString()); + }); + uncheck(() -> mapper.writeValue(outputStream, rootNode)); + } + } + + @Override + public String getContentType() { + return "application/json"; + } +} diff --git a/flags/src/main/java/com/yahoo/vespa/flags/http/FlagDataResponse.java b/flags/src/main/java/com/yahoo/vespa/flags/http/FlagDataResponse.java new file mode 100644 index 00000000000..f6e81e030c7 --- /dev/null +++ b/flags/src/main/java/com/yahoo/vespa/flags/http/FlagDataResponse.java @@ -0,0 +1,30 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.flags.http; + +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.jdisc.Response; +import com.yahoo.vespa.flags.json.FlagData; + +import java.io.OutputStream; + +/** + * @author hakonhall + */ +public class FlagDataResponse extends HttpResponse { + private final FlagData data; + + FlagDataResponse(FlagData data) { + super(Response.Status.OK); + this.data = data; + } + + @Override + public void render(OutputStream outputStream) { + data.serializeToOutputStream(outputStream); + } + + @Override + public String getContentType() { + return "application/json"; + } +} diff --git a/flags/src/main/java/com/yahoo/vespa/flags/http/FlagsHandler.java b/flags/src/main/java/com/yahoo/vespa/flags/http/FlagsHandler.java new file mode 100644 index 00000000000..76f74cbe931 --- /dev/null +++ b/flags/src/main/java/com/yahoo/vespa/flags/http/FlagsHandler.java @@ -0,0 +1,138 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.flags.http; + +import com.google.inject.Inject; +import com.yahoo.container.jdisc.HttpRequest; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.container.jdisc.LoggingRequestHandler; +import com.yahoo.log.LogLevel; +import com.yahoo.restapi.Path; +import com.yahoo.vespa.flags.FlagDefinition; +import com.yahoo.vespa.flags.FlagId; +import com.yahoo.vespa.flags.Flags; +import com.yahoo.vespa.flags.json.FlagData; +import com.yahoo.vespa.flags.persistence.FlagsDb; +import com.yahoo.yolean.Exceptions; + +import java.io.UncheckedIOException; +import java.net.URI; +import java.util.Objects; + +/** + * Handles /flags/v1 requests + * + * @author hakonhall + */ +public class FlagsHandler extends LoggingRequestHandler { + + private final FlagsDb flagsDb; + + @Inject + public FlagsHandler(LoggingRequestHandler.Context context, FlagsDb flagsDb) { + super(context); + this.flagsDb = flagsDb; + } + + @Override + public HttpResponse handle(HttpRequest request) { + try { + switch (request.getMethod()) { + case GET: return handleGET(request); + case DELETE: return handleDELETE(request); + case PUT: return handlePUT(request); + default: return ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is not supported"); + } + } + catch (IllegalArgumentException e) { + return ErrorResponse.badRequest(Exceptions.toMessageString(e)); + } + catch (RuntimeException e) { + log.log(LogLevel.WARNING, "Unexpected error handling '" + request.getUri() + "'", e); + return ErrorResponse.internalServerError(Exceptions.toMessageString(e)); + } + } + + private HttpResponse handleGET(HttpRequest request) { + Path path = new Path(request.getUri()); + if (path.matches("/flags/v1")) return new V1Response(flagsV1Uri(request), "data", "defined"); + if (path.matches("/flags/v1/data")) return getFlagDataList(request); + if (path.matches("/flags/v1/data/{flagId}")) return getFlagData(findFlagId(request, path)); + if (path.matches("/flags/v1/defined")) return new DefinedFlags(Flags.getAllFlags()); + if (path.matches("/flags/v1/defined/{flagId}")) return getDefinedFlag(findFlagId(request, path)); + return ErrorResponse.notFoundError("Nothing at path '" + path + "'"); + } + + private HttpResponse handlePUT(HttpRequest request) { + Path path = new Path(request.getUri()); + if (path.matches("/flags/v1/data/{flagId}")) return putFlagData(request, findFlagId(request, path)); + return ErrorResponse.notFoundError("Nothing at path '" + path + "'"); + } + + private HttpResponse handleDELETE(HttpRequest request) { + Path path = new Path(request.getUri()); + if (path.matches("/flags/v1/data/{flagId}")) return deleteFlagData(findFlagId(request, path)); + return ErrorResponse.notFoundError("Nothing at path '" + path + "'"); + } + + private String flagsV1Uri(HttpRequest request) { + URI uri = request.getUri(); + String port = uri.getPort() < 0 ? "" : ":" + uri.getPort(); + return uri.getScheme() + "://" + uri.getHost() + port + "/flags/v1"; + } + + private HttpResponse getDefinedFlag(FlagId flagId) { + var definedFlag = Flags.getFlag(flagId).map(DefinedFlag::new); + if (definedFlag.isPresent()) { + return definedFlag.get(); + } + return ErrorResponse.notFoundError("Flag " + flagId + " not defined"); + } + + private HttpResponse getFlagDataList(HttpRequest request) { + return new FlagDataListResponse(flagsV1Uri(request), flagsDb.getAllFlags(), + Objects.equals(request.getProperty("recursive"), "true")); + } + + private HttpResponse getFlagData(FlagId flagId) { + var data = flagsDb.getValue(flagId).map(FlagDataResponse::new); + if (data.isPresent()) { + return data.get(); + } + return ErrorResponse.notFoundError("Flag " + flagId + " not set"); + } + + private HttpResponse putFlagData(HttpRequest request, FlagId flagId) { + FlagData data; + try { + data = FlagData.deserialize(request.getData()); + } catch (UncheckedIOException e) { + return ErrorResponse.badRequest("Failed to deserialize request data: " + Exceptions.toMessageString(e)); + } + + if (!isForce(request)) { + FlagDefinition definition = Flags.getFlag(flagId).get(); // FlagId has been validated in findFlagId() + data.validate(definition.getUnboundFlag().serializer()); + } + + flagsDb.setValue(flagId, data); + return new OKResponse(); + } + + private HttpResponse deleteFlagData(FlagId flagId) { + flagsDb.removeValue(flagId); + return new OKResponse(); + } + + private FlagId findFlagId(HttpRequest request, Path path) { + FlagId flagId = new FlagId(path.get("flagId")); + if (!isForce(request) && Flags.getFlag(flagId).isEmpty()) { + throw new IllegalArgumentException("There is no flag '" + flagId + "' (use ?force=true to override)"); + } + return flagId; + } + + private boolean isForce(HttpRequest request) { + return Objects.equals(request.getProperty("force"), "true"); + } + +} diff --git a/flags/src/main/java/com/yahoo/vespa/flags/http/OKResponse.java b/flags/src/main/java/com/yahoo/vespa/flags/http/OKResponse.java new file mode 100644 index 00000000000..d094e2d5734 --- /dev/null +++ b/flags/src/main/java/com/yahoo/vespa/flags/http/OKResponse.java @@ -0,0 +1,19 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.flags.http; + +import com.yahoo.container.jdisc.EmptyResponse; +import com.yahoo.jdisc.Response; + +/** + * @author hakonhall + */ +public class OKResponse extends EmptyResponse { + public OKResponse() { + super(Response.Status.OK); + } + + @Override + public String getContentType() { + return "application/json"; + } +} diff --git a/flags/src/main/java/com/yahoo/vespa/flags/http/SlimeJsonResponse.java b/flags/src/main/java/com/yahoo/vespa/flags/http/SlimeJsonResponse.java new file mode 100644 index 00000000000..dd71795ae43 --- /dev/null +++ b/flags/src/main/java/com/yahoo/vespa/flags/http/SlimeJsonResponse.java @@ -0,0 +1,38 @@ +// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.flags.http; + +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.slime.JsonFormat; +import com.yahoo.slime.Slime; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * A generic Json response using Slime for JSON encoding + * + * @author bratseth + */ +public class SlimeJsonResponse extends HttpResponse { + + private final Slime slime; + + public SlimeJsonResponse(Slime slime) { + super(200); + this.slime = slime; + } + + public SlimeJsonResponse(int statusCode, Slime slime) { + super(statusCode); + this.slime = slime; + } + + @Override + public void render(OutputStream stream) throws IOException { + new JsonFormat(true).encode(stream, slime); + } + + @Override + public String getContentType() { return "application/json"; } + +} diff --git a/flags/src/main/java/com/yahoo/vespa/flags/http/V1Response.java b/flags/src/main/java/com/yahoo/vespa/flags/http/V1Response.java new file mode 100644 index 00000000000..e8ff0bd99a4 --- /dev/null +++ b/flags/src/main/java/com/yahoo/vespa/flags/http/V1Response.java @@ -0,0 +1,46 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.flags.http; + +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.jdisc.Response; +import com.yahoo.slime.Cursor; +import com.yahoo.slime.JsonFormat; +import com.yahoo.slime.Slime; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; + +/** + * @author hakonhall + */ +public class V1Response extends HttpResponse { + + private final Slime slime; + + public V1Response(String flagsV1Uri, String... names) { + super(Response.Status.OK); + this.slime = generateBody(flagsV1Uri, List.of(names)); + } + + @Override + public void render(OutputStream stream) throws IOException { + new JsonFormat(true).encode(stream, slime); + } + + @Override + public String getContentType() { + return "application/json"; + } + + private static Slime generateBody(String flagsV1Uri, List<String> names) { + Slime slime = new Slime(); + Cursor root = slime.setObject(); + names.forEach(name -> { + Cursor data = root.setObject(name); + data.setString("url", flagsV1Uri + "/" + name); + }); + return slime; + } + +} diff --git a/flags/src/main/java/com/yahoo/vespa/flags/persistence/FlagsDb.java b/flags/src/main/java/com/yahoo/vespa/flags/persistence/FlagsDb.java new file mode 100644 index 00000000000..2ed762f2895 --- /dev/null +++ b/flags/src/main/java/com/yahoo/vespa/flags/persistence/FlagsDb.java @@ -0,0 +1,68 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.flags.persistence; + +import com.google.inject.Inject; +import com.yahoo.path.Path; +import com.yahoo.vespa.curator.Curator; +import com.yahoo.vespa.flags.FlagId; +import com.yahoo.vespa.flags.json.FlagData; +import org.apache.curator.framework.recipes.cache.ChildData; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * @author hakonhall + */ +public class FlagsDb { + + private static final Path ROOT_PATH = Path.fromString("/flags/v1"); + + private final Curator curator; + private final Curator.DirectoryCache cache; + + @Inject + public FlagsDb(Curator curator) { + this.curator = curator; + curator.create(ROOT_PATH); + ExecutorService executorService = Executors.newFixedThreadPool(1); + this.cache = curator.createDirectoryCache(ROOT_PATH.getAbsolute(), true, false, executorService); + cache.start(); + } + + /** Get the String value of the flag. */ + public Optional<FlagData> getValue(FlagId flagId) { + return Optional.ofNullable(cache.getCurrentData(getZkPathFor(flagId))) + .map(ChildData::getData) + .map(FlagData::deserializeUtf8Json); + } + + /** Set the String value of the flag. */ + public void setValue(FlagId flagId, FlagData data) { + curator.set(getZkPathFor(flagId), data.serializeToUtf8Json()); + } + + /** Get all flags that have been set. */ + public Map<FlagId, FlagData> getAllFlags() { + List<ChildData> dataList = cache.getCurrentData(); + return dataList.stream() + .map(ChildData::getData) + .map(FlagData::deserializeUtf8Json) + .collect(Collectors.toMap(FlagData::id, Function.identity())); + } + + /** Remove the flag value if it exists. */ + public void removeValue(FlagId flagId) { + curator.delete(getZkPathFor(flagId)); + } + + private static Path getZkPathFor(FlagId flagId) { + return ROOT_PATH.append(flagId.toString()); + } + +} diff --git a/flags/src/main/java/com/yahoo/vespa/flags/persistence/package-info.java b/flags/src/main/java/com/yahoo/vespa/flags/persistence/package-info.java new file mode 100644 index 00000000000..d4753ed1756 --- /dev/null +++ b/flags/src/main/java/com/yahoo/vespa/flags/persistence/package-info.java @@ -0,0 +1,8 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +/** + * @author mpolden + */ +@ExportPackage +package com.yahoo.vespa.flags.persistence; + +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/flags/src/test/java/com/yahoo/vespa/flags/http/FlagsHandlerTest.java b/flags/src/test/java/com/yahoo/vespa/flags/http/FlagsHandlerTest.java new file mode 100644 index 00000000000..8ae1008ba22 --- /dev/null +++ b/flags/src/test/java/com/yahoo/vespa/flags/http/FlagsHandlerTest.java @@ -0,0 +1,203 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.flags.http; + +import com.yahoo.container.jdisc.HttpRequest; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.jdisc.http.HttpRequest.Method; +import com.yahoo.text.Utf8; +import com.yahoo.vespa.curator.mock.MockCurator; +import com.yahoo.vespa.flags.FetchVector; +import com.yahoo.vespa.flags.FlagId; +import com.yahoo.vespa.flags.Flags; +import com.yahoo.vespa.flags.UnboundBooleanFlag; +import com.yahoo.vespa.flags.persistence.FlagsDb; +import com.yahoo.yolean.Exceptions; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertEquals; + +/** + * @author hakonhall + */ +public class FlagsHandlerTest { + private static final UnboundBooleanFlag FLAG1 = Flags.defineFeatureFlag( + "id1", false, "desc1", "mod1"); + private static final UnboundBooleanFlag FLAG2 = Flags.defineFeatureFlag( + "id2", true, "desc2", "mod2", + FetchVector.Dimension.HOSTNAME, FetchVector.Dimension.APPLICATION_ID); + + private static final String FLAGS_V1_URL = "https://foo.com:4443/flags/v1"; + + private final FlagsDb flagsDb = new FlagsDb(new MockCurator()); + private final FlagsHandler handler = new FlagsHandler(FlagsHandler.testOnlyContext(), flagsDb); + + @Test + public void testV1() { + String expectedResponse = "{" + + Stream.of("data", "defined") + .map(name -> "\"" + name + "\":{\"url\":\"https://foo.com:4443/flags/v1/" + name + "\"}") + .collect(Collectors.joining(",")) + + "}"; + verifySuccessfulRequest(Method.GET, "", "", expectedResponse); + verifySuccessfulRequest(Method.GET, "/", "", expectedResponse); + } + + @Test + public void testDefined() { + try (Flags.Replacer replacer = Flags.clearFlagsForTesting()) { + fixUnusedWarning(replacer); + Flags.defineFeatureFlag("id", false, "desc", "mod", FetchVector.Dimension.HOSTNAME); + verifySuccessfulRequest(Method.GET, "/defined", "", + "{\"id\":{\"description\":\"desc\",\"modification-effect\":\"mod\",\"dimensions\":[\"hostname\"]}}"); + + verifySuccessfulRequest(Method.GET, "/defined/id", "", + "{\"description\":\"desc\",\"modification-effect\":\"mod\",\"dimensions\":[\"hostname\"]}"); + } + } + + private void fixUnusedWarning(Flags.Replacer replacer) { } + + @Test + public void testData() { + // PUT flag with ID id1 + verifySuccessfulRequest(Method.PUT, "/data/" + FLAG1.id(), + "{\n" + + " \"id\": \"id1\",\n" + + " \"rules\": [\n" + + " {\n" + + " \"value\": true\n" + + " }\n" + + " ]\n" + + "}", + ""); + + // GET on ID id1 should return the same as the put. + verifySuccessfulRequest(Method.GET, "/data/" + FLAG1.id(), + "", "{\"id\":\"id1\",\"rules\":[{\"value\":true}]}"); + + // List all flags should list only id1 + verifySuccessfulRequest(Method.GET, "/data", + "", "{\"flags\":[{\"id\":\"id1\",\"url\":\"https://foo.com:4443/flags/v1/data/id1\"}]}"); + + // Should be identical to above: suffix / on path should be ignored + verifySuccessfulRequest(Method.GET, "/data/", + "", "{\"flags\":[{\"id\":\"id1\",\"url\":\"https://foo.com:4443/flags/v1/data/id1\"}]}"); + + // Verify absent port => absent in response + assertThat(handleWithPort(Method.GET, -1, "/data", "", 200), + is("{\"flags\":[{\"id\":\"id1\",\"url\":\"https://foo.com/flags/v1/data/id1\"}]}")); + + // PUT id2 + verifySuccessfulRequest(Method.PUT, "/data/" + FLAG2.id(), + "{\n" + + " \"id\": \"id2\",\n" + + " \"rules\": [\n" + + " {\n" + + " \"conditions\": [\n" + + " {\n" + + " \"type\": \"whitelist\",\n" + + " \"dimension\": \"hostname\",\n" + + " \"values\": [ \"host1\", \"host2\" ]\n" + + " },\n" + + " {\n" + + " \"type\": \"blacklist\",\n" + + " \"dimension\": \"application\",\n" + + " \"values\": [ \"app1\", \"app2\" ]\n" + + " }\n" + + " ],\n" + + " \"value\": true\n" + + " }\n" + + " ],\n" + + " \"attributes\": {\n" + + " \"zone\": \"zone1\"\n" + + " }\n" + + "}\n", + ""); + + // GET on id2 should now return what was put + verifySuccessfulRequest(Method.GET, "/data/" + FLAG2.id(), "", + "{\"id\":\"id2\",\"rules\":[{\"conditions\":[{\"type\":\"whitelist\",\"dimension\":\"hostname\",\"values\":[\"host1\",\"host2\"]},{\"type\":\"blacklist\",\"dimension\":\"application\",\"values\":[\"app1\",\"app2\"]}],\"value\":true}],\"attributes\":{\"zone\":\"zone1\"}}"); + + // The list of flag data should return id1 and id2 + verifySuccessfulRequest(Method.GET, "/data", + "", + "{\"flags\":[{\"id\":\"id1\",\"url\":\"https://foo.com:4443/flags/v1/data/id1\"},{\"id\":\"id2\",\"url\":\"https://foo.com:4443/flags/v1/data/id2\"}]}"); + + // Putting (overriding) id1 should work silently + verifySuccessfulRequest(Method.PUT, "/data/" + FLAG1.id(), + "{\n" + + " \"id\": \"id1\",\n" + + " \"rules\": [\n" + + " {\n" + + " \"value\": false\n" + + " }\n" + + " ]\n" + + "}\n", + ""); + + // Verify PUT + verifySuccessfulRequest(Method.GET, "/data/" + FLAG1.id(), "", "{\"id\":\"id1\",\"rules\":[{\"value\":false}]}"); + + // Get all recursivelly displays all flag data + verifySuccessfulRequest(Method.GET, "/data?recursive=true", "", + "{\"flags\":[{\"id\":\"id1\",\"rules\":[{\"value\":false}]},{\"id\":\"id2\",\"rules\":[{\"conditions\":[{\"type\":\"whitelist\",\"dimension\":\"hostname\",\"values\":[\"host1\",\"host2\"]},{\"type\":\"blacklist\",\"dimension\":\"application\",\"values\":[\"app1\",\"app2\"]}],\"value\":true}],\"attributes\":{\"zone\":\"zone1\"}}]}"); + + // Deleting both flags + verifySuccessfulRequest(Method.DELETE, "/data/" + FLAG1.id(), "", ""); + verifySuccessfulRequest(Method.DELETE, "/data/" + FLAG2.id(), "", ""); + + // And the list of data flags should now be empty + verifySuccessfulRequest(Method.GET, "/data", "", "{\"flags\":[]}"); + } + + @Test + public void testForcing() { + assertThat(handle(Method.PUT, "/data/" + new FlagId("undef"), "", 400), + containsString("There is no flag 'undef'")); + + assertThat(handle(Method.PUT, "/data/" + new FlagId("undef") + "?force=true", "", 400), + containsString("No content to map due to end-of-input")); + + assertThat(handle(Method.PUT, "/data/" + FLAG1.id(), "{}", 400), + containsString("Flag ID missing")); + + assertThat(handle(Method.PUT, "/data/" + FLAG1.id(), "{\"id\": \"id1\",\"rules\": [{\"value\":\"string\"}]}", 400), + containsString("Wrong type of JsonNode: STRING")); + + assertThat(handle(Method.PUT, "/data/" + FLAG1.id() + "?force=true", "{\"id\": \"id1\",\"rules\": [{\"value\":\"string\"}]}", 200), + is("")); + } + + private void verifySuccessfulRequest(Method method, String pathSuffix, String requestBody, String expectedResponseBody) { + assertThat(handle(method, pathSuffix, requestBody, 200), is(expectedResponseBody)); + } + + private String handle(Method method, String pathSuffix, String requestBody, int expectedStatus) { + return handleWithPort(method, 4443, pathSuffix, requestBody, expectedStatus); + } + + private String handleWithPort(Method method, int port, String pathSuffix, String requestBody, int expectedStatus) { + String uri = "https://foo.com" + (port < 0 ? "" : ":" + port) + "/flags/v1" + pathSuffix; + HttpRequest request = HttpRequest.createTestRequest(uri, method, makeInputStream(requestBody)); + HttpResponse response = handler.handle(request); + assertEquals(expectedStatus, response.getStatus()); + assertEquals("application/json", response.getContentType()); + var outputStream = new ByteArrayOutputStream(); + Exceptions.uncheck(() -> response.render(outputStream)); + return outputStream.toString(StandardCharsets.UTF_8); + } + + private InputStream makeInputStream(String content) { + return new ByteArrayInputStream(Utf8.toBytes(content)); + } +} diff --git a/flags/src/test/java/com/yahoo/vespa/flags/persistence/FlagsDbTest.java b/flags/src/test/java/com/yahoo/vespa/flags/persistence/FlagsDbTest.java new file mode 100644 index 00000000000..5102305af90 --- /dev/null +++ b/flags/src/test/java/com/yahoo/vespa/flags/persistence/FlagsDbTest.java @@ -0,0 +1,56 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.flags.persistence; + +import com.yahoo.vespa.curator.mock.MockCurator; +import com.yahoo.vespa.flags.FetchVector; +import com.yahoo.vespa.flags.FlagId; +import com.yahoo.vespa.flags.JsonNodeRawFlag; +import com.yahoo.vespa.flags.json.Condition; +import com.yahoo.vespa.flags.json.FlagData; +import com.yahoo.vespa.flags.json.Rule; +import org.junit.Test; + +import java.util.Map; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author hakonhall + */ +public class FlagsDbTest { + @Test + public void test() { + MockCurator curator = new MockCurator(); + FlagsDb db = new FlagsDb(curator); + + Condition condition1 = new Condition(Condition.Type.WHITELIST, FetchVector.Dimension.HOSTNAME, "host1"); + Rule rule1 = new Rule(Optional.of(JsonNodeRawFlag.fromJson("13")), condition1); + FlagId flagId = new FlagId("id"); + FlagData data = new FlagData(flagId, new FetchVector().with(FetchVector.Dimension.ZONE_ID, "zone-a"), rule1); + db.setValue(flagId, data); + + Optional<FlagData> dataCopy = db.getValue(flagId); + assertTrue(dataCopy.isPresent()); + + assertEquals("{\"id\":\"id\",\"rules\":[{\"conditions\":[{\"type\":\"whitelist\",\"dimension\":\"hostname\"," + + "\"values\":[\"host1\"]}],\"value\":13}],\"attributes\":{\"zone\":\"zone-a\"}}", + dataCopy.get().serializeToJson()); + + FlagId flagId2 = new FlagId("id2"); + FlagData data2 = new FlagData(flagId2, new FetchVector().with(FetchVector.Dimension.ZONE_ID, "zone-a"), rule1); + db.setValue(flagId2, data2); + Map<FlagId, FlagData> flags = db.getAllFlags(); + assertThat(flags.size(), equalTo(2)); + assertThat(flags.get(flagId), notNullValue()); + assertThat(flags.get(flagId2), notNullValue()); + + db.removeValue(flagId2); + assertFalse(db.getValue(flagId2).isPresent()); + } +} |