Java tutorial
/* * Copyright (c) 2014 Red Hat, Inc. and others * * Red Hat licenses this file to you 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 io.vertx.config.git; import io.vertx.config.ConfigRetriever; import io.vertx.config.ConfigRetrieverOptions; import io.vertx.config.ConfigStoreOptions; import io.vertx.core.Vertx; import io.vertx.core.json.DecodeException; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.ext.unit.Async; import io.vertx.ext.unit.TestContext; import io.vertx.ext.unit.junit.VertxUnitRunner; import org.apache.commons.io.FileUtils; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.Ref; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import java.io.File; import java.io.IOException; import java.net.MalformedURLException; import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicBoolean; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.hamcrest.CoreMatchers.is; /** * @author <a href="http://escoffier.me">Clement Escoffier</a> */ @RunWith(VertxUnitRunner.class) public class GitConfigStoreTest { private Vertx vertx; private ConfigRetriever retriever; private Git git; private Git bare; private File bareRoot; private File root = new File("target/junk/repo"); private String branch; private String remote = "origin"; @Before public void setUp(TestContext context) throws IOException, GitAPIException { vertx = Vertx.vertx(); vertx.exceptionHandler(context.exceptionHandler()); FileUtils.deleteDirectory(new File("target/junk")); bareRoot = new File("target/junk/bare-repo.git"); bare = createBareRepository(bareRoot); git = connect(bareRoot, root); branch = "master"; } @After public void tearDown() { AtomicBoolean done = new AtomicBoolean(); if (retriever != null) { retriever.close(); } if (git != null) { git.close(); } if (bare != null) { bare.close(); } vertx.close(v -> done.set(true)); await().untilAtomic(done, is(true)); } @Test public void testWithEmptyRepository(TestContext tc) throws GitAPIException, IOException { Async async = tc.async(); add(git, root, new File("src/test/resources/files/some-text.txt"), null); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().addStore(new ConfigStoreOptions().setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()) .put("path", "target/junk/work").put("filesets", new JsonArray().add(new JsonObject().put("pattern", "**/*.json")))))); retriever.getConfig(ar -> { assertThat(ar.succeeded()).isTrue(); assertThat(ar.result()).isEmpty(); async.complete(); }); } @Test public void testWithARepositoryWithAMatchingFile(TestContext tc) throws GitAPIException, IOException { Async async = tc.async(); add(git, root, new File("src/test/resources/files/some-text.txt"), null); add(git, root, new File("src/test/resources/files/regular.json"), null); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().addStore(new ConfigStoreOptions().setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()) .put("path", "target/junk/work") .put("filesets", new JsonArray().add(new JsonObject().put("pattern", "*.json")))))); retriever.getConfig(ar -> { assertThat(ar.succeeded()).isTrue(); assertThat(ar.result()).isNotEmpty(); JsonObject json = ar.result(); assertThat(json).isNotNull(); assertThat(json.getString("key")).isEqualTo("value"); assertThat(json.getBoolean("true")).isTrue(); assertThat(json.getBoolean("false")).isFalse(); assertThat(json.getString("missing")).isNull(); assertThat(json.getInteger("int")).isEqualTo(5); assertThat(json.getDouble("float")).isEqualTo(25.3); assertThat(json.getJsonArray("array").size()).isEqualTo(3); assertThat(json.getJsonArray("array").contains(1)).isTrue(); assertThat(json.getJsonArray("array").contains(2)).isTrue(); assertThat(json.getJsonArray("array").contains(3)).isTrue(); assertThat(json.getJsonObject("sub").getString("foo")).isEqualTo("bar"); async.complete(); }); } @Test public void testWithACustomBranch(TestContext tc) throws GitAPIException, IOException { Async async = tc.async(); add(git, root, new File("src/test/resources/files/a.json"), null); branch = "dev"; add(git, root, new File("src/test/resources/files/regular.json"), null); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().addStore(new ConfigStoreOptions().setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()) .put("path", "target/junk/work").put("branch", branch) .put("filesets", new JsonArray().add(new JsonObject().put("pattern", "*.json")))))); retriever.getConfig(ar -> { assertThat(ar.succeeded()).isTrue(); assertThat(ar.result()).isNotEmpty(); JsonObject json = ar.result(); assertThat(json).isNotNull(); assertThat(json.getString("key")).isEqualTo("value"); async.complete(); }); } @Test public void testWithACustomBranchAndRemote(TestContext tc) throws GitAPIException, IOException { git.close(); remote = "acme"; FileUtils.deleteQuietly(root); git = connect(bareRoot, root); Async async = tc.async(); add(git, root, new File("src/test/resources/files/a.json"), null); branch = "dev"; add(git, root, new File("src/test/resources/files/regular.json"), null); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().addStore(new ConfigStoreOptions().setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()) .put("path", "target/junk/work").put("branch", branch).put("remote", remote) .put("filesets", new JsonArray().add(new JsonObject().put("pattern", "*.json")))))); retriever.getConfig(ar -> { if (ar.failed()) { ar.cause().printStackTrace(); } assertThat(ar.succeeded()).isTrue(); assertThat(ar.result()).isNotEmpty(); JsonObject json = ar.result(); assertThat(json).isNotNull(); assertThat(json.getString("key")).isEqualTo("value"); async.complete(); }); } @Test public void testWithARepositoryWithAMatchingPropertiesFile(TestContext tc) throws GitAPIException, IOException { Async async = tc.async(); add(git, root, new File("src/test/resources/files/some-text.txt"), null); add(git, root, new File("src/test/resources/files/regular.json"), null); add(git, root, new File("src/test/resources/files/regular.properties"), null); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().addStore(new ConfigStoreOptions() .setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()).put("path", "target/junk/work") .put("filesets", new JsonArray().add( new JsonObject().put("pattern", "*.properties").put("format", "properties")))))); retriever.getConfig(ar -> { if (ar.failed()) { ar.cause().printStackTrace(); } assertThat(ar.succeeded()).isTrue(); assertThat(ar.result()).isNotEmpty(); JsonObject json = ar.result(); assertThat(json).isNotNull(); assertThat(json.getString("key")).isEqualTo("value"); assertThat(json.getBoolean("true")).isTrue(); assertThat(json.getBoolean("false")).isFalse(); assertThat(json.getString("missing")).isNull(); assertThat(json.getInteger("int")).isEqualTo(5); assertThat(json.getDouble("float")).isEqualTo(25.3); async.complete(); }); } @Test(expected = NullPointerException.class) public void testWithMissingPathInConf() { new GitConfigStoreFactory().create(vertx, new JsonObject().put("no-path", "").put("url", "git url").put("filesets", new JsonArray())); } @Test(expected = NullPointerException.class) public void testWithMissingGitRepoUrlInConf() { new GitConfigStoreFactory().create(vertx, new JsonObject().put("path", "target").put("filesets", new JsonArray())); } @Test(expected = NullPointerException.class) public void testWithMissingFileSets() { new GitConfigStoreFactory().create(vertx, new JsonObject().put("path", "src/test/resources").put("url", "git url")); } @Test(expected = IllegalArgumentException.class) public void testWithMissingPatternInAFileSet() { new GitConfigStoreFactory().create(vertx, new JsonObject().put("path", "src/test/resources").put("filesets", new JsonArray().add(new JsonObject().put("format", "properties")))); } @Test public void testName() { assertThat(new GitConfigStoreFactory().name()).isNotNull().isEqualTo("git"); } @Test public void testWithNonExistingPath(TestContext tc) throws IOException, GitAPIException { add(git, root, new File("src/test/resources/files/some-text.txt"), null); add(git, root, new File("src/test/resources/files/regular.json"), null); push(git); Async async = tc.async(); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().addStore(new ConfigStoreOptions().setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()) .put("path", "target/junk/do-not-exist") .put("filesets", new JsonArray().add(new JsonObject().put("pattern", "*.json")))))); retriever.getConfig(ar -> { assertThat(ar.succeeded()).isTrue(); assertThat(ar.result()).isNotEmpty(); async.complete(); }); } @Test(expected = IllegalArgumentException.class) public void testWithAPathThatIsAFile() { new GitConfigStoreFactory().create(vertx, new JsonObject().put("path", "src/test/resources/files/regular.json") .put("url", bareRoot.getAbsolutePath()) .put("filesets", new JsonArray().add(new JsonObject().put("pattern", "*.json")))); } @Test(expected = IllegalArgumentException.class) public void testWhenTheFormatIsUnknown() { new GitConfigStoreFactory().create(vertx, new JsonObject() .put("path", "src/test/resources/files/regular.json").put("url", bareRoot.getAbsolutePath()) .put("filesets", new JsonArray().add(new JsonObject().put("pattern", "*.json").put("format", "unknown")))); } @Test public void testWithoutAnExistingRepo(TestContext tc) throws IOException, GitAPIException { Async async = tc.async(); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().addStore(new ConfigStoreOptions().setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()) .put("path", "target/junk/do-not-exist") .put("filesets", new JsonArray().add(new JsonObject().put("pattern", "*.json")))))); retriever.getConfig(ar -> { assertThat(ar.failed()).isTrue(); assertThat(ar.cause().getMessage()).contains("origin", "master"); async.complete(); }); } @Test public void testWith2FileSetsAndNoIntersection(TestContext tc) throws GitAPIException, IOException { Async async = tc.async(); add(git, root, new File("src/test/resources/files/regular.json"), "file"); add(git, root, new File("src/test/resources/files/a.json"), "dir"); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().addStore(new ConfigStoreOptions() .setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()).put("path", "target/junk/work") .put("filesets", new JsonArray().add(new JsonObject().put("pattern", "file/reg*.json")) .add(new JsonObject().put("pattern", "dir/a.*son")))))); retriever.getConfig(ar -> { assertThat(ar.result().getString("key")).isEqualTo("value"); assertThat(ar.result().getString("a.name")).isEqualTo("A"); async.complete(); }); } @Test public void testWith2FileSetsAndWithIntersection(TestContext tc) throws GitAPIException, IOException { Async async = tc.async(); add(git, root, new File("src/test/resources/files/b.json"), "dir"); add(git, root, new File("src/test/resources/files/a.json"), "dir"); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().addStore(new ConfigStoreOptions() .setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()).put("path", "target/junk/work") .put("filesets", new JsonArray().add(new JsonObject().put("pattern", "dir/b.json")) .add(new JsonObject().put("pattern", "dir/a.*son")))))); retriever.getConfig(ar -> { assertThat(ar.result().getString("a.name")).isEqualTo("A"); assertThat(ar.result().getString("b.name")).isEqualTo("B"); assertThat(ar.result().getString("conflict")).isEqualTo("A"); async.complete(); }); } @Test public void testWith2FileSetsAndWithIntersectionReversed(TestContext tc) throws GitAPIException, IOException { Async async = tc.async(); add(git, root, new File("src/test/resources/files/b.json"), "dir"); add(git, root, new File("src/test/resources/files/a.json"), "dir"); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().addStore(new ConfigStoreOptions() .setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()).put("path", "target/junk/work") .put("filesets", new JsonArray().add(new JsonObject().put("pattern", "dir/a.*son")) .add(new JsonObject().put("pattern", "dir/b.json")))))); retriever.getConfig(ar -> { assertThat(ar.result().getString("conflict")).isEqualTo("B"); assertThat(ar.result().getString("a.name")).isEqualTo("A"); assertThat(ar.result().getString("b.name")).isEqualTo("B"); async.complete(); }); } @Test public void testWithDeepConfigMerge(TestContext tc) throws GitAPIException, IOException { Async async = tc.async(); add(git, root, new File("src/test/resources/files/b.json"), "dir"); add(git, root, new File("src/test/resources/files/a.json"), "dir"); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().addStore(new ConfigStoreOptions() .setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()).put("path", "target/junk/work") .put("filesets", new JsonArray().add(new JsonObject().put("pattern", "dir/b.json")) .add(new JsonObject().put("pattern", "dir/a.*son")))))); retriever.getConfig(ar -> { // Both level-3 objects must exist. assertThat(ar.result().getJsonObject("parent").getJsonObject("level_2").getString("key1")) .isEqualTo("A"); assertThat(ar.result().getJsonObject("parent").getJsonObject("level_2").getString("key2")) .isEqualTo("B"); async.complete(); }); } @Test public void testWithAFileSetMatching2FilesWithConflict(TestContext tc) throws GitAPIException, IOException { Async async = tc.async(); add(git, root, new File("src/test/resources/files/b.json"), "dir"); add(git, root, new File("src/test/resources/files/a.json"), "dir"); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().addStore(new ConfigStoreOptions().setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()) .put("path", "target/junk/work").put("filesets", new JsonArray().add(new JsonObject().put("pattern", "dir/?.*son")))))); retriever.getConfig(ar -> { assertThat(ar.result().getString("b.name")).isEqualTo("B"); assertThat(ar.result().getString("a.name")).isEqualTo("A"); // Alphabetical order, so B is last. assertThat(ar.result().getString("conflict")).isEqualTo("B"); async.complete(); }); } @Test public void testWithAFileSetMatching2FilesOneNotBeingAJsonFile(TestContext tc) throws GitAPIException, IOException { Async async = tc.async(); add(git, root, new File("src/test/resources/files/a-bad.json"), "dir"); add(git, root, new File("src/test/resources/files/a.json"), "dir"); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().addStore(new ConfigStoreOptions().setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()) .put("path", "target/junk/work").put("filesets", new JsonArray().add(new JsonObject().put("pattern", "dir/a?*.*son")))))); retriever.getConfig(ar -> { assertThat(ar.failed()); assertThat(ar.cause()).isInstanceOf(DecodeException.class); async.complete(); }); } @Test public void testConfigurationUpdate() throws IOException, GitAPIException { add(git, root, new File("src/test/resources/files/a.json"), "dir"); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().setScanPeriod(1000).addStore(new ConfigStoreOptions().setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()) .put("path", "target/junk/work").put("filesets", new JsonArray().add(new JsonObject().put("pattern", "dir/*.json")))))); AtomicBoolean done = new AtomicBoolean(); retriever.getConfig(ar -> { assertThat(ar.succeeded()).isTrue(); assertThat(ar.result().getString("a.name")).isEqualTo("A"); done.set(true); }); await().untilAtomic(done, is(true)); updateA(); await().until(() -> "A2".equals(retriever.getCachedConfig().getString("a.name")) && "B".equalsIgnoreCase(retriever.getCachedConfig().getString("b.name"))); } @Test public void testConfigurationUpdateWithMergeIssue_Commit(TestContext tc) throws IOException, GitAPIException { add(git, root, new File("src/test/resources/files/a.json"), "dir"); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().setScanPeriod(1000).addStore(new ConfigStoreOptions().setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()) .put("path", "target/junk/work").put("filesets", new JsonArray().add(new JsonObject().put("pattern", "dir/*.json")))))); AtomicBoolean done = new AtomicBoolean(); retriever.getConfig(ar -> { assertThat(ar.succeeded()).isTrue(); assertThat(ar.result().getString("a.name")).isEqualTo("A"); done.set(true); }); await().untilAtomic(done, is(true)); // Edit the file in the work dir File a = new File("target/junk/work/dir/a.json"); assertThat(a).isFile(); FileUtils.write(a, new JsonObject().put("a.name", "A").put("conflict", "A").put("added", "added").encodePrettily(), StandardCharsets.UTF_8); git.add().addFilepattern("dir/a.json").call(); git.commit().setMessage("update A").setAuthor("clement", "clement@apache.org") .setCommitter("clement", "clement@apache.org").call(); done.set(false); retriever.getConfig(ar -> { assertThat(ar.succeeded()).isTrue(); assertThat(ar.result().getString("a.name")).isEqualTo("A"); assertThat(ar.result().getString("added")).isEqualTo("added"); done.set(true); }); await().untilAtomic(done, is(true)); updateA(); Async async = tc.async(); retriever.getConfig(ar -> { assertThat(ar.succeeded()).isFalse(); assertThat(ar.cause().getMessage()).containsIgnoringCase("conflict"); async.complete(); }); } @Test public void testConfigurationUpdateWithMergeIssue_Edit(TestContext tc) throws IOException, GitAPIException { add(git, root, new File("src/test/resources/files/a.json"), "dir"); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().setScanPeriod(1000).addStore(new ConfigStoreOptions().setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()) .put("path", "target/junk/work").put("filesets", new JsonArray().add(new JsonObject().put("pattern", "dir/*.json")))))); AtomicBoolean done = new AtomicBoolean(); retriever.getConfig(ar -> { assertThat(ar.succeeded()).isTrue(); assertThat(ar.result().getString("a.name")).isEqualTo("A"); done.set(true); }); await().untilAtomic(done, is(true)); // Edit the file in the work dir File a = new File("target/junk/work/dir/a.json"); assertThat(a).isFile(); FileUtils.write(a, new JsonObject().put("a.name", "A-modified").put("conflict", "A").encodePrettily(), StandardCharsets.UTF_8); done.set(false); retriever.getConfig(ar -> { assertThat(ar.succeeded()).isTrue(); assertThat(ar.result().getString("a.name")).isEqualTo("A-modified"); done.set(true); }); await().untilAtomic(done, is(true)); updateA(); Async async = tc.async(); retriever.getConfig(ar -> { assertThat(ar.succeeded()).isFalse(); assertThat(ar.cause().getMessage()).containsIgnoringCase("conflict"); async.complete(); }); } @Test public void testUsingAnExistingRepo() throws IOException, GitAPIException { git.close(); root = new File("target/junk/work"); git = connect(bareRoot, root); add(git, root, new File("src/test/resources/files/a.json"), "dir"); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().setScanPeriod(1000).addStore(new ConfigStoreOptions().setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()) .put("path", "target/junk/work").put("filesets", new JsonArray().add(new JsonObject().put("pattern", "dir/*.json")))))); AtomicBoolean done = new AtomicBoolean(); retriever.getConfig(ar -> { assertThat(ar.succeeded()).isTrue(); assertThat(ar.result().getString("a.name")).isEqualTo("A"); done.set(true); }); await().untilAtomic(done, is(true)); updateA(); await().until(() -> "A2".equals(retriever.getCachedConfig().getString("a.name")) && "B".equalsIgnoreCase(retriever.getCachedConfig().getString("b.name"))); } @Test public void testWithExistingRepoOnTheWrongBranch() throws Exception { git.close(); root = new File("target/junk/work"); git = connect(bareRoot, root); add(git, root, new File("src/test/resources/files/a.json"), "dir"); push(git); branch = "dev"; add(git, root, new File("src/test/resources/files/b.json"), "dir"); push(git); retriever = ConfigRetriever.create(vertx, new ConfigRetrieverOptions().setScanPeriod(1000).addStore(new ConfigStoreOptions().setType("git") .setConfig(new JsonObject().put("url", bareRoot.getAbsolutePath()) .put("path", "target/junk/work").put("filesets", new JsonArray().add(new JsonObject().put("pattern", "dir/*.json")))))); AtomicBoolean done = new AtomicBoolean(); retriever.getConfig(ar -> { assertThat(ar.succeeded()).isTrue(); assertThat(ar.result().getString("a.name")).isEqualTo("A"); done.set(true); }); await().untilAtomic(done, is(true)); updateA(); await().until(() -> "A2".equals(retriever.getCachedConfig().getString("a.name")) && "B".equalsIgnoreCase(retriever.getCachedConfig().getString("b.name"))); } private void updateA() { try { add(git, root, new File("src/test/resources/files/b.json"), "dir"); FileUtils.copyFile(new File("src/test/resources/files/a-v2.json"), new File(root, "dir/a.json")); git.add().addFilepattern("dir/a.json").call(); git.commit().setAuthor("clement", "clement@apache.org").setCommitter("clement", "clement@apache.org") .setMessage("Update a.json").call(); push(git); } catch (Exception e) { throw new RuntimeException(e); } } private void push(Git git) throws GitAPIException { git.push().setRemote(remote).setForce(true).call(); } private Git connect(File bareRoot, File root) throws MalformedURLException, GitAPIException { return Git.cloneRepository().setURI(bareRoot.getAbsolutePath()).setRemote(remote).setDirectory(root).call(); } private Git createBareRepository(File root) throws GitAPIException { return Git.init().setDirectory(root).setBare(true).call(); } private GitConfigStoreTest add(Git git, File root, File file, String directory) throws IOException, GitAPIException { if (!file.isFile()) { throw new RuntimeException("File not found " + file.getAbsolutePath()); } if (!"master".equalsIgnoreCase(git.getRepository().getBranch())) { git.checkout().setCreateBranch(true).setName("master").call(); } if (!branch.equalsIgnoreCase(git.getRepository().getBranch())) { boolean create = true; for (Ref ref : git.branchList().call()) { if (ref.getName().equals("refs/heads/" + branch)) { create = false; } } git.checkout().setCreateBranch(create).setName(branch).call(); } String relative; if (directory != null) { relative = directory + File.separator + file.getName(); } else { relative = file.getName(); } File output = new File(root, relative); if (output.exists()) { output.delete(); } if (!output.getParentFile().isDirectory()) { output.getParentFile().mkdirs(); } FileUtils.copyFile(file, output); git.add().addFilepattern(relative).call(); git.commit().setAuthor("clement", "clement@apache.org").setCommitter("clement", "clement@apache.org") .setMessage("Add " + relative).call(); return this; } }