diff --git a/src/main/java/com/ecwid/consul/v1/ConsulClient.java b/src/main/java/com/ecwid/consul/v1/ConsulClient.java index 8d839641..ffddc09d 100644 --- a/src/main/java/com/ecwid/consul/v1/ConsulClient.java +++ b/src/main/java/com/ecwid/consul/v1/ConsulClient.java @@ -38,6 +38,10 @@ import com.ecwid.consul.v1.session.model.Session; import com.ecwid.consul.v1.status.StatusClient; import com.ecwid.consul.v1.status.StatusConsulClient; +import com.ecwid.consul.v1.transactions.ParamBuilder; +import com.ecwid.consul.v1.transactions.TransactionsClient; +import com.ecwid.consul.v1.transactions.TransactionsConsulClient; +import com.ecwid.consul.v1.transactions.model.TxnResult; import java.util.List; import java.util.Map; @@ -62,7 +66,8 @@ public class ConsulClient implements KeyValueClient, QueryClient, SessionClient, - StatusClient { + StatusClient, + TransactionsClient { private final AclClient aclClient; private final AgentClient agentClient; @@ -74,6 +79,7 @@ public class ConsulClient implements private final QueryClient queryClient; private final SessionClient sessionClient; private final StatusClient statusClient; + private final TransactionsClient transactionsClient; public ConsulClient(ConsulRawClient rawClient) { aclClient = new AclConsulClient(rawClient); @@ -86,6 +92,7 @@ public ConsulClient(ConsulRawClient rawClient) { queryClient = new QueryConsulClient(rawClient); sessionClient = new SessionConsulClient(rawClient); statusClient = new StatusConsulClient(rawClient); + transactionsClient = new TransactionsConsulClient(rawClient); } /** @@ -209,7 +216,7 @@ public Response> getAgentMembers() { public Response getAgentSelf() { return agentClient.getAgentSelf(); } - + @Override public Response getAgentSelf(String token) { return agentClient.getAgentSelf(token); @@ -868,4 +875,23 @@ public Response getStatusLeader() { public Response> getStatusPeers() { return statusClient.getStatusPeers(); } + + /** + * Submit transaction + * + * @param token token + * @param builder parameters builder + * @return process result report + */ + @Override + public Response commit(String token, ParamBuilder builder) { return transactionsClient.commit(token,builder);} + + /** + * Submit transaction + * + * @param builder parameters builder + * @return process result report + */ + @Override + public Response commit(ParamBuilder builder) { return transactionsClient.commit(builder);} } diff --git a/src/main/java/com/ecwid/consul/v1/transactions/ParamBuilder.java b/src/main/java/com/ecwid/consul/v1/transactions/ParamBuilder.java new file mode 100644 index 00000000..cd203dd7 --- /dev/null +++ b/src/main/java/com/ecwid/consul/v1/transactions/ParamBuilder.java @@ -0,0 +1,231 @@ +package com.ecwid.consul.v1.transactions; + +import com.ecwid.consul.v1.transactions.model.Operate; +import com.ecwid.consul.v1.transactions.model.TxnReqItem; +import com.ecwid.consul.v1.transactions.model.Verb; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedList; +import java.util.List; + +/** + * Build consul Transaction parameters + *

+ * https://www.consul.io/api/txn.html#tables-of-operations + * + * @author Trisia Cliven (quanguanyu@qq.com) + * @since 2019-11-11 12:21:52 + */ +public class ParamBuilder { + + private List operates; + + private ParamBuilder() { + operates = new LinkedList<>(); + } + + public static ParamBuilder getInstance() { + return new ParamBuilder(); + } + + /** + * Sets the Key to the given Value + * + * @param key key + * @param value value (do not base64) + * @param flag [option] + * @return this + */ + public ParamBuilder kvSet(String key, String value, int... flag) { + Operate op = new Operate(Verb.set) + .setKey(key) + .setValue(Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8))); + if (flag != null && flag.length > 0) { + op.setFlags(flag[0]); + } + operates.add(new TxnReqItem(op)); + return this; + } + + /** + * Sets, but with CAS semantics + * + * @param key key + * @param value value + * @param index index + * @param flag [option] + * @return this + */ + public ParamBuilder kvCas(String key, String value, int index, int... flag) { + Operate op = new Operate(Verb.cas) + .setKey(key) + .setValue(Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8))) + .setIndex(index); + if (flag != null && flag.length > 0) { + op.setFlags(flag[0]); + } + operates.add(new TxnReqItem(op)); + return this; + } + + /** + * Lock with the given Session + * + * @param key key + * @param value value + * @param session session id + * @param flag [option] + * @return this + */ + public ParamBuilder kvLock(String key, String value, String session, int... flag) { + Operate op = new Operate(Verb.lock) + .setKey(key) + .setValue(Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8))) + .setSession(session); + if (flag != null && flag.length > 0) { + op.setFlags(flag[0]); + } + operates.add(new TxnReqItem(op)); + return this; + } + + + /** + * Unlock with the given Session + * + * @param key key + * @param value value + * @param session session id + * @param flag [option] + * @return this + */ + public ParamBuilder kvUnlock(String key, String value, String session, int... flag) { + Operate op = new Operate(Verb.unlock) + .setKey(key) + .setValue(Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8))) + .setSession(session); + if (flag != null && flag.length > 0) { + op.setFlags(flag[0]); + } + operates.add(new TxnReqItem(op)); + return this; + } + + /** + * Get the key, fails if it does not exist + * + * @param key key + * @return this + */ + public ParamBuilder kvGet(String key) { + Operate op = new Operate(Verb.get) + .setKey(key); + operates.add(new TxnReqItem(op)); + return this; + } + + /** + * Gets all keys with the prefix + * + * @param key key + * @return this + */ + public ParamBuilder kvGetTree(String key) { + Operate op = new Operate(Verb.get_tree) + .setKey(key); + operates.add(new TxnReqItem(op)); + return this; + } + + /** + * Fail if modify index != index + * + * @param key key + * @param index index + * @return this + */ + public ParamBuilder kvCheckIndex(String key, int index) { + Operate op = new Operate(Verb.check_index) + .setKey(key) + .setIndex(index); + operates.add(new TxnReqItem(op)); + return this; + } + + /** + * Fail if not locked by session + * + * @param key key + * @param session session id + * @return this + */ + public ParamBuilder kvCheckSession(String key, String session) { + Operate op = new Operate(Verb.check_session) + .setKey(key) + .setSession(session); + operates.add(new TxnReqItem(op)); + return this; + } + + /** + * Fail if key exists + * + * @param key key + * @return this + */ + public ParamBuilder kvCheckNotExists(String key) { + Operate op = new Operate(Verb.check_not_exists) + .setKey(key); + operates.add(new TxnReqItem(op)); + return this; + } + + /** + * Delete the key + * + * @param key key + * @return this + */ + public ParamBuilder kvDelete(String key) { + Operate op = new Operate(Verb.delete) + .setKey(key); + operates.add(new TxnReqItem(op)); + return this; + } + + /** + * Delete all keys with a prefix + * + * @param key key + * @return this + */ + public ParamBuilder kvDeleteTree(String key) { + Operate op = new Operate(Verb.delete_tree) + .setKey(key); + operates.add(new TxnReqItem(op)); + return this; + } + + /** + * Delete, but with CAS semantics + * + * @param key key + * @return this + */ + public ParamBuilder kvDeleteCas(String key) { + Operate op = new Operate(Verb.delete_cas) + .setKey(key); + operates.add(new TxnReqItem(op)); + return this; + } + + /** + * Build Request parameters + * + * @return parameters + */ + public List build() { + return this.operates; + } +} diff --git a/src/main/java/com/ecwid/consul/v1/transactions/TransactionsClient.java b/src/main/java/com/ecwid/consul/v1/transactions/TransactionsClient.java new file mode 100644 index 00000000..fa6b8f9f --- /dev/null +++ b/src/main/java/com/ecwid/consul/v1/transactions/TransactionsClient.java @@ -0,0 +1,29 @@ +package com.ecwid.consul.v1.transactions; + + +import com.ecwid.consul.v1.Response; +import com.ecwid.consul.v1.transactions.model.TxnResult; + +/** + * Transactions Client + * + * @author Trisia Cliven (quanguanyu@qq.com) + * @since 2019-11-11 10:24:49 + */ +public interface TransactionsClient { + /** + * Submit transaction + * + * @param token token + * @param builder parameters builder + * @return process result report + */ + public Response commit(String token, ParamBuilder builder); + /** + * Submit transaction + * + * @param builder parameters builder + * @return process result report + */ + public Response commit(ParamBuilder builder); +} diff --git a/src/main/java/com/ecwid/consul/v1/transactions/TransactionsConsulClient.java b/src/main/java/com/ecwid/consul/v1/transactions/TransactionsConsulClient.java new file mode 100644 index 00000000..5172c3a2 --- /dev/null +++ b/src/main/java/com/ecwid/consul/v1/transactions/TransactionsConsulClient.java @@ -0,0 +1,59 @@ +package com.ecwid.consul.v1.transactions; + +import com.ecwid.consul.SingleUrlParameters; +import com.ecwid.consul.UrlParameters; +import com.ecwid.consul.json.GsonFactory; +import com.ecwid.consul.transport.HttpResponse; +import com.ecwid.consul.v1.ConsulRawClient; +import com.ecwid.consul.v1.OperationException; +import com.ecwid.consul.v1.Response; +import com.ecwid.consul.v1.transactions.model.TxnResult; + +/** + * Transactions Client for consul + * + * @author Trisia Cliven (quanguanyu@qq.com) + */ +public class TransactionsConsulClient implements TransactionsClient { + private final ConsulRawClient rawClient; + + public TransactionsConsulClient(ConsulRawClient rawClient) { + this.rawClient = rawClient; + } + + public TransactionsConsulClient() { + this(new ConsulRawClient()); + } + + /** + * Submit transaction + * + * @param token token + * @param builder parameters builder + * @return process result report + */ + @Override + public Response commit(String token, ParamBuilder builder) { + UrlParameters tokenParams = token != null ? new SingleUrlParameters("token", token) : null; + String json = GsonFactory.getGson().toJson(builder.build()); + HttpResponse httpResponse = rawClient.makePutRequest("/v1/txn", json, tokenParams); + + if (httpResponse.getStatusCode() == 200) { + TxnResult value = GsonFactory.getGson().fromJson(httpResponse.getContent(), TxnResult.class); + return new Response<>(value, httpResponse); + } else { + throw new OperationException(httpResponse); + } + } + + /** + * Submit transaction + * + * @param builder parameters builder + * @return process result report + */ + @Override + public Response commit(ParamBuilder builder) { + return this.commit(null, builder); + } +} diff --git a/src/main/java/com/ecwid/consul/v1/transactions/model/OpError.java b/src/main/java/com/ecwid/consul/v1/transactions/model/OpError.java new file mode 100644 index 00000000..24e52415 --- /dev/null +++ b/src/main/java/com/ecwid/consul/v1/transactions/model/OpError.java @@ -0,0 +1,46 @@ +package com.ecwid.consul.v1.transactions.model; + +/** + * Operate error info + * + * @author Trisia Cliven (quanguanyu@qq.com) + */ +public class OpError { + + /** + * OpIndex gives the index of the failed operation in the transaction, + */ + private Integer OpIndex; + + /** + * What is a string with an error message about why that operation failed. + */ + private String What; + + public OpError() { + } + + public Integer getOpIndex() { + return OpIndex; + } + + public void setOpIndex(Integer opIndex) { + OpIndex = opIndex; + } + + public String getWhat() { + return What; + } + + public void setWhat(String what) { + What = what; + } + + @Override + public String toString() { + return "OpError{" + + "OpIndex=" + OpIndex + + ", What='" + What + '\'' + + '}'; + } +} diff --git a/src/main/java/com/ecwid/consul/v1/transactions/model/Operate.java b/src/main/java/com/ecwid/consul/v1/transactions/model/Operate.java new file mode 100644 index 00000000..aa50288b --- /dev/null +++ b/src/main/java/com/ecwid/consul/v1/transactions/model/Operate.java @@ -0,0 +1,98 @@ +package com.ecwid.consul.v1.transactions.model; + +/** + * Operate item + * + * @author Trisia Cliven (quanguanyu@qq.com) + */ +public class Operate { + + /** + * Specifies the type of operation to perform. + */ + private Verb Verb; + + /** + * Specifies the full path of the entry. + */ + private String Key; + + /** + * Specifies a base64-encoded blob of data. Values cannot be larger than 512kB. + */ + private String Value; + + /** + * Specifies an opaque unsigned integer that can be attached to each entry. + * Clients can choose to use this however makes sense for their application. + */ + private Integer Flags; + + /** + * Specifies an index. See the table below for more information. + */ + private Integer Index; + + /** + * Specifies a session. See the table below for more information. + */ + private String Session; + + public Operate(Verb verb) { + Verb = verb; + } + + public Verb getVerb() { + return Verb; + } + + public Operate setVerb(Verb verb) { + Verb = verb; + return this; + } + + public String getKey() { + return Key; + } + + public Operate setKey(String key) { + Key = key; + return this; + } + + public String getValue() { + return Value; + } + + public Operate setValue(String value) { + Value = value; + return this; + } + + public Integer getFlags() { + return Flags; + } + + public Operate setFlags(Integer flags) { + Flags = flags; + return this; + } + + public Integer getIndex() { + return Index; + } + + public Operate setIndex(Integer index) { + Index = index; + return this; + } + + public String getSession() { + return Session; + } + + public Operate setSession(String session) { + Session = session; + return this; + } +} diff --git a/src/main/java/com/ecwid/consul/v1/transactions/model/TxnReqItem.java b/src/main/java/com/ecwid/consul/v1/transactions/model/TxnReqItem.java new file mode 100644 index 00000000..ed81f230 --- /dev/null +++ b/src/main/java/com/ecwid/consul/v1/transactions/model/TxnReqItem.java @@ -0,0 +1,24 @@ +package com.ecwid.consul.v1.transactions.model; + +/** + * @author Trisia Cliven (quanguanyu@qq.com) + */ +public class TxnReqItem { + + private Operate KV; + + public TxnReqItem(Operate KV) { + this.KV = KV; + } + + public TxnReqItem() { + } + + public Operate getKV() { + return KV; + } + + public void setKV(Operate KV) { + this.KV = KV; + } +} diff --git a/src/main/java/com/ecwid/consul/v1/transactions/model/TxnRespItem.java b/src/main/java/com/ecwid/consul/v1/transactions/model/TxnRespItem.java new file mode 100644 index 00000000..c9c103df --- /dev/null +++ b/src/main/java/com/ecwid/consul/v1/transactions/model/TxnRespItem.java @@ -0,0 +1,26 @@ +package com.ecwid.consul.v1.transactions.model; + +import com.ecwid.consul.v1.kv.model.GetValue; + +/** + * @author Trisia Cliven (quanguanyu@qq.com) + */ +public class TxnRespItem { + + private GetValue KV; + + public GetValue getKV() { + return KV; + } + + public void setKV(GetValue KV) { + this.KV = KV; + } + + @Override + public String toString() { + return "TxnRespItem{" + + "KV=" + KV + + '}'; + } +} diff --git a/src/main/java/com/ecwid/consul/v1/transactions/model/TxnResult.java b/src/main/java/com/ecwid/consul/v1/transactions/model/TxnResult.java new file mode 100644 index 00000000..9f699fb0 --- /dev/null +++ b/src/main/java/com/ecwid/consul/v1/transactions/model/TxnResult.java @@ -0,0 +1,55 @@ +package com.ecwid.consul.v1.transactions.model; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; + +/** + * @author Trisia Cliven (quanguanyu@qq.com) + * @since 2019-11-11 10:31:48 + */ +public class TxnResult { + /** + * Results has entries for some operations + * if the transaction was successful. + * To save space, + * the Value for KV results will be null for any Verb other than "get" or "get-tree". + * Like the /v1/kv/ endpoint, Value will be Base64-encoded if it is present. + * Also, no result entries will be added for verbs that delete keys. + */ + @SerializedName("Results") + private List results; + + /** + * Errors has entries describing which operations failed if the transaction was rolled back. + * The OpIndex gives the index of the failed operation in the transaction, + * and What is a string with an error message about why that operation failed. + */ + @SerializedName("Errors") + private List errors; + + + public List getResults() { + return results; + } + + public void setResults(List results) { + this.results = results; + } + + public List getErrors() { + return errors; + } + + public void setErrors(List errors) { + this.errors = errors; + } + + @Override + public String toString() { + return "TxnResult{" + + "results=" + results + + ", errors=" + errors + + '}'; + } +} diff --git a/src/main/java/com/ecwid/consul/v1/transactions/model/Verb.java b/src/main/java/com/ecwid/consul/v1/transactions/model/Verb.java new file mode 100644 index 00000000..6e49a3da --- /dev/null +++ b/src/main/java/com/ecwid/consul/v1/transactions/model/Verb.java @@ -0,0 +1,80 @@ +package com.ecwid.consul.v1.transactions.model; + +import com.google.gson.annotations.SerializedName; + +/** + * Specifies the type of operation to perform. + * + * @author Trisia Cliven (quanguanyu@qq.com) + */ +public enum Verb { + + /** + * Sets the Key to the given Value + */ + set("set"), + /** + * Sets, but with CAS semantics + */ + cas("cas"), + /** + * Lock with the given Session + */ + lock("lock"), + /** + * Unlock with the given Session + */ + unlock("unlock"), + /** + * Get the key, fails if it does not exist + */ + get("get"), + /** + * Gets all keys with the prefix + */ + @SerializedName("get-tree") + get_tree("get-tree"), + /** + * Fail if modify index != index + */ + @SerializedName("check-index") + check_index("check-index"), + /** + * Fail if not locked by session + */ + @SerializedName("check-session") + check_session("check-session"), + /** + * Fail if key exists + */ + @SerializedName("check-not-exists") + check_not_exists("check-not-exists"), + /** + * Delete the key + */ + delete("delete"), + /** + * Delete all keys with a prefix + */ + @SerializedName("delete-tree") + delete_tree("delete-tree"), + /** + * Delete, but with CAS semantics + */ + @SerializedName("delete-cas") + delete_cas("delete-cas"); + + /** + * Verb + */ + private String verb; + + Verb(String verb) { + this.verb = verb; + } + + @Override + public String toString() { + return verb; + } +} diff --git a/src/test/java/com/ecwid/consul/v1/transactions/ParamBuilderTest.java b/src/test/java/com/ecwid/consul/v1/transactions/ParamBuilderTest.java new file mode 100644 index 00000000..a4ca64b0 --- /dev/null +++ b/src/test/java/com/ecwid/consul/v1/transactions/ParamBuilderTest.java @@ -0,0 +1,21 @@ +package com.ecwid.consul.v1.transactions; + +import com.ecwid.consul.json.GsonFactory; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ParamBuilderTest { + + @Test + void BuilderSerializedTest() { + ParamBuilder p = ParamBuilder.getInstance() + .kvDeleteTree("A") + .kvCheckIndex("A", 12) + .kvDeleteCas("B"); + String s = GsonFactory.getGson().toJson(p); + System.out.println(s); + String ans = "{\"operates\":[{\"KV\":{\"Verb\":\"delete-tree\",\"Key\":\"A\"}},{\"KV\":{\"Verb\":\"check-index\",\"Key\":\"A\",\"Index\":12}},{\"KV\":{\"Verb\":\"delete-cas\",\"Key\":\"B\"}}]}"; + assertEquals(ans, s); + } +} diff --git a/src/test/java/com/ecwid/consul/v1/transactions/TransactionsConsulClientTest.java b/src/test/java/com/ecwid/consul/v1/transactions/TransactionsConsulClientTest.java new file mode 100644 index 00000000..69d5dfa6 --- /dev/null +++ b/src/test/java/com/ecwid/consul/v1/transactions/TransactionsConsulClientTest.java @@ -0,0 +1,67 @@ +package com.ecwid.consul.v1.transactions; + +import com.ecwid.consul.ConsulTestConstants; +import com.ecwid.consul.v1.ConsulRawClient; +import com.ecwid.consul.v1.Response; +import com.ecwid.consul.v1.session.SessionClient; +import com.ecwid.consul.v1.session.SessionConsulClient; +import com.ecwid.consul.v1.session.model.NewSession; +import com.ecwid.consul.v1.transactions.model.TxnResult; +import com.pszymczyk.consul.ConsulProcess; +import com.pszymczyk.consul.ConsulStarterBuilder; +import com.pszymczyk.consul.infrastructure.Ports; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + + +import static org.junit.jupiter.api.Assertions.*; + +class TransactionsConsulClientTest { + + private ConsulProcess consul; + private int port = Ports.nextAvailable(); + + private ConsulRawClient consulClient; + private TransactionsConsulClient client; + SessionClient sessionClient; + + @BeforeEach + void setUp() { + consulClient = new ConsulRawClient("localhost", port); + + consul = ConsulStarterBuilder.consulStarter() + .withConsulVersion(ConsulTestConstants.CONSUL_VERSION) + .withHttpPort(port) + .build() + .start(); + client = new TransactionsConsulClient(consulClient); + sessionClient = new SessionConsulClient(consulClient); + } + + @AfterEach + void tearDown() { + consul.close(); + } + + + @Test + void commit() { + NewSession newSession = new NewSession(); + newSession.setName("LOCK_SESSIONRawResponse_NAME"); + String session = sessionClient.sessionCreate(newSession, null).getValue(); + String key = "KEY_TO_BE_LOCK"; + ParamBuilder builder = ParamBuilder.getInstance() + .kvLock(key, "_LOCK", session) + .kvGet(key) + .kvSet(key, "_MODIFY") + .kvUnlock(key, "_UNLOCK", session) + // if delete success that is no result report + .kvDelete(key); + + Response resultResponse = client.commit(builder); + sessionClient.sessionDestroy(session, null); + + assertEquals(4, resultResponse.getValue().getResults().size()); + } +}