From e81d80fc79f1b809ca3e2811b1e1189d8035c12c Mon Sep 17 00:00:00 2001 From: "Anh (Duke) Nguyen" <58082199+dukeraphaelng@users.noreply.github.com> Date: Mon, 8 Mar 2021 13:27:51 +1100 Subject: [PATCH 1/3] feat: complete majority of the API Authored By: Duke Nguyen Co-Authored By: Caspian Baska --- README.md | 60 +++++++++++----------- shard.yml | 1 + spec/kv_spec.cr | 6 +-- src/etcd/auth.cr | 97 +++++++++++++++++++++++++---------- src/etcd/cluster.cr | 28 ++++++---- src/etcd/kv.cr | 46 ++++++++++++----- src/etcd/lease.cr | 40 ++++++++++----- src/etcd/maintenance.cr | 24 +++++++++ src/etcd/model/auth.cr | 32 ++++++++++++ src/etcd/model/base.cr | 23 +++++++++ src/etcd/model/cluster.cr | 24 +++++++++ src/etcd/model/kv.cr | 35 ++++++------- src/etcd/model/lease.cr | 37 ++++++++----- src/etcd/model/maintenance.cr | 52 +++++++++++++++++-- src/etcd/watch.cr | 2 +- 15 files changed, 377 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 8778a4f..c410f72 100644 --- a/README.md +++ b/README.md @@ -34,43 +34,44 @@ client.range("/service/hello").kvs.try(&.first?) #=> # # #= 0.35" license: MIT + authors: - Caspian Baska - Duke Nguyen diff --git a/spec/kv_spec.cr b/spec/kv_spec.cr index 64833b3..a1aab99 100644 --- a/spec/kv_spec.cr +++ b/spec/kv_spec.cr @@ -54,7 +54,7 @@ module Etcd client = Etcd.from_env response = client.kv.put("#{TEST_PREFIX}/hello", "world") - response.should be_a Model::PutResponse + response.should be_a Model::Put end it "queries a range of keys" do @@ -64,7 +64,7 @@ module Etcd client.kv.put(key, value) response = client.kv.range(key) - response.should be_a Model::RangeResponse + response.should be_a Model::Range values = response.kvs || [] of Model::Kv value_present = values.any? { |r| r.key == key && r.value == value } value_present.should be_true @@ -82,7 +82,7 @@ module Etcd client.kv.put(key1, value1, lease: lease.id) response = client.kv.range_prefix(key0) - response.should be_a Model::RangeResponse + response.should be_a Model::Range values = response.kvs || [] of Model::Kv key_present = values.any? { |r| r.key == key1 && r.value == value1 } key_present.should be_true diff --git a/src/etcd/auth.cr b/src/etcd/auth.cr index ef2d65a..1b6d2c0 100644 --- a/src/etcd/auth.cr +++ b/src/etcd/auth.cr @@ -5,82 +5,123 @@ class Etcd::Auth end # auth/authenticate - def authenticate - raise "unimplemented" + def authenticate(name : String, password : String) + validate!(name) + + response = client.api.post("/auth/auth/authenticate", {name: name, password: password}).body + Model::Token.from_json(response).token end # auth/disable def disable - raise "unimplemented" + client.api.post("/auth/auth/disable").success? end # auth/enable def enable - raise "unimplemented" + client.api.post("/auth/auth/enable").success? end # auth/role/add - def role_add - raise "unimplemented" + def role_add(name : String) + validate!(name) + + client.api.post("/auth/role/add", {name: name}).success? end # auth/role/delete - def role_delete - raise "unimplemented" + def role_delete(role : String) + client.api.post("/auth/role/delete", {role: role}).success? end # auth/role/get - def role_get - raise "unimplemented" + def role_get(role : String) + response = client.api.post("/auth/role/get", {role: role}).body + Model::Permissions.from_json(response).perm end # auth/role/grant - def role_grant - raise "unimplemented" + def role_grant(name : String, perm_key : String, range_end : String) + validate!(name) + + options = { + :name => name, + :perm => { + :key => perm_key, + :permType => "READ", + :range_end => range_end, + }, + } + + client.api.post("/auth/role/grant", options).success? end # auth/role/list def role_list - raise "unimplemented" + response = client.api.post("/auth/role/list").body + Roles.from_json(response).roles end # auth/role/revoke - def role_revoke - raise "unimplemented" + def role_revoke(key : String, range_end : String, role : String) + client.api.post("/auth/role/revoke").success? end # auth/user/add - def user_add - raise "unimplemented" + def user_add(name : String, password : String, no_password : Bool) + validate!(name) + + options = { + :name => name, + :options => { + :no_password => no_password, + }, + :password => password, + } + + client.api.post("/auth/user/add", options).success? end # auth/user/changepw - def user_changepw - raise "unimplemented" + def user_changepw(name : String, password : String) + validate!(name) + + client.api.post("/auth/user/changepw", {name: name, password: password}).success? end # auth/user/delete - def user_delete - raise "unimplemented" + def user_delete(name : String) + validate!(name) + + client.api.post("/auth/user/delete", {name: name}).success? end # auth/user/get - def user_get - raise "unimplemented" + def user_get(name : String) + validate!(name) + + response = client.api.post("/auth/user/get", {name: name}).body + Model::Roles.from_json(response).roles end # auth/user/grant - def user_grant - raise "unimplemented" + def user_grant(role : String, user : String) + client.api.post("/auth/user/grant").success? end # auth/user/list def user_list - raise "unimplemented" + response = client.api.post("/auth/user/list").body + Model::Users.from_json(response).users end # auth/user/revoke - def user_revoke - raise "unimplemented" + def user_revoke(name : String, role : String) + validate!(name) + client.api.post("/auth/user/revoke").success? + end + + private def validate!(name : String) + raise ArgumentError.new("`name` is empty") if name.empty? end end diff --git a/src/etcd/cluster.cr b/src/etcd/cluster.cr index 4a813eb..e63b0c5 100644 --- a/src/etcd/cluster.cr +++ b/src/etcd/cluster.cr @@ -1,26 +1,36 @@ module Etcd::Cluster + private getter client : Etcd::Client + + def initialize(@client = Etcd::Client.new) + end + # POST cluster/member/add - def member_add - raise "unimplemented" + def member_add(is_learner : Bool, peer_urls : Array(String)) + response = client.api.post("/cluster/member/add", {is_learner: is_learner, peerURLs: peer_urls}).body + Model::Cluster::MemberAdd.from_json(response) end # POST cluster/member/list def member_list - raise "unimplemented" + response = client.api.post("/cluster/member/list").body + Model::Cluster::Members.from_json(response).members end # POST cluster/member/promote - def member_promote - raise "unimplemented" + def member_promote(id : UInt64) + response = client.api.post("/cluster/member/promote", {ID: id}).body + Model::Cluster::Members.from_json(response).members end # POST cluster/member/remove - def member_remove - raise "unimplemented" + def member_remove(id : UInt64) + response = client.api.post("/cluster/member/remove", {ID: id}).body + Model::Cluster::Members.from_json(response).members end # POST cluster/member/update - def member_update - raise "unimplemented" + def member_update(id : UInt64, peer_urls : Array(String)) + response = client.api.post("/cluster/member/update", {ID: id, peerURLs: peer_urls}).body + Model::Cluster::Members.from_json(response).members end end diff --git a/src/etcd/kv.cr b/src/etcd/kv.cr index 73bcaf8..335c799 100644 --- a/src/etcd/kv.cr +++ b/src/etcd/kv.cr @@ -38,7 +38,7 @@ module Etcd }.compact response = client.api.post("/kv/put", options) - Model::PutResponse.from_json(response.body) + Model::Put.from_json(response.body) end # Deletes key or range of keys @@ -55,7 +55,7 @@ module Etcd }.compact response = client.api.post("/kv/deleterange", post_body) - Model::DeleteResponse.from_json(response.body) + Model::Delete.from_json(response.body) end # Deletes an entire keyspace prefix @@ -66,7 +66,7 @@ module Etcd end # Queries a range of keys - def range(key, range_end : String? = nil, base64_keys : Bool = true) + def range(key, range_end : String? = nil, limit : Int64 = 0_i64, base64_keys : Bool = true) # Otherwise bypass encoding keys if base64_keys key = Base64.strict_encode(key) @@ -76,17 +76,18 @@ module Etcd post_body = { :key => key, :range_end => range_end, + :limit => limit, }.compact response = client.api.post("/kv/range", post_body) - Model::RangeResponse.from_json(response.body) + Model::Range.from_json(response.body) end # Query keys beneath a prefix - def range_prefix(prefix) + def range_prefix(prefix, limit : Int64 = 0_i64) encoded_prefix = Base64.strict_encode(prefix) range_end = prefix_range_end encoded_prefix - range(encoded_prefix, range_end, base64_keys: false) + range(encoded_prefix, range_end, limit, base64_keys: false) end # Query all keys >= key @@ -96,6 +97,15 @@ module Etcd range(encoded_key, range_end, base64_keys: false) end + def txn(post_body) + response = client.api.post("/kv/txn", post_body) + Model::Txn.from_json(response.body).succeeded + end + + def compaction(physical : Bool, revision : Int64) + client.api.post("/kv/compaction", {:physical => physical, :revision => revision}).success? + end + # Non-Standard Requests ############################################################################## @@ -123,7 +133,7 @@ module Etcd } response = client.api.post("/kv/txn", post_body) - Model::TxnResponse.from_json(response.body).succeeded + Model::Txn.from_json(response.body).succeeded end # Moves a value from `key` to `key_destination`, deleting the kv at `key` in the process. @@ -133,12 +143,20 @@ module Etcd value = Base64.strict_encode(value.to_s) post_body = { - :compare => [{ - :key => key_d, - :value => Base64.strict_encode("0"), - :target => "VERSION", - :result => "EQUAL", - }], + :compare => [ + { + :key => key_d, + :value => Base64.strict_encode("0"), + :target => "VERSION", + :result => "EQUAL", + }, + { + :key => key_o, + :value => Base64.strict_encode("0"), + :target => "VERSION", + :result => "NOT_EQUAL", + }, + ], :success => [ { :request_put => { @@ -186,7 +204,7 @@ module Etcd }], } - Model::TxnResponse.from_json(client.api.post("/kv/txn", post_body).body).succeeded + Model::Txn.from_json(client.api.post("/kv/txn", post_body).body).succeeded end def get(key) : String? diff --git a/src/etcd/lease.cr b/src/etcd/lease.cr index 1673b48..4be0908 100644 --- a/src/etcd/lease.cr +++ b/src/etcd/lease.cr @@ -6,11 +6,11 @@ class Etcd::Lease def initialize(@client = Etcd::Client.new) end - # Requests a lease - # ttl ttl of granted lease Int64 - # id id of 0 prompts etcd to assign any id to lease UInt64 - def grant(ttl : Int64 = @ttl, id = 0) - Model::Grant.from_json(client.api.post("/lease/grant", {TTL: ttl, ID: 0}).body) + # /kv/lease/leases + # /lease/leases + # Queries for all existing leases in an etcd cluster + def leases + Model::Leases.from_json(client.api.post("/kv/lease/leases").body).leases.map(&.id) end # Requests persistence of lease. @@ -21,6 +21,16 @@ class Etcd::Lease nil end + # /kv/lease/revoke + # Revokes an etcd lease + # id Id of lease Int64 + def revoke(id : Int64) + # To get header: Etcd::Model::WithHeader.from_json(response.body) + client.api.post("/kv/lease/revoke", {ID: id}).success? + end + + # /kv/lease/timetolive + # /lease/timetolive # Queries the TTL of a lease # id id of lease Int64 # query_keys query all the lease's keys for ttl Bool @@ -28,14 +38,20 @@ class Etcd::Lease Model::TimeToLive.from_json(client.api.post("/kv/lease/timetolive", {ID: id, keys: query_keys}).body) end - # Revokes an etcd lease - # id Id of lease Int64 - def revoke(id : Int64) - client.api.post("/kv/lease/revoke", {ID: id}).success? + # /lease/grant + # Requests a lease + # ttl ttl of granted lease Int64 + # id id of 0 prompts etcd to assign any id to lease UInt64 + def grant(ttl : Int64 = @ttl, id = 0) + Model::Grant.from_json(client.api.post("/lease/grant", {TTL: ttl, ID: 0}).body) end - # Queries for all existing leases in an etcd cluster - def leases - Model::LeasesArray.from_json(client.api.post("/kv/lease/leases").body).leases.map(&.id) + # /lease/keepalive + # Requests persistence of lease. + # Must be invoked periodically to avoid key loss. + def keep_alive(id : Int64) : Int64? + model = Model::KeepAlive.from_json(client.api.post("/lease/keepalive", {ID: id}).body) + raise Exception.new(model.error.not_nil!.to_s) if model.result.nil? + model.result.not_nil!.ttl end end diff --git a/src/etcd/maintenance.cr b/src/etcd/maintenance.cr index d3a554f..1e46f5b 100644 --- a/src/etcd/maintenance.cr +++ b/src/etcd/maintenance.cr @@ -6,6 +6,26 @@ class Etcd::Maintenance def initialize(@client = Etcd::Client.new) end + def alarm(action : Model::AlarmAction, alarm : Model::AlarmType, member_id : UInt64) + response = client.api.post("/maintenance/alarm", {action: action, alarm: alarm, memberID: member_id}).body + Model::Alarms.from_json(response).alarms + end + + def defragment + client.api.post("/maintenance/defragment").success? + end + + def hash(revision : String) + response = client.api.post("/maintenance/hash").body + Model::Revision.from_json(response) + end + + def snapshot + model = Model::Snapshot.from_json(client.api.post("/maintenance/snapshot").body) + raise Exception.new(model.error.not_nil!.to_s) if model.result.nil? + model.result + end + # Queries status of etcd instance def status Model::Status.from_json(client.api.post("/maintenance/status").body) @@ -15,4 +35,8 @@ class Etcd::Maintenance def leader status.leader end + + def transfer_leadership(target_id : UInt64) + client.api.post("/maintenance/transfer_leadership", {targetID: target_id}).success? + end end diff --git a/src/etcd/model/auth.cr b/src/etcd/model/auth.cr index e69de29..f096935 100644 --- a/src/etcd/model/auth.cr +++ b/src/etcd/model/auth.cr @@ -0,0 +1,32 @@ +require "./base" + +module Etcd::Model + struct Token < WithHeader + getter token : String + end + + struct Permissions < WithHeader + getter perm : Array(Permission) + end + + enum PermissionType + READ + WRITE + READWRITE + end + + struct Permission < WithHeader + getter key : String # Bytes + @[JSON::Field(key: "permType")] + getter perm_type : PermissionType + getter range_end : String # Bytes + end + + struct Roles < WithHeader + getter roles : Array(String) + end + + struct Users < WithHeader + getter user : Array(String) + end +end diff --git a/src/etcd/model/base.cr b/src/etcd/model/base.cr index 7b18751..1212102 100644 --- a/src/etcd/model/base.cr +++ b/src/etcd/model/base.cr @@ -3,10 +3,33 @@ require "json" # Etcd data models # Refer to documentation https://coreos.com/etcd/docs/latest/dev-guide/api_reference_v3.html module Etcd::Model + struct Enum + def to_json(json : JSON::Builder) + json.string(value) + end + end + private abstract struct Base include JSON::Serializable end + abstract struct WithHeader < Base + getter header : Header + end + + struct Error < Base + getter details : Array(ErrorDetail) + getter grpc_code : Int32 + getter http_code : Int32 + getter http_status : String + getter message : String + end + + struct ErrorDetail < Base + getter type_url : String + getter value : String # Bytes + end + struct Header < Base @[JSON::Field(converter: Etcd::Model::StringTypeConverter(UInt64))] getter cluster_id : UInt64? diff --git a/src/etcd/model/cluster.cr b/src/etcd/model/cluster.cr index e69de29..f971fac 100644 --- a/src/etcd/model/cluster.cr +++ b/src/etcd/model/cluster.cr @@ -0,0 +1,24 @@ +require "./base" + +module Etcd::Model + struct MemberAdd < WithHeader + getter member : Member + getter members : Array(Member) + end + + struct Members < WithHeader + getter members : Array(Member) + end + + struct Member + @[JSON::Field(key: "ID", converter: Etcd::Model::StringTypeConverter(UInt64))] + getter id : UInt64 + @[JSON::Field(key: "clientURLs")] + getter client_urls : Array(String) + @[JSON::Field(key: "isLearner")] + getter is_learner : Bool + getter name : String + @[JSON::Field(key: "peerURLs")] + getter peer_urls : Array(String) + end +end diff --git a/src/etcd/model/kv.cr b/src/etcd/model/kv.cr index f15ba84..85c25e2 100644 --- a/src/etcd/model/kv.cr +++ b/src/etcd/model/kv.cr @@ -2,48 +2,45 @@ require "./base" module Etcd::Model struct Kv < Base - @[JSON::Field(converter: Etcd::Model::Base64Converter)] - getter key : String - @[JSON::Field(converter: Etcd::Model::Base64Converter)] - getter value : String? @[JSON::Field(converter: Etcd::Model::StringTypeConverter(UInt64))] getter create_revision : UInt64? + @[JSON::Field(converter: Etcd::Model::Base64Converter)] + getter key : String + @[JSON::Field(converter: Etcd::Model::StringTypeConverter(Int64))] + getter lease : Int64? @[JSON::Field(converter: Etcd::Model::StringTypeConverter(UInt64))] getter mod_revision : UInt64? + @[JSON::Field(converter: Etcd::Model::Base64Converter)] + getter value : String? @[JSON::Field(converter: Etcd::Model::StringTypeConverter(Int64))] getter version : Int64? - @[JSON::Field(converter: Etcd::Model::StringTypeConverter(Int64))] - getter lease : Int64? end - struct RangeResponse < Base + struct Range < Base getter header : Header? @[JSON::Field(converter: Etcd::Model::StringTypeConverter(Int32))] getter count : Int32 = 0 getter kvs : Array(Etcd::Model::Kv) = [] of Etcd::Model::Kv end - struct PutResponse < Base - getter header : Header + struct Put < WithHeader getter prev_kv : Kv? end - struct DeleteResponse < Base - getter header : Header + struct Delete < WithHeader @[JSON::Field(converter: Etcd::Model::StringTypeConverter(Int32))] getter deleted : Int32 = 0 getter prev_kvs : Array(Etcd::Model::Kv) = [] of Etcd::Model::Kv end struct TxnResponse < Base - getter header : Header - getter succeeded : Bool = false + getter response_range : Range? + getter response_put : Put? + getter response_delete : Delete? + end - alias Response = NamedTuple( - response_range: Etcd::Model::RangeResponse?, - response_put: Etcd::Model::PutResponse?, - response_delete: Etcd::Model::DeleteResponse?, - ) - getter responses : Array(Etcd::Model::TxnResponse::Response) = [] of Etcd::Model::TxnResponse::Response + struct Txn < WithHeader + getter succeeded : Bool = false + getter responses : Array(TxnResponse) = [] of TxnResponse end end diff --git a/src/etcd/model/lease.cr b/src/etcd/model/lease.cr index 896ead0..f0a4a29 100644 --- a/src/etcd/model/lease.cr +++ b/src/etcd/model/lease.cr @@ -1,31 +1,44 @@ require "./base" module Etcd::Model - struct Grant < Base - @[JSON::Field(key: "ID", converter: Etcd::Model::StringTypeConverter(Int64))] - getter id : Int64 - @[JSON::Field(key: "TTL", converter: Etcd::Model::StringTypeConverter(Int64))] - getter ttl : Int64 + struct Leases < WithHeader + getter leases : Array(Lease) end - struct KeepAlive < Base - @[JSON::Field(root: "TTL", converter: Etcd::Model::StringTypeConverter(Int64))] - getter result : Int64? + struct Lease < Base + @[JSON::Field(key: "ID", converter: Etcd::Model::StringTypeConverter(Int64))] + getter id : Int64 end - struct TimeToLive < Base + struct TimeToLive < WithHeader + @[JSON::Field(key: "ID", converter: Etcd::Model::StringTypeConverter(Int64))] + getter id : Int64 + @[JSON::Field(key: "TTL", converter: Etcd::Model::StringTypeConverter(Int64))] + getter ttl : Int64 @[JSON::Field(key: "grantedTTL", converter: Etcd::Model::StringTypeConverter(Int64))] getter granted_ttl : Int64 + getter keys : Array(String)? # This should be Array(Bytes)? + end + + # Returns error + struct Grant < WithHeader + @[JSON::Field(key: "ID", converter: Etcd::Model::StringTypeConverter(Int64))] + getter id : Int64 @[JSON::Field(key: "TTL", converter: Etcd::Model::StringTypeConverter(Int64))] getter ttl : Int64 + getter error : String? end - struct LeasesArray < Base - getter leases : Array(LeasesItem) + # Returns error + struct KeepAlive < Base + getter error : Error? + getter result : KeepAliveResult? end - struct LeasesItem < Base + struct KeepAliveResult < WithHeader @[JSON::Field(key: "ID", converter: Etcd::Model::StringTypeConverter(Int64))] getter id : Int64 + @[JSON::Field(key: "TTL", converter: Etcd::Model::StringTypeConverter(Int64))] + getter ttl : Int64 end end diff --git a/src/etcd/model/maintenance.cr b/src/etcd/model/maintenance.cr index 5593a26..0a8f941 100644 --- a/src/etcd/model/maintenance.cr +++ b/src/etcd/model/maintenance.cr @@ -1,16 +1,62 @@ require "./base" module Etcd::Model - struct Status < Base - getter header : Header - getter version : String + enum AlarmAction + GET + ACTIVATE + DEACTIVATE + end + + enum AlarmType + NONE + NOSPACE + CORRUPT + end + + struct Alarm < Base + getter alarm : AlarmType + @[JSON::Field(converter: Etcd::Model::StringTypeConverter(UInt64))] + getter member_id : UInt64 + end + + struct Alarms < WithHeader + getter alarms : Array(Alarm) + end + + struct Revision < WithHeader + @[JSON::Field(converter: Etcd::Model::StringTypeConverter(Int64))] + getter compact_revision : Int64 + @[JSON::Field(converter: Etcd::Model::StringTypeConverter(Int64))] + getter hash : Int64 + end + + struct Snapshot < Base + getter error : Error? + getter result : SnapshotResult? + end + + struct SnapshotResult < WithHeader + getter blob : String # Bytes + @[JSON::Field(converter: Etcd::Model::StringTypeConverter(UInt64))] + getter remaining_bytes : UInt64 + end + + struct Status < WithHeader @[JSON::Field(key: "dbSize", converter: Etcd::Model::StringTypeConverter(Int64))] getter db_size : Int64 + @[JSON::Field(key: "dbSizeInUse", converter: Etcd::Model::StringTypeConverter(Int64))] + getter db_size_in_use : Int64 + getter errors : Array(String)? + @[JSON::Field(key: "isLearner")] + getter is_learner : Bool? @[JSON::Field(converter: Etcd::Model::StringTypeConverter(UInt64))] getter leader : UInt64 + @[JSON::Field(key: "raftAppliedIndex", converter: Etcd::Model::StringTypeConverter(UInt64))] + getter raft_applied_index : UInt64 @[JSON::Field(key: "raftIndex", converter: Etcd::Model::StringTypeConverter(UInt64))] getter raft_index : UInt64 @[JSON::Field(key: "raftTerm", converter: Etcd::Model::StringTypeConverter(UInt64))] getter raft_term : UInt64 + getter version : String end end diff --git a/src/etcd/watch.cr b/src/etcd/watch.cr index dac12f5..64811d6 100644 --- a/src/etcd/watch.cr +++ b/src/etcd/watch.cr @@ -71,7 +71,7 @@ class Etcd::Watch Watcher.new( key: key, - create_api: ->{ client.spawn_api }, + create_api: ->@client.spawn_api, range_end: range_end, filters: filters, start_revision: start_revision, From e1974f07f80a42eb0c5d66f85ce3748f704ccbe9 Mon Sep 17 00:00:00 2001 From: Caspian Baska Date: Tue, 16 Nov 2021 12:57:18 +1100 Subject: [PATCH 2/3] feat: add endpoint base --- src/etcd/endpoint.cr | 24 +++++++++ src/etcd/lease.cr | 110 ++++++++++++++++++++++------------------ src/etcd/model/base.cr | 9 +++- src/etcd/model/lease.cr | 11 +--- 4 files changed, 96 insertions(+), 58 deletions(-) create mode 100644 src/etcd/endpoint.cr diff --git a/src/etcd/endpoint.cr b/src/etcd/endpoint.cr new file mode 100644 index 0000000..bee209b --- /dev/null +++ b/src/etcd/endpoint.cr @@ -0,0 +1,24 @@ +require "./client" + +abstract class Endpoint + private getter client : Etcd::Client + + def initialize(@client = Etcd::Client.new) + end + + private macro request(verb, path, arguments, response_type) + begin + %response = client.api.{{ verb.downcase.id }}({{ path }}, body: {{ arguments }}) + %body = %response.body + %result = {{ response_type }}.from_json(%body) + rescue e : JSON::SerializableError + raise Error.new("incorrect {{ verb.id }} {{ path.id }} response: #{ %body }", cause: e) + end + + unless (%error = %result.error).nil? && %response.success? + raise Error.new(%error.try(&.message) || "Unsuccessful response") + end + + %result + end +end diff --git a/src/etcd/lease.cr b/src/etcd/lease.cr index 4be0908..cdd06e1 100644 --- a/src/etcd/lease.cr +++ b/src/etcd/lease.cr @@ -1,57 +1,71 @@ require "./model/lease" +require "./endpoint" -class Etcd::Lease - private getter client : Etcd::Client +module Etcd + class Lease < Endpoint + # /kv/lease/leases + # /lease/leases + # Queries for all existing leases in an etcd cluster + def leases + request( + "POST", + "/kv/lease/leases", + nil, + Model::Leases, + ).leases.map(&.id) + end - def initialize(@client = Etcd::Client.new) - end - - # /kv/lease/leases - # /lease/leases - # Queries for all existing leases in an etcd cluster - def leases - Model::Leases.from_json(client.api.post("/kv/lease/leases").body).leases.map(&.id) - end - - # Requests persistence of lease. - # Must be invoked periodically to avoid key loss. - def keep_alive(id : Int64) : Int64? - Model::KeepAlive.from_json(client.api.post("/lease/keepalive", {ID: id}).body).result - rescue JSON::SerializableError - nil - end + # /kv/lease/revoke + # Revokes an etcd lease + # id Id of lease Int64 + def revoke(id : Int64) + request( + "POST", + "/kv/lease/revoke", + {ID: id}, + Model::EmptyResponse, + ) - # /kv/lease/revoke - # Revokes an etcd lease - # id Id of lease Int64 - def revoke(id : Int64) - # To get header: Etcd::Model::WithHeader.from_json(response.body) - client.api.post("/kv/lease/revoke", {ID: id}).success? - end + true + end - # /kv/lease/timetolive - # /lease/timetolive - # Queries the TTL of a lease - # id id of lease Int64 - # query_keys query all the lease's keys for ttl Bool - def timetolive(id : Int64, query_keys = false) - Model::TimeToLive.from_json(client.api.post("/kv/lease/timetolive", {ID: id, keys: query_keys}).body) - end + # /kv/lease/timetolive + # /lease/timetolive + # Queries the TTL of a lease + # id id of lease Int64 + # query_keys query all the lease's keys for ttl Bool + def timetolive(id : Int64, query_keys = false) + request( + "POST", + "/kv/lease/timetolive", + {ID: id, keys: query_keys}, + Model::TimeToLive, + ) + end - # /lease/grant - # Requests a lease - # ttl ttl of granted lease Int64 - # id id of 0 prompts etcd to assign any id to lease UInt64 - def grant(ttl : Int64 = @ttl, id = 0) - Model::Grant.from_json(client.api.post("/lease/grant", {TTL: ttl, ID: 0}).body) - end + # /lease/grant + # Requests a lease + # ttl ttl of granted lease Int64 + # id id of 0 prompts etcd to assign any id to lease UInt64 + def grant(ttl : Int64 = @ttl, id = 0) + request( + "POST", + "/lease/grant", + {TTL: ttl, ID: 0}, + Model::Grant + ) + end - # /lease/keepalive - # Requests persistence of lease. - # Must be invoked periodically to avoid key loss. - def keep_alive(id : Int64) : Int64? - model = Model::KeepAlive.from_json(client.api.post("/lease/keepalive", {ID: id}).body) - raise Exception.new(model.error.not_nil!.to_s) if model.result.nil? - model.result.not_nil!.ttl + # /lease/keepalive + # Requests persistence of lease. + # Must be invoked periodically to avoid key loss. + def keep_alive(id : Int64) : Int64? + request( + "POST", + "/lease/keepalive", + {ID: id}, + Model::KeepAlive + ).result + end end end diff --git a/src/etcd/model/base.cr b/src/etcd/model/base.cr index 1212102..51c88db 100644 --- a/src/etcd/model/base.cr +++ b/src/etcd/model/base.cr @@ -13,10 +13,17 @@ module Etcd::Model include JSON::Serializable end - abstract struct WithHeader < Base + private abstract struct Response < Base + getter error : Error? + end + + abstract struct WithHeader < Response getter header : Header end + struct EmptyResponse < Response + end + struct Error < Base getter details : Array(ErrorDetail) getter grpc_code : Int32 diff --git a/src/etcd/model/lease.cr b/src/etcd/model/lease.cr index f0a4a29..28a80a6 100644 --- a/src/etcd/model/lease.cr +++ b/src/etcd/model/lease.cr @@ -26,19 +26,12 @@ module Etcd::Model getter id : Int64 @[JSON::Field(key: "TTL", converter: Etcd::Model::StringTypeConverter(Int64))] getter ttl : Int64 - getter error : String? end # Returns error struct KeepAlive < Base getter error : Error? - getter result : KeepAliveResult? - end - - struct KeepAliveResult < WithHeader - @[JSON::Field(key: "ID", converter: Etcd::Model::StringTypeConverter(Int64))] - getter id : Int64 - @[JSON::Field(key: "TTL", converter: Etcd::Model::StringTypeConverter(Int64))] - getter ttl : Int64 + @[JSON::Field(root: "result", key: "TTL", converter: Etcd::Model::StringTypeConverter(Int64))] + getter result : Int64? end end From ee2dc0c91d3c907218a0fcaff7e97e10ea5af75b Mon Sep 17 00:00:00 2001 From: Caspian Baska Date: Tue, 16 Nov 2021 13:14:19 +1100 Subject: [PATCH 3/3] fix(lease): gracefully handle missing key --- spec/lease_spec.cr | 6 ++---- src/etcd/lease.cr | 2 +- src/etcd/model/lease.cr | 11 +++++++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/spec/lease_spec.cr b/spec/lease_spec.cr index 8b2cc4e..f953bed 100644 --- a/spec/lease_spec.cr +++ b/spec/lease_spec.cr @@ -53,10 +53,8 @@ module Etcd it "handles nil on keep_alive" do # Deserialise and handle incorrect json - Etcd::Model::KeepAlive.from_json(%({"result": {"TTL": "15"}})).result.should eq(15) - expect_raises(klass: JSON::SerializableError, message: "JSON key not found: TTL") { - Etcd::Model::KeepAlive.from_json(%({"result": {"error": "error"}})) - } + Etcd::Model::KeepAlive.from_json(%({"result": {"TTL": "15"}})).result.try(&.ttl).should eq(15) + Etcd::Model::KeepAlive.from_json(%({"result": {"error": "error"}})) client = Etcd.from_env new_ttl = client.lease.keep_alive 5_i64 diff --git a/src/etcd/lease.cr b/src/etcd/lease.cr index cdd06e1..f41ba41 100644 --- a/src/etcd/lease.cr +++ b/src/etcd/lease.cr @@ -65,7 +65,7 @@ module Etcd "/lease/keepalive", {ID: id}, Model::KeepAlive - ).result + ).result.try(&.ttl) end end end diff --git a/src/etcd/model/lease.cr b/src/etcd/model/lease.cr index 28a80a6..4e3fd14 100644 --- a/src/etcd/model/lease.cr +++ b/src/etcd/model/lease.cr @@ -29,9 +29,12 @@ module Etcd::Model end # Returns error - struct KeepAlive < Base - getter error : Error? - @[JSON::Field(root: "result", key: "TTL", converter: Etcd::Model::StringTypeConverter(Int64))] - getter result : Int64? + struct KeepAlive < Response + getter result : Result? + + struct Result < Base + @[JSON::Field(key: "TTL", converter: Etcd::Model::StringTypeConverter(Int64))] + getter ttl : Int64? + end end end