class EZDyn::Client

The main class for Dyn REST API interaction.

For more information about the API, see {help.dyn.com/dns-api-knowledge-base/ the official documentation}.

Public Class Methods

new(customer_name: ENV['DYN_CUSTOMER_NAME'], username: ENV['DYN_USERNAME'], password: ENV['DYN_PASSWORD']) click to toggle source

Initializes a new Dyn REST API client.

@param customer_name [String] API customer name. @param username [String] API username. @param password [String] API password. @return [Client] Dyn REST API client.

# 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

Public Instance Methods

add_pending_change(chg) click to toggle source

@private

# File lib/ezdyn/changes.rb, line 4
def add_pending_change(chg)
  @pending_changes ||= []
  @pending_changes << chg
end
build_uri(type:, fqdn:, id: nil) click to toggle source

@private

# 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) click to toggle source

@private

# 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) click to toggle source

Performs an actual REST call.

@param method [String] HTTP method to use, eg “GET”, “POST”, “DELETE”, “PUT”. @param uri [String] Relative API URI to call. @param payload [Hash] JSON data payload to send with request. @return [Response] Response object.

# 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.login 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) click to toggle source

@private

# 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) click to toggle source

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

@raise [RuntimeError] if any commit was unsuccessful. @param zone [String] The zone name to commit. By default all pending

changes from any zone will be committed.

@param message [String] If supplied, this message will be used for the

zone update notes field.
# 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) click to toggle source

Calls the Dyn API to create a new record.

@note As a side effect upon success, this method creates a [CreateChange]

object in the `pending_changes` array.

@raise [RuntimeError] if the record could not be created. @param type [RecordType, String, Symbol] Type of record to create. @param fqdn [String] FQDN of the record to create. @param value [String, Array] Value(s) to submit for the record data. @param ttl [String, Integer] TTL value (optional). @return [Record] A Record object filled with the values returned by the API.

# 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:) click to toggle source

Delete a record.

@note As a side effect upon success, this method creates a [DeleteChange]

object in the `pending_changes` array.

@raise [RuntimeError] if record does not exist. @raise [RuntimeError] if record cannot be deleted. @param record [Record] The Record object of the record to be deleted.

# 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:) click to toggle source

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.

@note As a side effect upon success, this method creates [DeleteChange]

objects in the `pending_changes` array for each record deleted.

@param type [RecordType,String,Symbol] The type of record(s) to delete.

Defaults to `:any`.

@param fqdn [String] The FQDN of the record(s) to delete.

# 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) click to toggle source

Signals if a particular record exists.

@param type [EZDyn::RecordType, String, Symbol] RecordType of record to

check for. `:any` will match any record type.

@param fqdn [String] FQDN of records to check for. Required. @param id [String] API record ID of the record to check for. Optional. @return [Boolean] Returns true if any such record exists.

# 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:) click to toggle source

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

@param type [EZDyn::RecordType, String, Symbol] The record type to be fetched. @param fqdn [String] FQDN of the record to fetch. @param id [String] Dyn API record ID of the record to fetch. @return [EZDyn::Record] Newly created, synced Record object.

# 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:) click to toggle source

@private

# 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_zones() click to toggle source

@private

# 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:) click to toggle source

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

@return [Zone] The appropriate Zone object, or nil if nothing matched.

# 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?() click to toggle source

Signals whether the client has successfully logged in.

@return [Boolean] True if the client is logged in.

# File lib/ezdyn/client.rb, line 100
def logged_in?
  @logged_in
end
login() click to toggle source

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

# File lib/ezdyn/client.rb, line 105
def login
  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
logout() click to toggle source

End the current Dyn REST API session.

# 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_zones() click to toggle source

@private

# File lib/ezdyn/changes.rb, line 10
def pending_change_zones
  @pending_changes.collect(&:zone).uniq
end
pending_changes(zone: nil) click to toggle source

List currently pending changes (optionally per zone).

@param zone [String] (Optional) If specified, only return pending changes

for the named zone.

@return [Array] Array of [Change] objects awaiting commit.

# 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:) click to toggle source

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

@param type [EZDyn::RecordType, String, Symbol] Desired record type. Use

`:any` to fetch all records.

@param fqdn [String] FQDN of the records to fetch. @return [Array<EZDyn::Record>] An array of synced Record objects.

# 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:) click to toggle source

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

@param zone [String] The Zone to lookup.

# 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) click to toggle source

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

@param zone [String] The zone name to roll back. By default all pending

changes from any zone will be rolled back.
# 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) click to toggle source

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

@note As a side effect upon success, this method creates an [UpdateChange]

or a [CreateChange] object in the `pending_changes` array.

@raise [RuntimeError] if the record could not be created or updated. @param record [Record] A Record object for the record to be updated.

Either this parameter or the `type` and `fqdn` parameters are
required.

@param type [RecordType, String, Symbol] Type of record to update/create (not required if `record` is provided). @param fqdn [String] FQDN of the record to update/create (not requried if `record` is provided). @param value [String, Array] New value(s) to submit for the record data (optional if updating TTL and record already exists). @param ttl [String, Integer] New TTL value (optional). @return [Record] A Record object filled with the values returned by the API.

# 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
zones() click to toggle source

List all DNS zones known to this client.

@return [Array<Zone>] An array of Zone objects.

# File lib/ezdyn/zone.rb, line 100
def zones
  EZDyn.debug { "Client.zones()" }
  @zones ||= self.fetch_zones
end