Class: EZDyn::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/ezdyn/client.rb,
lib/ezdyn/crud.rb,
lib/ezdyn/zone.rb,
lib/ezdyn/changes.rb

Overview

The main class for Dyn REST API interaction.

For more information about the API, see / the official documentation.

Instance Method Summary collapse

Constructor Details

#initialize(customer_name: ENV['DYN_CUSTOMER_NAME'], username: ENV['DYN_USERNAME'], password: ENV['DYN_PASSWORD']) ⇒ Client

Initializes a new Dyn REST API client.

Parameters:

  • customer_name (String) (defaults to: ENV['DYN_CUSTOMER_NAME'])

    API customer name.

  • username (String) (defaults to: ENV['DYN_USERNAME'])

    API username.

  • password (String) (defaults to: ENV['DYN_PASSWORD'])

    API password.



16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'lib/ezdyn/client.rb', line 16

def initialize(customer_name: ENV['DYN_CUSTOMER_NAME'], username: ENV['DYN_USERNAME'], password: ENV['DYN_PASSWORD'])
  @customer_name = customer_name
  @username = username
  @password = password

  if @customer_name.nil? or @username.nil? or @password.nil?
    EZDyn.info { "Some credentials are missing" }
    raise "Missing credentials"
  end

  @base_url = URI('https://api2.dynect.net/')
  @headers = {
    'Content-Type' => 'application/json',
  }

  @logged_in = false
end

Instance Method Details

#add_pending_change(chg) ⇒ Object



4
5
6
7
# File 'lib/ezdyn/changes.rb', line 4

def add_pending_change(chg)
  @pending_changes ||= []
  @pending_changes << chg
end

#build_uri(type:, fqdn:, id: nil) ⇒ Object



139
140
141
142
# File 'lib/ezdyn/client.rb', line 139

def build_uri(type:, fqdn:, id: nil)
  EZDyn.debug { "Client.build_uri( type: #{type}, fqdn: #{fqdn}, id: #{id} )" }
  "#{RecordType.find(type).uri_name}/#{self.guess_zone(fqdn: fqdn)}/#{fqdn}/#{id}"
end

#build_url(path) ⇒ Object



35
36
37
38
39
40
# File 'lib/ezdyn/client.rb', line 35

def build_url(path)
  path = path.to_s
  path = "/#{path}" unless path.start_with?('/')
  path = "/REST#{path}" unless path.start_with?('/REST')
  @base_url.merge(path)
end

#call_api(method: "get", uri:, payload: {}, max_attempts: EZDyn::API_RETRY_MAX_ATTEMPTS) ⇒ Response

Performs an actual REST call.

Parameters:

  • method (String) (defaults to: "get")

    HTTP method to use, eg “GET”, “POST”, “DELETE”, “PUT”.

  • uri (String)

    Relative API URI to call.

  • payload (Hash) (defaults to: {})

    JSON data payload to send with request.

Returns:



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/ezdyn/client.rb', line 48

def call_api(method: "get", uri:, payload: {}, max_attempts: EZDyn::API_RETRY_MAX_ATTEMPTS)
  EZDyn.debug { "API CALL: #{method.to_s.upcase} #{uri} #{ ( payload || {} ).to_json }" }
  self. if not self.logged_in? and uri != "Session"

  payload_str = payload.to_json
  if payload == {}
    payload_str = nil
  end

  response = nil
  begin
    EZDyn.debug { "About to make REST request to #{method.to_s.upcase} #{uri}" }

    request_url = build_url(uri)

    request = Net::HTTP.const_get(method.capitalize).new(request_url)
    @headers.each do |name, value|
      request[name] = value
    end
    request.body = payload_str if payload_str

    http = Net::HTTP.new(request_url.host, request_url.port)
    http.use_ssl = true if request_url.scheme == 'https'
    response = Response.new(http.start { |http| http.request(request) })

    if response.delayed?
      wait = EZDyn::API_RETRY_DELAY_SECONDS
      max_attempts.times do |retry_count|
        EZDyn.debug { "Async API call response retrieval, attempt #{retry_count + 1}" }
        EZDyn.debug { "Waiting for #{wait} seconds" }
        sleep wait

        response = self.call_api(uri: "/REST/Job/#{response.job_id}")
        break if response.success?

        EZDyn.debug { "Async response status: #{response.status}" }
        wait += (retry_count * EZDyn::API_RETRY_BACKOFF)
      end
    end

    EZDyn.debug { "Call was successful" }
  rescue => e
    EZDyn.info { "REST request to #{uri} threw an exception: #{e}" }
    raise "Got an exception: #{e}"
  end

  response
end

#clear_pending_changes(zone: nil) ⇒ Object



31
32
33
34
35
36
37
38
39
# File 'lib/ezdyn/changes.rb', line 31

def clear_pending_changes(zone: nil)
  if zone.nil?
    @pending_changes = []
  else
    @pending_changes.delete_if do |pc|
      pc.zone.name == zone.to_s
    end
  end
end

#commit(zone: nil, message: nil) ⇒ Object

Commits any pending changes to a zone or to all zones with an optional update message.

Parameters:

  • zone (String) (defaults to: nil)

    The zone name to commit. By default all pending changes from any zone will be committed.

  • message (String) (defaults to: nil)

    If supplied, this message will be used for the zone update notes field.

Raises:

  • (RuntimeError)

    if any commit was unsuccessful.



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/ezdyn/crud.rb', line 165

def commit(zone: nil, message: nil)
  EZDyn.debug { "Client{}.commit( zone: #{zone} )" }
  payload = { publish: true }
  payload[:notes] = message if not message.nil?

  zones = zone.nil? ? self.pending_change_zones : [Zone.find(zone)]

  zones.each do |zone|
    EZDyn.debug { " - committing Zone{#{zone.name}}" }
    response = self.call_api(
      method: "put",
      uri: "Zone/#{zone}",
      payload: payload
    )

    if response.success?
      self.clear_pending_changes(zone: zone)
    else
      EZDyn.debug { " - failed to commit Zone{#{zone.name}}: #{response.simple_message}" }
      raise "Could not commit zone #{zone.name}: #{response.simple_message}"
    end
  end
end

#create(type:, fqdn:, value:, ttl: nil) ⇒ Record

Note:

As a side effect upon success, this method creates a [CreateChange] object in the ‘pending_changes` array.

Calls the Dyn API to create a new record.

Parameters:

  • type (RecordType, String, Symbol)

    Type of record to create.

  • fqdn (String)

    FQDN of the record to create.

  • value (String, Array)

    Value(s) to submit for the record data.

  • ttl (String, Integer) (defaults to: nil)

    TTL value (optional).

Returns:

  • (Record)

    A Record object filled with the values returned by the API.

Raises:

  • (RuntimeError)

    if the record could not be created.



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/ezdyn/crud.rb', line 14

def create(type:, fqdn:, value:, ttl: nil)
  EZDyn.debug { "Client.create( type: #{type}, fqdn: #{fqdn}, value: #{value}, ttl: #{ttl} )" }

  ttl = ( ttl || Record::DefaultTTL ).to_i
  values = Array(value)

  return values.map do |val|
    value_key = Array(RecordType.find(type).value_key)
    split_val = val.split(' ', value_key.length)
    response = self.call_api(
      method: "post",
      uri: self.build_uri(type: type, fqdn: fqdn),
      payload: { rdata: value_key.zip(split_val).to_h, ttl: ttl }
    )

    if not response.success?
      raise "Failed to create record: #{response.simple_message}"
    end

    record = Record.new(client: self, raw: response.data)
    self.add_pending_change(CreateChange.new(record: record))
    record
  end
end

#delete(record:) ⇒ Object

Note:

As a side effect upon success, this method creates a [DeleteChange] object in the ‘pending_changes` array.

Delete a record.

Parameters:

  • record (Record)

    The Record object of the record to be deleted.

Raises:

  • (RuntimeError)

    if record does not exist.

  • (RuntimeError)

    if record cannot be deleted.



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/ezdyn/crud.rb', line 103

def delete(record:)
  EZDyn.debug { "Client{}.delete( record: Record{#{record.record_id}} )" }
  if not record.sync!.exists?
    raise "Nothing to delete"
  end

  response = self.call_api(
    method: "delete",
    uri: record.uri
  )

  if not response.success?
    raise "Could not delete: #{response.simple_message}"
  end

  self.add_pending_change(DeleteChange.new(record: record))
end

#delete_all(type: :any, fqdn:) ⇒ Object

Note:

As a side effect upon success, this method creates [DeleteChange] objects in the ‘pending_changes` array for each record deleted.

Deletes all records of a specified type and FQDN. Specify type ‘:any` (or don’t specify ‘type` at all) to delete all records for a FQDN.

Parameters:

  • type (RecordType, String, Symbol) (defaults to: :any)

    The type of record(s) to delete. Defaults to ‘:any`.

  • fqdn (String)

    The FQDN of the record(s) to delete.



130
131
132
133
134
135
# File 'lib/ezdyn/crud.rb', line 130

def delete_all(type: :any, fqdn:)
  EZDyn.debug { "Client{}.delete_all( type: #{type}, fqdn: #{fqdn} )" }
  self.records_for(type: type, fqdn: fqdn).each do |record|
    record.delete!
  end
end

#exists?(type: :any, fqdn:, id: nil) ⇒ Boolean

Signals if a particular record exists.

Parameters:

  • type (EZDyn::RecordType, String, Symbol) (defaults to: :any)

    RecordType of record to check for. ‘:any` will match any record type.

  • fqdn (String)

    FQDN of records to check for. Required.

  • id (String) (defaults to: nil)

    API record ID of the record to check for. Optional.

Returns:

  • (Boolean)

    Returns true if any such record exists.



201
202
203
204
205
206
207
208
209
210
# File 'lib/ezdyn/client.rb', line 201

def exists?(type: :any, fqdn:, id: nil)
  EZDyn.debug { "Client.exists?( type: #{type}, fqdn: #{fqdn}, id: #{id} )" }
  if not id.nil? and type != :any
    EZDyn.debug { "Fetching a single record" }
    self.fetch_record(type: type, fqdn: fqdn, id: id) != []
  else
    EZDyn.debug { "Fetching records_for #{type} #{fqdn}" }
    self.records_for(type: type, fqdn: fqdn).count > 0
  end
end

#fetch_record(type:, fqdn:, id:) ⇒ EZDyn::Record

Fetches a record if you know the type, FQDN, and ID.

Parameters:

  • type (EZDyn::RecordType, String, Symbol)

    The record type to be fetched.

  • fqdn (String)

    FQDN of the record to fetch.

  • id (String)

    Dyn API record ID of the record to fetch.

Returns:



163
164
165
166
167
168
169
170
171
# File 'lib/ezdyn/client.rb', line 163

def fetch_record(type:, fqdn:, id:)
  EZDyn.debug { "Client.fetch_record( type: #{type}, fqdn: #{fqdn}, id: #{id} )" }
  data = self.fetch_uri_data(uri: self.build_uri(type: type, fqdn: fqdn, id: id))
  if data == []
    nil
  else
    Record.new(client: self, raw: data)
  end
end

#fetch_uri_data(uri:) ⇒ Object



145
146
147
148
149
150
151
152
153
154
155
# File 'lib/ezdyn/client.rb', line 145

def fetch_uri_data(uri:)
  EZDyn.debug { "Client.fetch_uri_data( uri: #{uri} )" }
  response = call_api(uri: uri)
  if response.success?
    EZDyn.debug { "fetch_uri_data success!" }
    return response.data
  else
    EZDyn.debug { "fetch_uri_data failure!" }
    return []
  end
end

#fetch_zonesObject



91
92
93
94
95
# File 'lib/ezdyn/zone.rb', line 91

def fetch_zones
  EZDyn.debug { "Client.fetch_zones()" }
  @zones = self.fetch_uri_data(uri: '/Zone/').
    collect { |uri| Zone.new(client: self, uri: uri) }
end

#guess_zone(fqdn:) ⇒ Zone

Match the given FQDN to a zone known to this client.

Returns:

  • (Zone)

    The appropriate Zone object, or nil if nothing matched.



108
109
110
111
# File 'lib/ezdyn/zone.rb', line 108

def guess_zone(fqdn:)
  EZDyn.debug { "Client.guess_zone( fqdn: #{fqdn} )" }
  self.zones.find { |z| fqdn.downcase =~ /#{z.name.downcase}$/ }
end

#logged_in?Boolean

Signals whether the client has successfully logged in.

Returns:

  • (Boolean)

    True if the client is logged in.



100
101
102
# File 'lib/ezdyn/client.rb', line 100

def logged_in?
  @logged_in
end

#loginObject

Begin a Dyn REST API session. This method will be called implicitly when required.



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/ezdyn/client.rb', line 105

def 
  EZDyn.debug { "Logging in..." }
  response = call_api(
    method: "post",
    uri: "Session",
    payload: {
      customer_name: @customer_name,
      user_name: @username,
      password: @password,
    }
  )

  EZDyn.debug { "Response status: #{response.status}" }

  if response.success?
    @headers['Auth-Token'] = response.data["token"]
    @logged_in = true
  else
    raise "Login failed"
  end
end

#logoutObject

End the current Dyn REST API session.



128
129
130
131
132
133
134
135
136
# File 'lib/ezdyn/client.rb', line 128

def logout
  call_api(
    method: "delete",
    uri: "Session"
  )

  @headers.delete('Auth-Token')
  @logged_in = false
end

#pending_change_zonesObject



10
11
12
# File 'lib/ezdyn/changes.rb', line 10

def pending_change_zones
  @pending_changes.collect(&:zone).uniq
end

#pending_changes(zone: nil) ⇒ Array

List currently pending changes (optionally per zone).

Parameters:

  • zone (String) (defaults to: nil)

    (Optional) If specified, only return pending changes for the named zone.

Returns:

  • (Array)

    Array of [Change] objects awaiting commit.



19
20
21
22
23
24
25
26
27
28
# File 'lib/ezdyn/changes.rb', line 19

def pending_changes(zone: nil)
  if zone.nil?
    @pending_changes
  else
    zone = Zone.new(client: self, name: zone)
    @pending_changes.select do |pc|
      pc.zone.name == zone.name
    end
  end
end

#records_for(type: :any, fqdn:) ⇒ Array<EZDyn::Record>

Fetches all records for a particular FQDN (and record type).

Parameters:

  • type (EZDyn::RecordType, String, Symbol) (defaults to: :any)

    Desired record type. Use ‘:any` to fetch all records.

  • fqdn (String)

    FQDN of the records to fetch.

Returns:



179
180
181
182
183
# File 'lib/ezdyn/client.rb', line 179

def records_for(type: :any, fqdn:)
  EZDyn.debug { "Client.records_for( type: #{type}, fqdn: #{fqdn} )" }
  self.fetch_uri_data(uri: self.build_uri(type: type, fqdn: fqdn)).
    collect { |uri| Record.new(client: self, uri: uri) }
end

#records_in_zone(zone:) ⇒ Object

Fetches all records for a zone. NOTE: This can take very long, and probably isn’t what you want.

Parameters:

  • zone (String)

    The Zone to lookup.



188
189
190
191
192
# File 'lib/ezdyn/client.rb', line 188

def records_in_zone(zone:)
  EZDyn.debug { "Client.records_in_zone( zone: #{zone}" }
  self.fetch_uri_data(uri: "/AllRecord/#{zone}").
    collect{ |uri| Record.new(client: self, uri: uri) }
end

#rollback(zone: nil) ⇒ Object

Rolls back any pending changes to a zone or to all zones.

Parameters:

  • zone (String) (defaults to: nil)

    The zone name to roll back. By default all pending changes from any zone will be rolled back.



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/ezdyn/crud.rb', line 141

def rollback(zone: nil)
  EZDyn.debug { "Client{}.rollback( zone: #{zone} )" }

  zones = zone.nil? ? self.pending_change_zones : [Zone.new(client: self, name: zone)]

  zones.each do |zone|
    EZDyn.debug { " - rolling back Zone{#{zone.name}}" }
    response = self.call_api(method: "delete", uri: "ZoneChanges/#{zone}")
    if response.success?
      self.clear_pending_changes(zone: zone)
    else
      EZDyn.debug { " - failed to roll back Zone{#{zone.name}}: #{response.simple_message}" }
      raise "Failed to roll back zone #{zone.name}"
    end
  end
end

#update(record: nil, type: nil, fqdn: nil, value: nil, ttl: nil) ⇒ Record

Note:

As a side effect upon success, this method creates an [UpdateChange] or a [CreateChange] object in the ‘pending_changes` array.

Calls the Dyn API to update or create a record. Could also be called ‘upsert`.

Parameters:

  • record (Record) (defaults to: nil)

    A Record object for the record to be updated. Either this parameter or the ‘type` and `fqdn` parameters are required.

  • type (RecordType, String, Symbol) (defaults to: nil)

    Type of record to update/create (not required if ‘record` is provided).

  • fqdn (String) (defaults to: nil)

    FQDN of the record to update/create (not requried if ‘record` is provided).

  • value (String, Array) (defaults to: nil)

    New value(s) to submit for the record data (optional if updating TTL and record already exists).

  • ttl (String, Integer) (defaults to: nil)

    New TTL value (optional).

Returns:

  • (Record)

    A Record object filled with the values returned by the API.

Raises:

  • (RuntimeError)

    if the record could not be created or updated.



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/ezdyn/crud.rb', line 54

def update(record: nil, type: nil, fqdn: nil, value: nil, ttl: nil)
  EZDyn.debug { "Client.update( record: #{record.nil? ? nil : "Record{#{record.record_id}}"}, type: #{type}, fqdn: #{fqdn}, value: #{value}, ttl: #{ttl} )" }

  values = Array(value)

  if record.nil?
    if type.nil? or fqdn.nil?
      raise "Cannot update a record without a Record object or both record type and FQDN"
    end
    records = self.records_for(type: type, fqdn: fqdn)
  else
    records = [record]
  end

  response = nil
  if records.count.zero? && (fqdn.nil? || type.nil? || value.nil?)
    raise "Record doesn't exist, and insufficient information to create it was given"
  end

  response = self.call_api(
    method: "put",
    uri: self.build_uri(type: type, fqdn: fqdn),
    payload: {
      "#{type}Records" => values.map do |val|
        value_key = Array(RecordType.find(type).value_key)
        split_val = val.split(' ', value_key.length)
        { rdata: value_key.zip(split_val).to_h, ttl: ttl || records.map(&:ttl).min }
      end
    }
  )

  if not response.success?
    raise "Could not update: #{response.simple_message}"
  end

  new_records = response.data.map { |res| Record.new(client: self, raw: res) }
  self.add_pending_change(UpdateChange.new(records: records, new_records: new_records))

  return new_records
end

#zonesArray<Zone>

List all DNS zones known to this client.

Returns:

  • (Array<Zone>)

    An array of Zone objects.



100
101
102
103
# File 'lib/ezdyn/zone.rb', line 100

def zones
  EZDyn.debug { "Client.zones()" }
  @zones ||= self.fetch_zones
end