From 4885086e648f890429dfdb7a784077c25a8584b7 Mon Sep 17 00:00:00 2001 From: Vitalie Cherpec Date: Tue, 29 Aug 2023 13:59:58 +0300 Subject: [PATCH] Add REST API client implementation --- .rubocop.yml | 2 + README.md | 5 + Rakefile | 4 +- lib/luadns.rb | 13 ++- lib/luadns/client.rb | 105 +++++++++++++++++- lib/luadns/errors.rb | 45 +++++++- lib/luadns/http_client.rb | 61 ++++++++++ lib/luadns/response.rb | 6 + lib/luadns/schema.rb | 4 - lib/luadns/schema/base.rb | 15 +++ lib/luadns/schema/record.rb | 2 +- lib/luadns/schema/user.rb | 2 +- lib/luadns/schema/zone.rb | 2 +- lib/luadns/version.rb | 2 + test/client_test.rb | 70 +++++++++++- test/fixtures/http/users/me.show:err-bad-key | 11 ++ ....create:err => zones.create:err-forbidden} | 0 .../fixtures/http/zones.create:err-validation | 13 +++ test/records_test.rb | 76 +++++++++++++ test/support/helpers.rb | 8 ++ test/test_helper.rb | 12 +- test/users_test.rb | 24 ++++ test/zones_test.rb | 79 +++++++++++++ 23 files changed, 540 insertions(+), 21 deletions(-) create mode 100644 lib/luadns/http_client.rb create mode 100644 lib/luadns/response.rb create mode 100644 lib/luadns/schema/base.rb create mode 100644 test/fixtures/http/users/me.show:err-bad-key rename test/fixtures/http/{zones.create:err => zones.create:err-forbidden} (100%) create mode 100644 test/fixtures/http/zones.create:err-validation create mode 100644 test/records_test.rb create mode 100644 test/support/helpers.rb create mode 100644 test/users_test.rb create mode 100644 test/zones_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index fb3298c..770b73d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,9 +6,11 @@ require: - rubocop-minitest AllCops: + NewCops: enable TargetRubyVersion: 3.0 Exclude: - '*.gemspec' + - 'Gemfile' - 'Rakefile' - 'vendor/**/*' diff --git a/README.md b/README.md index d324650..3920e75 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,8 @@ This is the Ruby client for [LuaDNS REST API](https://www.luadns.com/api.html). [![Build Status](https://github.com/luadns/luadns-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/luadns/luadns-ruby/actions/workflows/ci.yml) + + +Usage: + +``` diff --git a/Rakefile b/Rakefile index f7d392f..6e35329 100644 --- a/Rakefile +++ b/Rakefile @@ -3,13 +3,13 @@ require 'rake/testtask' require 'rdoc/task' desc 'Default: run tests.' -task :default => :test +task default: :test desc 'Run luadns unit tests.' Rake::TestTask.new do |t| t.libs << 'test' t.libs << 'lib' - t.test_files = Dir[ 'test/*_test.rb' ] + t.test_files = Dir[ 'test/**/*_test.rb' ] t.verbose = true end diff --git a/lib/luadns.rb b/lib/luadns.rb index b1f2539..c757533 100644 --- a/lib/luadns.rb +++ b/lib/luadns.rb @@ -1,7 +1,18 @@ +# frozen_string_literal: true + module Luadns - BASE_URL = 'https://api.luadns.com/v1' + BASE_URL = 'https://api.luadns.com/v1' + JSON_MIME = 'application/json' + USER_AGENT = "luadns-ruby/#{VERSION}".freeze end +require 'httparty' require_relative 'luadns/errors' require_relative 'luadns/schema' +require_relative 'luadns/schema/base' +require_relative 'luadns/schema/user' +require_relative 'luadns/schema/zone' +require_relative 'luadns/schema/record' +require_relative 'luadns/response' +require_relative 'luadns/http_client' require_relative 'luadns/client' diff --git a/lib/luadns/client.rb b/lib/luadns/client.rb index bd33592..e625153 100644 --- a/lib/luadns/client.rb +++ b/lib/luadns/client.rb @@ -1,13 +1,106 @@ module Luadns class Client - attr_reader :base_url - attr_reader :username - attr_reader :password + @@options = { + format: :json, + headers: { + 'Accept' => JSON_MIME, + 'Content-Type' => JSON_MIME, + 'User-Agent' => USER_AGENT + } + } - def initialize(username, password, base_url = Luadns::BASE_URL) + attr_reader :http_client + + def initialize(username, password, base_url = BASE_URL) + @http_client = HttpClient.new(request_options(username, password)) @base_url = base_url - @username = username - @password = password + end + + # Gets current user. + def me + http_client.get(endpoint_url('/users/me')) do |response| + JSON.parse(response.body, object_class: Schema::User) + end + end + + # Lists all zones. + def list_zones + http_client.get(endpoint_url('/zones')) do |response| + JSON.parse(response.body, object_class: Schema::Zone) + end + end + + # Creates a zone. + def create_zone(attrs) + http_client.post(endpoint_url('/zones'), attrs) do |response| + JSON.parse(response.body, object_class: Schema::Zone) + end + end + + # Gets a zone. + def get_zone(zone_id) + http_client.get(endpoint_url("/zones/#{zone_id}")) do |response| + JSON.parse(response.body, object_class: Schema::Zone) + end + end + + # Updates a zone. + def update_zone(zone_id, attrs) + http_client.put(endpoint_url("/zones/#{zone_id}"), attrs) do |response| + JSON.parse(response.body, object_class: Schema::Zone) + end + end + + # Deletes a zone. + def delete_zone(zone_id) + http_client.delete(endpoint_url("/zones/#{zone_id}")) do |response| + JSON.parse(response.body, object_class: Schema::Zone) + end + end + + # Lists all zone records. + def list_records(zone) + http_client.get(endpoint_url("/zones/#{zone.id}/records")) do |response| + JSON.parse(response.body, object_class: Schema::Record) + end + end + + # Creates a zone record. + def create_record(zone, attrs) + http_client.post(endpoint_url("/zones/#{zone.id}/records"), attrs) do |response| + JSON.parse(response.body, object_class: Schema::Record) + end + end + + # Gets a zone record. + def get_record(zone, record_id) + http_client.get(endpoint_url("/zones/#{zone.id}/records/#{record_id}")) do |response| + JSON.parse(response.body, object_class: Schema::Record) + end + end + + # Updates a zone record. + def update_record(zone, record_id, attrs) + http_client.put(endpoint_url("/zones/#{zone.id}/records/#{record_id}"), attrs) do |response| + JSON.parse(response.body, object_class: Schema::Record) + end + end + + # Deletes a zone record. + def delete_record(zone, record_id) + http_client.delete(endpoint_url("/zones/#{zone.id}/records/#{record_id}")) do |response| + JSON.parse(response.body, object_class: Schema::Record) + end + end + + protected + + def request_options(username, password) + @@options.merge({ basic_auth: { username: username, password: password } }) + end + + def endpoint_url(path) + "#{@base_url}#{path}" end end end diff --git a/lib/luadns/errors.rb b/lib/luadns/errors.rb index ace3da4..c6aab0d 100644 --- a/lib/luadns/errors.rb +++ b/lib/luadns/errors.rb @@ -1,3 +1,46 @@ module Luadns - # class LuadnsError < RuntimeError; end + class NotFoundError < StandardError; end + + class RequestError < StandardError + attr_reader :message + + def initialize(response) + @message = JSON.parse(response.body)['message'] + end + end + + class AuthFailedError < RequestError; end + + class ValidationError < RequestError + attr_reader :message + + def initialize(response) + body = JSON.parse(response.body) + @message = body.map do |t| + fields = t['fieldNames'] + message = t['message'] + "#{fields.join(', ')}: #{message}" + end.join('; ') + end + end + + class TooManyRequestsError < StandardError + attr_reader :code + attr_reader :reset + + def initialize(response) + @code = response.code + @reset = response.headers['X-Ratelimit-Reset'].to_i + end + end + + class ServerError < StandardError + attr_reader :code + attr_reader :content_type + + def initialize(response) + @code = response.code + @content_type = response.headers['Content-Type'] + end + end end diff --git a/lib/luadns/http_client.rb b/lib/luadns/http_client.rb new file mode 100644 index 0000000..af52c8c --- /dev/null +++ b/lib/luadns/http_client.rb @@ -0,0 +1,61 @@ +module Luadns + class HttpClient + def initialize(options = {}) + @options = options + end + + # Make a HTTP GET request. + def get(url, options = {}) + yield http_request(:get, url, nil, request_options(options)) + end + + # Make a HTTP POST request. + def post(url, data = nil, options = {}) + yield http_request(:post, url, data, request_options(options)) + end + + # Make a HTTP PUT request. + def put(url, data = nil, options = {}) + yield http_request(:put, url, data, request_options(options)) + end + + # Make a HTTP DELETE request. + def delete(url, options = {}) + yield http_request(:delete, url, nil, request_options(options)) + end + + private + + def http_request(method, url, data = nil, options = {}) + request_type = options.dig(:headers, 'Content-Type') + options = options.merge(body: encode_body(data, request_type)) if data + response = HTTParty.send(method, url, options) + case response.code + when 200 + raise ServerError, response unless response.headers['Content-Type'].start_with?(JSON_MIME) + + response + when 400 + raise ValidationError, response + when 401 + raise AuthFailedError, response + when 403 + raise RequestError, response + when 404 + raise NotFoundError, response + when 429 + raise TooManyRequestsError, response + else + raise ServerError, response + end + end + + def encode_body(data, content_type) + content_type == JSON_MIME ? JSON.dump(data) : data + end + + def request_options(options) + @options.merge(options) + end + end +end diff --git a/lib/luadns/response.rb b/lib/luadns/response.rb new file mode 100644 index 0000000..1c3f876 --- /dev/null +++ b/lib/luadns/response.rb @@ -0,0 +1,6 @@ +module Luadns + class Response + attr_reader :status_code + attr_reader :body + end +end diff --git a/lib/luadns/schema.rb b/lib/luadns/schema.rb index 5f0740d..30a983d 100644 --- a/lib/luadns/schema.rb +++ b/lib/luadns/schema.rb @@ -2,7 +2,3 @@ module Luadns module Schema end end - -require_relative 'schema/user' -require_relative 'schema/zone' -require_relative 'schema/record' diff --git a/lib/luadns/schema/base.rb b/lib/luadns/schema/base.rb new file mode 100644 index 0000000..7c240c7 --- /dev/null +++ b/lib/luadns/schema/base.rb @@ -0,0 +1,15 @@ +module Luadns + module Schema + class Base + def initialize(options = {}) + options.each do |name, value| + send("#{name}=", value) + end + end + + def []=(name, value) + send("#{name}=", value) if respond_to?(name) + end + end + end +end diff --git a/lib/luadns/schema/record.rb b/lib/luadns/schema/record.rb index 29468fd..52b53bb 100644 --- a/lib/luadns/schema/record.rb +++ b/lib/luadns/schema/record.rb @@ -1,6 +1,6 @@ module Luadns module Schema - class Record + class Record < Base attr_accessor :id attr_accessor :name attr_accessor :type diff --git a/lib/luadns/schema/user.rb b/lib/luadns/schema/user.rb index 921d4b3..80a6136 100644 --- a/lib/luadns/schema/user.rb +++ b/lib/luadns/schema/user.rb @@ -1,6 +1,6 @@ module Luadns module Schema - class User + class User < Base attr_accessor :email attr_accessor :name attr_accessor :repo_uri diff --git a/lib/luadns/schema/zone.rb b/lib/luadns/schema/zone.rb index b534658..e7ddef4 100644 --- a/lib/luadns/schema/zone.rb +++ b/lib/luadns/schema/zone.rb @@ -1,6 +1,6 @@ module Luadns module Schema - class Zone + class Zone < Base attr_accessor :id attr_accessor :name attr_accessor :created_at diff --git a/lib/luadns/version.rb b/lib/luadns/version.rb index 71306b5..a5427ec 100644 --- a/lib/luadns/version.rb +++ b/lib/luadns/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Luadns VERSION = '0.1' end diff --git a/test/client_test.rb b/test/client_test.rb index c8f98ce..9959678 100644 --- a/test/client_test.rb +++ b/test/client_test.rb @@ -1,11 +1,75 @@ require 'test_helper' -class Luadns::ClientTest < Minitest::Test +class Luadns::ClientTest < Luadns::Test def setup @client = Luadns::Client.new('username', 'password') end - def test_true - assert true + it 'sets accept header to application/json' do + stub_request(:get, 'https://api.luadns.com/v1/users/me') + .with({ headers: { 'Accept' => 'application/json' } }) + .to_return(read_http_fixture('test/fixtures/http/users/me.show')) + + @client.me + end + + it 'uses basic access authentication' do + stub_request(:get, 'https://api.luadns.com/v1/users/me') + .with({ headers: { 'Authorization' => 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=' } }) + .to_return(read_http_fixture('test/fixtures/http/users/me.show')) + + @client.me + end + + it 'sets custom user-agent header' do + stub_request(:get, 'https://api.luadns.com/v1/users/me') + .with({ headers: { 'User-Agent' => "luadns-ruby/#{Luadns::VERSION}" } }) + .to_return(read_http_fixture('test/fixtures/http/users/me.show')) + + @client.me + end + + it 'raises exception when server when authentication fails' do + stub_request(:get, 'https://api.luadns.com/v1/users/me') + .to_return(read_http_fixture('test/fixtures/http/users/me.show:err-bad-key')) + + error = assert_raises Luadns::AuthFailedError do + @client.me + end + + assert_equal 'Invalid API Key', error.message + end + + it 'raises exception when server returns bad status code' do + stub_request(:get, 'https://api.luadns.com/v1/users/me') + .to_return(read_http_fixture('test/fixtures/http/users/me.show:err-bad-code')) + + error = assert_raises Luadns::ServerError do + @client.me + end + + assert_equal 502, error.code + end + + it 'raises exception when server returns bad content type' do + stub_request(:get, 'https://api.luadns.com/v1/users/me') + .to_return(read_http_fixture('test/fixtures/http/users/me.show:err-bad-content')) + + error = assert_raises Luadns::ServerError do + @client.me + end + + assert_equal 'text/html', error.content_type + end + + it 'raises exception when server returns 429 status code' do + stub_request(:get, 'https://api.luadns.com/v1/users/me') + .to_return(read_http_fixture('test/fixtures/http/users/me.show:err-too-many')) + + error = assert_raises Luadns::TooManyRequestsError do + @client.me + end + + assert_equal 1_693_221_300, error.reset end end diff --git a/test/fixtures/http/users/me.show:err-bad-key b/test/fixtures/http/users/me.show:err-bad-key new file mode 100644 index 0000000..c15b013 --- /dev/null +++ b/test/fixtures/http/users/me.show:err-bad-key @@ -0,0 +1,11 @@ +HTTP/1.0 401 Unauthorized +Cache-Control: no-cache, no-store, no-transform, must-revalidate, private, max-age=0 +Content-Length: 100 +Content-Type: application/json; charset=utf-8 +Date: Tue, 05 Sep 2023 14:39:41 GMT +Expires: Thu, 01 Jan 1970 02:00:00 EET +Pragma: no-cache +Www-Authenticate: Basic realm="Authorization Required" +X-Accel-Expires: 0 + +{"status":"Unauthorized","request_id":"apollo.local/HkBjqPZiyD-000046","message":"Invalid API Key"} diff --git a/test/fixtures/http/zones.create:err b/test/fixtures/http/zones.create:err-forbidden similarity index 100% rename from test/fixtures/http/zones.create:err rename to test/fixtures/http/zones.create:err-forbidden diff --git a/test/fixtures/http/zones.create:err-validation b/test/fixtures/http/zones.create:err-validation new file mode 100644 index 0000000..3f79888 --- /dev/null +++ b/test/fixtures/http/zones.create:err-validation @@ -0,0 +1,13 @@ +HTTP/1.0 400 Bad Request +Cache-Control: no-cache, no-store, no-transform, must-revalidate, private, max-age=0 +Content-Length: 102 +Content-Type: application/json; charset=utf-8 +Date: Tue, 05 Sep 2023 14:45:32 GMT +Expires: Thu, 01 Jan 1970 02:00:00 EET +Pragma: no-cache +X-Accel-Expires: 0 +X-Ratelimit-Limit: 1200 +X-Ratelimit-Remaining: 1197 +X-Ratelimit-Reset: 1693925400 + +[{"fieldNames":["name"],"classification":"ValidationError","message":"invalid name (min=4, max=255)"}] \ No newline at end of file diff --git a/test/records_test.rb b/test/records_test.rb new file mode 100644 index 0000000..30d2d26 --- /dev/null +++ b/test/records_test.rb @@ -0,0 +1,76 @@ +require 'test_helper' + +class Luadns::RecordsTest < Luadns::Test + def setup + @client = Luadns::Client.new('username', 'password') + end + + def zone + Luadns::Schema::Zone.new(id: 5) + end + + it 'should list records' do + stub_request(:get, 'https://api.luadns.com/v1/zones/5/records') + .to_return(read_http_fixture('test/fixtures/http/zones/5/records.index')) + + records = @client.list_records(zone) + + assert_equal 11, records.size + + record = records.first + + assert_equal 115_014_343, record.id + assert_equal 'example.org.', record.name + assert_equal 'ns1.luadns.net. hostmaster.luadns.net. 1692975563 1200 120 604800 3600', record.content + assert_equal 3600, record.ttl + end + + it 'should create a new record' do + stub_request(:post, 'https://api.luadns.com/v1/zones/5/records') + .to_return(read_http_fixture('test/fixtures/http/zones/5/records.create')) + + record = @client.create_record(zone, + { name: 'example.org', type: 'TXT', content: 'Hello, world!', ttl: 3600 }) + + assert_equal 115_087_858, record.id + assert_equal 'example.org.', record.name + assert_equal 'Hello, world!', record.content + assert_equal 3600, record.ttl + end + + it 'should get record' do + stub_request(:get, 'https://api.luadns.com/v1/zones/5/records/115014348') + .to_return(read_http_fixture('test/fixtures/http/zones/5/records/115014348.show')) + + record = @client.get_record(zone, 115_014_348) + + assert_equal 115_014_348, record.id + assert_equal 'example.org.', record.name + assert_equal '1.1.1.1', record.content + assert_equal 86_400, record.ttl + end + + it 'should update record' do + stub_request(:put, 'https://api.luadns.com/v1/zones/5/records/115014348') + .to_return(read_http_fixture('test/fixtures/http/zones/5/records/115014348.update')) + + record = @client.update_record(zone, 115_014_348, { name: 'example.org.', content: '2.2.2.2', ttl: 3600 }) + + assert_equal 115_014_348, record.id + assert_equal 'example.org.', record.name + assert_equal '2.2.2.2', record.content + assert_equal 86_400, record.ttl + end + + it 'should delete record' do + stub_request(:delete, 'https://api.luadns.com/v1/zones/5/records/115014348') + .to_return(read_http_fixture('test/fixtures/http/zones/5/records/115014348.delete')) + + record = @client.delete_record(zone, 115_014_348) + + assert_equal 115_014_348, record.id + assert_equal 'example.org.', record.name + assert_equal '1.1.1.1', record.content + assert_equal 86_400, record.ttl + end +end diff --git a/test/support/helpers.rb b/test/support/helpers.rb new file mode 100644 index 0000000..ccb622e --- /dev/null +++ b/test/support/helpers.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module MinitestHelpers + def read_http_fixture(filename) + path = File.expand_path(File.expand_path("../../#{filename}", __dir__)) + IO.binread(path) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 66ed597..0ff793f 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,16 @@ require 'luadns' require 'minitest/autorun' -require 'minitest/unit' require 'minitest/reporters' +require 'webmock/minitest' +require 'support/helpers' Minitest::Reporters.use! [Minitest::Reporters::DefaultReporter.new(color: true)] + +module Luadns + class Test < Minitest::Test + # For `it` helper which makes tests more readable. + extend Minitest::Spec::DSL + + include MinitestHelpers + end +end diff --git a/test/users_test.rb b/test/users_test.rb new file mode 100644 index 0000000..13aec4a --- /dev/null +++ b/test/users_test.rb @@ -0,0 +1,24 @@ +require 'test_helper' + +class Luadns::UsersTest < Luadns::Test + def setup + @client = Luadns::Client.new('username', 'password') + end + + it 'returns current user info' do + stub_request(:get, 'https://api.luadns.com/v1/users/me') + .to_return(read_http_fixture('test/fixtures/http/users/me.show')) + + user = @client.me + + assert_equal 'joe@example.com', user.email + assert_equal 'Example User', user.name + assert_equal '', user.repo_uri + assert user.api_enabled + refute user.tfa + assert_match(/ssh-rsa AAAAB3NzaC1yc.*/, user.deploy_key) + assert_equal 300, user.ttl + assert_equal 'Free', user.package + assert_equal %w[ns1.luadns.net. ns2.luadns.net. ns3.luadns.net. ns4.luadns.net.], user.name_servers + end +end diff --git a/test/zones_test.rb b/test/zones_test.rb new file mode 100644 index 0000000..6dffa7f --- /dev/null +++ b/test/zones_test.rb @@ -0,0 +1,79 @@ +require 'test_helper' + +class Luadns::ZonesTest < Luadns::Test + def setup + @client = Luadns::Client.new('username', 'password') + end + + it 'should list zones' do + stub_request(:get, 'https://api.luadns.com/v1/zones') + .to_return(read_http_fixture('test/fixtures/http/zones.index')) + + zones = @client.list_zones + + assert_equal 1, zones.size + + zone = zones.first + + assert_equal 5, zone.id + assert_equal 'example.org', zone.name + end + + it 'should create new zone' do + stub_request(:post, 'https://api.luadns.com/v1/zones') + .to_return(read_http_fixture('test/fixtures/http/zones.create')) + + zone = @client.create_zone({ name: 'example.dev' }) + + assert_equal 75_247, zone.id + assert_equal 'example.dev', zone.name + + stub_request(:post, 'https://api.luadns.com/v1/zones') + .to_return(read_http_fixture('test/fixtures/http/zones.create:err-forbidden')) + + error = assert_raises Luadns::RequestError do + zone = @client.create_zone({ name: 'example.org' }) + end + + assert_equal "Zone 'example.org' is taken already.", error.message + + stub_request(:post, 'https://api.luadns.com/v1/zones') + .to_return(read_http_fixture('test/fixtures/http/zones.create:err-validation')) + + error = assert_raises Luadns::ValidationError do + zone = @client.create_zone({ name: '%example.org' }) + end + + assert_match(/invalid name/, error.message) + end + + it 'should get zone' do + stub_request(:get, 'https://api.luadns.com/v1/zones/5') + .to_return(read_http_fixture('test/fixtures/http/zones/5.show')) + + zone = @client.get_zone(5) + + assert_equal 5, zone.id + assert_equal 'example.org', zone.name + end + + it 'should update zone' do + stub_request(:put, 'https://api.luadns.com/v1/zones/5') + .to_return(read_http_fixture('test/fixtures/http/zones/5.update')) + + zone = @client.update_zone(5, { name: 'example.org' }) + + assert_equal 5, zone.id + assert_equal 'example.org', zone.name + end + + it 'should delete zone' do + stub_request(:delete, 'https://api.luadns.com/v1/zones/5') + .to_return(read_http_fixture('test/fixtures/http/zones/5.delete')) + + zone = @client.delete_zone(5) + + assert_equal 5, zone.id + assert_equal 'example.org', zone.name + end +end