Java tutorial
/* * Copyright (c) 2014 Intellectual Reserve, Inc. All rights reserved. * * 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 etcd.client; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufInputStream; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.handler.codec.http.DefaultFullHttpRequest; import io.netty.handler.codec.http.FullHttpRequest; import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpVersion; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.time.Duration; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.Collections; import java.util.List; import java.util.Optional; class DefaultEtcdClient implements EtcdClient { private static final ObjectMapper MAPPER = new ObjectMapper(); private final HttpClient client; private final EventLoopGroup eventLoopGroup; DefaultEtcdClient(EtcdClientBuilder builder) { EventLoopGroup eventLoopGroup = builder.eventLoopGroup; if (eventLoopGroup == null) { this.eventLoopGroup = eventLoopGroup = new NioEventLoopGroup(); } else { this.eventLoopGroup = null; } client = new HttpClient(eventLoopGroup, builder.executor, builder.servers, builder.retryOnConnectFailure); } @Override public DeleteRequest prepareDelete(String key) { return new DeleteRequestImpl(client, key); } @Override public GetRequest prepareGet(String key) { return new GetRequestImpl(client, key); } @Override public SetRequest prepareSet(String key) { return new SetRequestImpl(client, key); } @Override public WatchRequest watch(String Key) { throw new UnsupportedOperationException("The watch API isn't supported yet."); } @Override public void close() { if (eventLoopGroup != null) { eventLoopGroup.shutdownGracefully(); } } private class GetRequestImpl extends AbstractRequest implements GetRequest { private final String key; private boolean consistent = false; private boolean recursive = false; private boolean sorted = false; private boolean wait = false; private Long waitIndex = null; public GetRequestImpl(HttpClient client, String key) { super(client); key = validateKey(key); this.key = key; } @Override protected FullHttpRequest buildRequest() { final StringBuilder uriBuilder = new StringBuilder(); uriBuilder.append("/v2/keys").append(key); final StringBuilder queryBuilder = new StringBuilder(); if (consistent) { appendQueryStringSeparator(queryBuilder); queryBuilder.append("consistent=true"); } if (recursive) { appendQueryStringSeparator(queryBuilder); queryBuilder.append("recursive=true"); } if (sorted) { appendQueryStringSeparator(queryBuilder); queryBuilder.append("sorted=true"); } if (wait) { appendQueryStringSeparator(queryBuilder); queryBuilder.append("wait=true"); } if (waitIndex != null) { appendQueryStringSeparator(queryBuilder); queryBuilder.append("waitIndex=").append(waitIndex); } uriBuilder.append(queryBuilder); return new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, uriBuilder.toString()); } @Override protected Result createResult(FullHttpResponse response) { if (!response.getStatus().equals(HttpResponseStatus.OK)) { throwException(response); } return marshalResult(response); } @Override public GetRequest consistent() { consistent = true; return this; } @Override public GetRequest recursive() { recursive = true; return this; } @Override public GetRequest sorted() { sorted = true; return this; } @Override public GetRequest waitForChange() { wait = true; return this; } @Override public GetRequest waitIndex(long index) { waitIndex = index; return this; } } private void throwException(FullHttpResponse response) { try { final ErrorBody errorBody = MAPPER.readValue(new ByteBufInputStream(response.content()), ErrorBody.class); final String message = errorBody.message == null ? "Error executing request" : errorBody.message; if (response.getStatus().code() == HttpResponseStatus.NOT_FOUND.code()) { throw new KeyNotFoundException(message, errorBody.errorCode, errorBody.index, errorBody.cause); } final Long etcdIndex = Long.valueOf(response.headers().get("X-Etcd-Index")); throw new EtcdRequestException(message, errorBody.errorCode, etcdIndex, errorBody.cause); } catch (IOException e) { throw new RuntimeException(e); } } private class DeleteRequestImpl extends AbstractRequest implements DeleteRequest { private final String key; private String previousValue; private Long previousIndex; private boolean directory; private boolean recursive; public DeleteRequestImpl(HttpClient client, String key) { super(client); this.key = validateKey(key); } @Override protected FullHttpRequest buildRequest() { final StringBuilder uriBuilder = new StringBuilder(); uriBuilder.append("/v2/keys").append(key); final StringBuilder queryBuilder = new StringBuilder(); if (previousValue != null) { appendQueryStringSeparator(queryBuilder); queryBuilder.append("prevValue=").append(urlEncode(previousValue)); } if (previousIndex != null) { appendQueryStringSeparator(queryBuilder); queryBuilder.append("prevIndex=").append(previousIndex); } if (directory) { appendQueryStringSeparator(queryBuilder); queryBuilder.append("dir=true"); } if (recursive) { appendQueryStringSeparator(queryBuilder); queryBuilder.append("recursive=true"); } uriBuilder.append(queryBuilder); return new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.DELETE, uriBuilder.toString()); } @Override protected Result createResult(FullHttpResponse response) { if (!response.getStatus().equals(HttpResponseStatus.OK)) { throwException(response); } return marshalResult(response); } @Override public DeleteRequest previousValue(String value) { previousValue = value; return this; } @Override public DeleteRequest previousIndex(long index) { previousIndex = index; return this; } @Override public DeleteRequest directory() { directory = true; return this; } @Override public DeleteRequest recursive() { recursive = true; return this; } } private class SetRequestImpl extends AbstractRequest implements SetRequest { private final String key; private boolean directory = false; private Duration timeToLive; private String value; private boolean mustExist; private boolean mustNotExist; private String previousValue; private Long previousIndex; private boolean inOrder; private SetRequestImpl(HttpClient client, String key) { super(client); this.key = validateKey(key); } @Override protected FullHttpRequest buildRequest() { final HttpMethod method = inOrder ? HttpMethod.POST : HttpMethod.PUT; final DefaultFullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, method, "/v2/keys" + key); final StringBuilder body = new StringBuilder(); if (value != null) { body.append("value=").append(urlEncode(value)); } if (timeToLive != null) { appendFieldSeparator(body); body.append("ttl=").append(timeToLive.getSeconds()); } if (directory) { appendFieldSeparator(body); body.append("dir=true"); } if (mustExist && mustNotExist) { throw new EtcdException( "In what universe does it even makes sense for something to be required to both exist and not exist?"); } if (mustExist) { appendFieldSeparator(body); body.append("prevExist=true"); } if (mustNotExist) { appendFieldSeparator(body); body.append("prevExist=false"); } if (previousValue != null) { appendFieldSeparator(body); body.append("prevValue=").append(urlEncode(previousValue)); } if (previousIndex != null) { appendFieldSeparator(body); body.append("prevIndex=").append(previousIndex); } final byte[] content = body.toString().getBytes(); request.headers().add(HttpHeaders.Names.CONTENT_TYPE, HttpHeaders.Values.APPLICATION_X_WWW_FORM_URLENCODED + ";charset=utf-8"); request.headers().add(HttpHeaders.Names.CONTENT_LENGTH, content.length); request.content().writeBytes(content); return request; } @Override protected Result createResult(FullHttpResponse response) { if (!(response.getStatus().equals(HttpResponseStatus.CREATED) || response.getStatus().equals(HttpResponseStatus.OK))) { throwException(response); } return marshalResult(response); } @Override public SetRequest value(String value) { this.value = value; return this; } @Override public SetRequest timeToLive(Duration duration) { this.timeToLive = duration; return this; } @Override public SetRequest directory() { directory = true; return this; } @Override public SetRequest mustExist() { mustExist = true; return this; } @Override public SetRequest mustNotExist() { mustNotExist = true; return this; } @Override public SetRequest previousValue(String value) { this.previousValue = value; return this; } @Override public SetRequest previousIndex(long index) { this.previousIndex = index; return this; } @Override public SetRequest inOrder() { inOrder = true; return this; } } private void appendQueryStringSeparator(StringBuilder queryString) { if (queryString.length() == 0) { queryString.append('?'); } else if (queryString.length() > 0) { queryString.append('&'); } } private void appendFieldSeparator(StringBuilder body) { if (body.length() > 0) { body.append('&'); } } private static String validateKey(String key) { if (!key.startsWith("/")) { key = "/" + key; } return key; } private String urlEncode(String value) { try { return URLEncoder.encode(value, "UTF-8"); } catch (UnsupportedEncodingException e) { throw new Error(e); } } private Result marshalResult(FullHttpResponse response) { try { final EtcdMeta meta = new EtcdMeta(convertLong(response.headers().get("X-Etcd-Index")), convertLong(response.headers().get("X-Raft-Index")), convertLong(response.headers().get("X-Raft-Term"))); final ByteBuf content = response.content(); if (content.readableBytes() > 0) { final ByteBufInputStream inputStream = new ByteBufInputStream(content); final JsonResult json = MAPPER.readValue(inputStream, JsonResult.class); return new Result() { @Override public EtcdMeta getResponseMeta() { return meta; } @Override public Action getAction() { return json.action; } @Override public Node getNode() { return json.node; } @Override public Optional<Node> getPreviousNode() { return Optional.ofNullable(json.previousNode); } @Override public String toString() { return "Result {" + "meta = " + getResponseMeta() + ", action = " + getAction() + ", node = " + getNode() + ", prevNode = " + getPreviousNode().orElse(null) + "}"; } }; } else { throw new EtcdException("Empty response from server."); } } catch (IOException e) { throw new EtcdException(e); } } private static long convertLong(String value) { if (value == null) { return -1; } try { return Long.valueOf(value); } catch (NumberFormatException e) { return -1; } } private static class JsonResult { private final Action action; private final Node node; private final Node previousNode; @JsonCreator private JsonResult(@JsonProperty("action") String action, @JsonProperty("node") JsonNode node, @JsonProperty("prevNode") JsonNode previousNode) { this.action = Action.valueOf(action.toUpperCase()); this.node = node; this.previousNode = previousNode; } } private static class JsonNode implements Node { private final long createdIndex; private final Long modifiedIndex; private final String key; private final String value; private final Instant expiration; private final Duration timeToLive; private final boolean directory; private final List<? extends Node> nodes; @JsonCreator private JsonNode(@JsonProperty("createdIndex") long createdIndex, @JsonProperty("modifiedIndex") Long modifiedIndex, @JsonProperty("key") String key, @JsonProperty("value") String value, @JsonProperty("expiration") String expiration, @JsonProperty("ttl") Long timeToLive, @JsonProperty("dir") boolean directory, @JsonProperty("nodes") List<JsonNode> nodes) { this.createdIndex = createdIndex; this.modifiedIndex = modifiedIndex; this.key = key; this.value = value; this.expiration = expiration == null ? null : parseDate(expiration); this.timeToLive = timeToLive == null ? null : Duration.ofSeconds(timeToLive); this.directory = directory; this.nodes = nodes == null ? Collections.emptyList() : nodes; } private Instant parseDate(String expiration) { return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(expiration, Instant::from); } @Override public long getCreatedIndex() { return createdIndex; } @Override public Optional<Long> getModifiedIndex() { return Optional.ofNullable(modifiedIndex); } @Override public String getKey() { return key; } @Override public Optional<String> getValue() { return Optional.ofNullable(value); } @Override public Optional<Instant> getExpiration() { return Optional.ofNullable(expiration); } @Override public Optional<Duration> getTimetoLive() { return Optional.ofNullable(timeToLive); } @Override public boolean isDirectory() { return directory; } @Override public List<? extends Node> getNodes() { return nodes; } @Override public String toString() { return "JsonNode{" + "createdIndex=" + createdIndex + ", modifiedIndex=" + modifiedIndex + ", key='" + key + '\'' + ", value='" + value + '\'' + ", expiration=" + expiration + ", timeToLive=" + timeToLive + ", directory=" + directory + ", nodes=" + nodes + '}'; } } private static class ErrorBody { private final int errorCode; private final String cause; private final String message; private final Long index; private ErrorBody(@JsonProperty("errorCode") int errorCode, @JsonProperty("cause") String cause, @JsonProperty("message") String message, @JsonProperty("index") Long index) { this.errorCode = errorCode; this.cause = cause; this.message = message; this.index = index; } } }