diff --git a/lib/dnsimple/client.rb b/lib/dnsimple/client.rb index cc3dcec2..3c15870b 100644 --- a/lib/dnsimple/client.rb +++ b/lib/dnsimple/client.rb @@ -1,5 +1,6 @@ require 'dnsimple/version' require 'dnsimple/compatibility' +require 'dnsimple/client/records_service' module Dnsimple @@ -96,13 +97,22 @@ def delete(path, options = {}) def request(method, path, options) response = HTTParty.send(method, api_endpoint + path, base_options.merge(options)) - if response.code == 401 && response.headers[HEADER_OTP_TOKEN] == "required" - raise TwoFactorAuthenticationRequired, response["message"] - elsif response.code == 401 - raise AuthenticationFailed, response["message"] + case response.code + when 200..299 + response + when 401 + raise (response.headers[HEADER_OTP_TOKEN] == "required" ? TwoFactorAuthenticationRequired : AuthenticationFailed), response["message"] + when 404 + raise RecordNotFound.new(response) + else + raise RequestError(response) end + end + - response + # @return [Dnsimple::Client::RecordsService] The record-related API proxy. + def records + @records_service ||= Client::RecordsService.new(self) end diff --git a/lib/dnsimple/client/records_service.rb b/lib/dnsimple/client/records_service.rb new file mode 100644 index 00000000..a974954f --- /dev/null +++ b/lib/dnsimple/client/records_service.rb @@ -0,0 +1,100 @@ +module Dnsimple + class Client + class RecordsService < Struct.new(:client) + + # Lists the domain records in the account. + # + # @see http://developer.dnsimple.com/domains/records/#list + # + # @param [#to_s] domain The domain id or domain name. + # @param [Hash] options + # + # @return [Array] + # @raise [RecordNotFound] + # @raise [RequestError] When the request fails. + def list(domain, options = {}) + response = client.get("v1/domains/#{domain}/records", options) + + response.map { |r| Record.new(r["record"]) } + end + + # Creates the record in the account. + # + # @see http://developer.dnsimple.com/domains/records/#create + # + # @param [#to_s] domain The domain id or domain name. + # @param [Hash] attributes + # + # @return [Record] + # @raise [RecordNotFound] + # @raise [RequestError] When the request fails. + def create(domain, attributes = {}) + validate_mandatory_attributes(attributes, [:name, :record_type, :content]) + options = { body: { record: attributes }} + response = client.post("v1/domains/#{domain}/records", options) + + Record.new(response["record"]) + end + + # Gets a specific record in the account. + # + # @see http://developer.dnsimple.com/domains/records/#get + # + # @param [#to_s] domain The domain id or domain name. + # @param [Fixnum] record The record id. + # + # @return [Record] + # @raise [RecordNotFound] + # @raise [RequestError] When the request fails. + def find(domain, record) + response = client.get("v1/domains/#{domain}/records/#{record}") + + Record.new(response["record"]) + end + + # Updates the record in the account. + # + # @see http://developer.dnsimple.com/domains/records/#update + # + # @param [#to_s] domain The domain id or domain name. + # @param [Fixnum] record The record id. + # @param [Hash] attributes + # + # @return [Record] + # @raise [RecordNotFound] + # @raise [RequestError] When the request fails. + def update(domain, record, attributes = {}) + options = { body: { record: attributes }} + response = client.put("v1/domains/#{domain}/records/#{record}", options) + + Record.new(response["record"]) + end + + # Deletes a specific record from the account. + # + # WARNING: this cannot be undone. + # + # @see http://developer.dnsimple.com/domains/records/#delete + # + # @param [#to_s] domain The domain id or domain name. + # @param [Fixnum] record The record id. + # + # @return [void] + # @raise [RecordNotFound] + # @raise [RequestError] When the request fails. + def delete(domain, record) + client.delete("v1/domains/#{domain}/records/#{record}") + end + + + private + + def validate_mandatory_attributes(attributes, required) + required.each do |name| + attributes.key?(name) or raise(ArgumentError, ":#{name} is required") + end + end + + end + end +end diff --git a/lib/dnsimple/error.rb b/lib/dnsimple/error.rb index bde28ca8..0ae9f8aa 100644 --- a/lib/dnsimple/error.rb +++ b/lib/dnsimple/error.rb @@ -3,20 +3,24 @@ module Dnsimple class Error < StandardError end - class RecordExists < Error - end - - class RecordNotFound < Error + class RequestError < Error + attr_reader :response + + def initialize(*args) + if args.size == 2 + message, @response = *args + super("#{message}: #{response["error"]}") + else + @response = args.first + super("#{response.code}") + end + end end - # An exception that is raised if a method is called with missing or invalid parameter values. - class ValidationError < Error + class RecordExists < Error end - class RequestError < Error - def initialize(description, response) - super("#{description}: #{response["error"]}") - end + class RecordNotFound < RequestError end class AuthenticationError < Error diff --git a/lib/dnsimple/record.rb b/lib/dnsimple/record.rb index b104bd14..e84cef06 100644 --- a/lib/dnsimple/record.rb +++ b/lib/dnsimple/record.rb @@ -1,94 +1,20 @@ module Dnsimple class Record < Base - Aliases = { - 'priority' => 'prio', - 'time-to-live' => 'ttl' - } - attr_accessor :id - attr_accessor :domain + attr_accessor :domain_id attr_accessor :name + attr_accessor :type attr_accessor :content attr_accessor :ttl - attr_accessor :prio - attr_accessor :record_type - - - def fqdn - [name, domain.name].delete_if { |v| v !~ BLANK_REGEX }.join(".") - end - - def save(options={}) - record_hash = {} - %w(name content ttl prio).each do |attribute| - record_hash[Record.resolve(attribute)] = self.send(attribute) - end - - options.merge!(:body => {:record => record_hash}) - - response = Client.put("v1/domains/#{domain.id}/records/#{id}", options) - - case response.code - when 200 - self - else - raise RequestError.new("Error updating record", response) - end - end - - def delete(options={}) - Client.delete("v1/domains/#{domain.id}/records/#{id}", options) - end - alias :destroy :delete - - def self.resolve(name) - Record::Aliases[name] || name - end - - def self.create(domain, name, record_type, content, options={}) - record_hash = {:name => name, :record_type => record_type, :content => content} - record_hash[:ttl] = options.delete(:ttl) || 3600 - record_hash[:prio] = options.delete(:priority) - record_hash[:prio] = options.delete(:prio) || '' - - options.merge!({:body => {:record => record_hash}}) - - response = Client.post("v1/domains/#{domain.name}/records", options) - - case response.code - when 201 - new({:domain => domain}.merge(response["record"])) - when 406 - raise RecordExists, "Record #{name}.#{domain.name} already exists" - else - raise RequestError.new("Error creating record", response) - end - end - - def self.find(domain, id, options={}) - response = Client.get("v1/domains/#{domain.name}/records/#{id}", options) - - case response.code - when 200 - new({:domain => domain}.merge(response["record"])) - when 404 - raise RecordNotFound, "Could not find record #{id} for domain #{domain.name}" - else - raise RequestError.new("Error finding record", response) - end - end - - def self.all(domain, options={}) - response = Client.get("v1/domains/#{domain.name}/records", options) - - case response.code - when 200 - response.map { |r| new({:domain => domain}.merge(r["record"])) } - else - raise RequestError.new("Error listing records", response) - end - end - + attr_accessor :priority + attr_accessor :created_at + attr_accessor :updated_at + + alias :prio :priority + alias :prio= :priority= + alias :record_type :type + alias :record_type= :type= end + end diff --git a/spec/dnsimple/client/records_service_spec.rb b/spec/dnsimple/client/records_service_spec.rb new file mode 100644 index 00000000..ec63b8d9 --- /dev/null +++ b/spec/dnsimple/client/records_service_spec.rb @@ -0,0 +1,191 @@ +require 'spec_helper' + +describe Dnsimple::Client, ".records" do + + subject { described_class.new(api_endpoint: "https://api.zone", username: "user", api_token: "token").records } + + + describe ".list" do + before do + stub_request(:get, %r[/v1/domains/.+/records$]). + to_return(read_fixture("records/index/success.http")) + end + + it "builds the correct request" do + subject.list("example.com") + + expect(WebMock).to have_requested(:get, "https://api.zone/v1/domains/example.com/records"). + with { |req| req.headers['Accept'] == 'application/json' } + end + + it "returns the records" do + results = subject.list("example.com") + + expect(results).to be_a(Array) + expect(results.size).to eq(7) + + result = results[0] + expect(result.id).to eq(36) + result = results[1] + expect(result.id).to eq(37) + end + + context "when the record does not exist" do + it "raises RecordNotFound" do + stub_request(:get, %r[/v1]). + to_return(read_fixture("records/notfound.http")) + + expect { + subject.list("example.com") + }.to raise_error(Dnsimple::RecordNotFound) + end + end + end + + describe ".create" do + before do + stub_request(:post, %r[/v1/domains/.+/records$]). + to_return(read_fixture("records/create/success.http")) + end + + it "builds the correct request" do + subject.create("example.com", { name: "", record_type: "A", content: "127.0.0.1", prio: "1" }) + + expect(WebMock).to have_requested(:post, "https://api.zone/v1/domains/example.com/records"). + with(body: { record: { name: "", record_type: "A", content: "127.0.0.1", prio: "1" } }). + with { |req| req.headers['Accept'] == 'application/json' } + end + + it "returns the domain" do + result = subject.create("example.com", { name: "", record_type: "", content: "" }) + + expect(result).to be_a(Dnsimple::Record) + expect(result.id).to eq(3554751) + end + + context "when the record does not exist" do + it "raises RecordNotFound" do + stub_request(:post, %r[/v1]). + to_return(read_fixture("records/notfound.http")) + + expect { + subject.create("example.com", { name: "", record_type: "", content: "" }) + }.to raise_error(Dnsimple::RecordNotFound) + end + end + end + + describe ".find" do + before do + stub_request(:get, %r[/v1/domains/.+/records/.+$]). + to_return(read_fixture("records/show/success.http")) + end + + it "builds the correct request" do + subject.find("example.com", "2") + + expect(WebMock).to have_requested(:get, "https://api.zone/v1/domains/example.com/records/2"). + with { |req| req.headers['Accept'] == 'application/json' } + end + + it "returns the record" do + result = subject.find("example.com", 2) + + expect(result).to be_a(Dnsimple::Record) + expect(result.id).to eq(1495) + expect(result.domain_id).to eq(6) + expect(result.name).to eq("www") + expect(result.content).to eq("1.2.3.4") + expect(result.ttl).to eq(3600) + expect(result.prio).to be_nil + expect(result.record_type).to eq("A") + expect(result.created_at).to eq("2014-01-14T18:25:56Z") + expect(result.updated_at).to eq("2014-01-14T18:26:04Z") + end + + context "when the record does not exist" do + it "raises RecordNotFound" do + stub_request(:get, %r[/v1]). + to_return(read_fixture("records/notfound.http")) + + expect { + subject.find("example.com", 2) + }.to raise_error(Dnsimple::RecordNotFound) + end + end + end + + describe ".update" do + before do + stub_request(:put, %r[/v1/domains/.+/records/.+$]). + to_return(read_fixture("records/update/success.http")) + end + + it "builds the correct request" do + subject.update("example.com", 2, { content: "127.0.0.1", prio: "1" }) + + expect(WebMock).to have_requested(:put, "https://api.zone/v1/domains/example.com/records/2"). + with(body: { record: { content: "127.0.0.1", prio: "1" } }). + with { |req| req.headers['Accept'] == 'application/json' } + end + + it "returns the domain" do + result = subject.update("example.com", 2, {}) + + expect(result).to be_a(Dnsimple::Record) + expect(result.id).to eq(3554751) + end + + context "when the record does not exist" do + it "raises RecordNotFound" do + stub_request(:put, %r[/v1]). + to_return(read_fixture("records/notfound.http")) + + expect { + subject.update("example.com", 2, {}) + }.to raise_error(Dnsimple::RecordNotFound) + end + end + end + + describe ".delete" do + before do + stub_request(:delete, %r[/v1/domains/example.com/records/2$]). + to_return(read_fixture("domains/delete/success.http")) + end + + it "builds the correct request" do + subject.delete("example.com", "2") + + expect(WebMock).to have_requested(:delete, "https://api.zone/v1/domains/example.com/records/2"). + with { |req| req.headers['Accept'] == 'application/json' } + end + + it "returns nothing" do + result = subject.delete("example.com", 2) + + expect(result).to be_truthy + end + + it "supports HTTP 204" do + stub_request(:delete, %r[/v1]). + to_return(read_fixture("records/delete/success-204.http")) + + result = subject.delete("example.com", 2) + + expect(result).to be_truthy + end + + context "when the domain does not exist" do + it "raises RecordNotFound" do + stub_request(:delete, %r[/v1]). + to_return(read_fixture("records/notfound.http")) + + expect { + subject.delete("example.com", 2) + }.to raise_error(Dnsimple::RecordNotFound) + end + end + end + +end diff --git a/spec/dnsimple/record_spec.rb b/spec/dnsimple/record_spec.rb index 234b5fdc..533fbbd5 100644 --- a/spec/dnsimple/record_spec.rb +++ b/spec/dnsimple/record_spec.rb @@ -1,51 +1,4 @@ require 'spec_helper' describe Dnsimple::Record do - - let(:domain) { Dnsimple::Domain.new(:name => 'example.com') } - - - describe ".find" do - before do - stub_request(:get, %r[/v1/domains/example.com/records/2]). - to_return(read_fixture("records/show/success.http")) - end - - it "builds the correct request" do - described_class.find(domain, "2") - - expect(WebMock).to have_requested(:get, "https://#{CONFIG['username']}:#{CONFIG['password']}@#{CONFIG['host']}/v1/domains/example.com/records/2"). - with(:headers => { 'Accept' => 'application/json' }) - end - - context "when the record exists" do - it "returns the record" do - result = described_class.find(domain, "2") - - expect(result).to be_a(described_class) - expect(result.id).to eq(1495) - expect(result.domain).to be(domain) - expect(result.name).to eq("www") - expect(result.content).to eq("1.2.3.4") - expect(result.ttl).to eq(3600) - expect(result.prio).to be_nil - expect(result.record_type).to eq("A") - end - end - end - - - describe "#fqdn" do - it "joins the name and domain name" do - record = described_class.new(:name => 'www', :domain => domain) - expect(record.fqdn).to eq("www.#{domain.name}") - end - - it "strips a blank name" do - record = described_class.new(:name => '', :domain => domain) - expect(record.fqdn).to eq(domain.name) - end - end - end - diff --git a/spec/files/domains/notfound.http b/spec/files/domains/notfound.http index 3e5c7ced..0804e70f 100644 --- a/spec/files/domains/notfound.http +++ b/spec/files/domains/notfound.http @@ -14,4 +14,4 @@ Cache-Control: no-cache X-Request-Id: ef98925c644549114ccc4cb17f3c8c75 X-Runtime: 0.029489 -{"error":"Couldn't find Domain with name = test1383931357.com"} \ No newline at end of file +{"message":"Couldn't find Domain with name = test1383931357.com"} \ No newline at end of file diff --git a/spec/files/records/create/success.http b/spec/files/records/create/success.http new file mode 100644 index 00000000..95e50f6e --- /dev/null +++ b/spec/files/records/create/success.http @@ -0,0 +1,21 @@ +HTTP/1.1 201 Created +Server: nginx +Date: Sun, 14 Dec 2014 16:11:02 GMT +Content-Type: application/json; charset=utf-8 +Transfer-Encoding: chunked +Connection: keep-alive +Status: 201 Created +Strict-Transport-Security: max-age=631138519 +X-Frame-Options: SAMEORIGIN +X-XSS-Protection: 1 +X-Content-Type-Options: nosniff +Access-Control-Allow-Origin: * +Access-Control-Allow-Headers: Authorization,Accepts,Content-Type,X-DNSimple-Token,X-DNSimple-Domain-Token,X-CSRF-Token,x-requested-with +Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS +ETag: "45fbc90e793bde6153366ce961c88e73" +Cache-Control: max-age=0, private, must-revalidate +X-Request-Id: a34ae657-4810-4b1f-9f4c-39714aac7507 +X-Runtime: 0.234750 +Strict-Transport-Security: max-age=315360000 + +{"record":{"id":3554751,"domain_id":41571,"parent_id":null,"name":"0001","content":"127.0.0.1","ttl":3600,"prio":null,"record_type":"A","system_record":null,"created_at":"2014-12-14T16:11:02.771Z","updated_at":"2014-12-14T16:11:02.771Z"}} diff --git a/spec/files/records/delete/success-204.http b/spec/files/records/delete/success-204.http new file mode 100644 index 00000000..ee4b22a7 --- /dev/null +++ b/spec/files/records/delete/success-204.http @@ -0,0 +1,18 @@ +HTTP/1.1 204 No Content +Server: nginx/1.4.4 +Date: Tue, 14 Jan 2014 19:03:37 GMT +Content-Type: application/json; charset=utf-8 +Transfer-Encoding: chunked +Connection: close +Status: 204 No Content +X-Dnsimple-API-Version: 1.0.0 +Access-Control-Allow-Origin: * +Access-Control-Allow-Headers: Authorization,Accepts,Content-Type,X-Dnsimple-Token,X-Dnsimple-Domain-Token,X-CSRF-Token,x-requested-with +Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS +X-UA-Compatible: IE=Edge,chrome=1 +ETag: "3f9496912d04f422c7f083b93bd6e9e3" +Cache-Control: max-age=0, private, must-revalidate +X-Request-Id: eec6552de5a2aa5ae570139b388ffb9b +X-Runtime: 0.046892 +Strict-Transport-Security: max-age=315360000 + diff --git a/spec/files/records/delete/success.http b/spec/files/records/delete/success.http new file mode 100644 index 00000000..c7cb957d --- /dev/null +++ b/spec/files/records/delete/success.http @@ -0,0 +1,19 @@ +HTTP/1.1 200 OK +Server: nginx/1.4.4 +Date: Tue, 14 Jan 2014 19:03:37 GMT +Content-Type: application/json; charset=utf-8 +Transfer-Encoding: chunked +Connection: close +Status: 200 OK +X-Dnsimple-API-Version: 1.0.0 +Access-Control-Allow-Origin: * +Access-Control-Allow-Headers: Authorization,Accepts,Content-Type,X-Dnsimple-Token,X-Dnsimple-Domain-Token,X-CSRF-Token,x-requested-with +Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS +X-UA-Compatible: IE=Edge,chrome=1 +ETag: "3f9496912d04f422c7f083b93bd6e9e3" +Cache-Control: max-age=0, private, must-revalidate +X-Request-Id: eec6552de5a2aa5ae570139b388ffb9b +X-Runtime: 0.046892 +Strict-Transport-Security: max-age=315360000 + +{} \ No newline at end of file diff --git a/spec/files/records/show/notfound.http b/spec/files/records/notfound.http similarity index 84% rename from spec/files/records/show/notfound.http rename to spec/files/records/notfound.http index 16448576..d99ecd3c 100644 --- a/spec/files/records/show/notfound.http +++ b/spec/files/records/notfound.http @@ -14,4 +14,4 @@ Cache-Control: no-cache X-Request-Id: b7af596690cb83c7a7167f4d47aa50c3 X-Runtime: 0.044234 -{"error":"Couldn't find Record with id=14395 [WHERE \"records\".\"domain_id\" = 6]"} \ No newline at end of file +{"message":"Couldn't find Record with id=14395 [WHERE \"records\".\"domain_id\" = 6]"} \ No newline at end of file diff --git a/spec/files/records/update/success.http b/spec/files/records/update/success.http new file mode 100644 index 00000000..8bf2a02d --- /dev/null +++ b/spec/files/records/update/success.http @@ -0,0 +1,21 @@ +HTTP/1.1 200 OK +Server: nginx +Date: Sun, 14 Dec 2014 16:19:53 GMT +Content-Type: application/json; charset=utf-8 +Transfer-Encoding: chunked +Connection: keep-alive +Status: 200 OK +Strict-Transport-Security: max-age=631138519 +X-Frame-Options: SAMEORIGIN +X-XSS-Protection: 1 +X-Content-Type-Options: nosniff +Access-Control-Allow-Origin: * +Access-Control-Allow-Headers: Authorization,Accepts,Content-Type,X-DNSimple-Token,X-DNSimple-Domain-Token,X-CSRF-Token,x-requested-with +Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS +ETag: "45fbc90e793bde6153366ce961c88e73" +Cache-Control: max-age=0, private, must-revalidate +X-Request-Id: e44cd2a2-5010-42c6-bdec-8e12c339e8c9 +X-Runtime: 0.036717 +Strict-Transport-Security: max-age=315360000 + +{"record":{"id":3554751,"domain_id":41571,"parent_id":null,"name":"0001","content":"127.0.0.1","ttl":3600,"prio":null,"record_type":"A","system_record":null,"created_at":"2014-12-14T16:11:02.771Z","updated_at":"2014-12-14T16:11:02.771Z"}}