From 18ce8534206f9bfc28c5416fa6512235de793e6f Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Thu, 14 May 2020 14:33:38 +0300 Subject: [PATCH 1/6] Simple implementation of retained domains API endpoint This needed a new name, there are several classes of database object to be included in that endpoint. Currently, there is one list ordered by name, with each object containing status and ascii name for convenience. Can be converted to multiple fields (reserved and blocked separately). Also contains total count. Includes CORS preflight which seems to be a known problem for Rails in the past. --- .../repp/v1/retained_domains_controller.rb | 11 ++++ app/models/retained_domains.rb | 51 +++++++++++++++++++ config/routes.rb | 12 +++++ .../integration/repp/retained_domains_test.rb | 39 ++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 app/controllers/repp/v1/retained_domains_controller.rb create mode 100644 app/models/retained_domains.rb create mode 100644 test/integration/repp/retained_domains_test.rb diff --git a/app/controllers/repp/v1/retained_domains_controller.rb b/app/controllers/repp/v1/retained_domains_controller.rb new file mode 100644 index 000000000..e28bc531f --- /dev/null +++ b/app/controllers/repp/v1/retained_domains_controller.rb @@ -0,0 +1,11 @@ +module Repp + module V1 + class RetainedDomainsController < ActionController::API + def index + domains = RetainedDomains.new + + render json: { count: domains.count, domains: domains.to_jsonable } + end + end + end +end diff --git a/app/models/retained_domains.rb b/app/models/retained_domains.rb new file mode 100644 index 000000000..b0f077919 --- /dev/null +++ b/app/models/retained_domains.rb @@ -0,0 +1,51 @@ +# Hiding the queries behind its own class will allow us to include disputed or +# auctioned domains without meddling up with controller logic. +class RetainedDomains + RESERVED = 'reserved'.freeze + BLOCKED = 'blocked'.freeze + + attr_reader :domains + + def initialize + @domains = gather_domains + end + + def gather_domains + blocked_domains = BlockedDomain.order(name: :desc).all + reserved_domains = ReservedDomain.order(name: :desc).all + + domains = blocked_domains.to_a.union(reserved_domains.to_a) + + domains.sort_by(&:name) + end + + def to_jsonable + domains.map { |el| domain_to_json(el) } + end + + def domain_to_json(domain) + # Smelly, but ActiveRecord objects are weird and do not respond + # to usual syntax: + # case a + # when Array then "foo" + # when Hash then "bar" + # else "baz" + # end + status = case domain.class.to_s + when 'ReservedDomain' then RESERVED + when 'BlockedDomain' then BLOCKED + end + + punycode = SimpleIDN.to_ascii(domain.name) + + { + name: domain.name, + status: status, + punycode_name: punycode + } + end + + def count + domains.count + end +end diff --git a/config/routes.rb b/config/routes.rb index 53d78dfa9..a8b78a1f9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -39,6 +39,18 @@ Rails.application.routes.draw do mount Repp::API => '/' + namespace :repp do + namespace :v1 do + resources :retained_domains, only: %i[index] + end + end + + match 'repp/v1/retained_domains', + controller: 'api/cors', + action: 'cors_preflight_check', + via: [:options], + as: 'repp_cors_preflight_check' + namespace :api do namespace :v1 do namespace :registrant do diff --git a/test/integration/repp/retained_domains_test.rb b/test/integration/repp/retained_domains_test.rb new file mode 100644 index 000000000..c30af001e --- /dev/null +++ b/test/integration/repp/retained_domains_test.rb @@ -0,0 +1,39 @@ +require 'test_helper' + +class ReppV1RetainedDomainsTest < ActionDispatch::IntegrationTest + # Uses magical fixtures, will fail once fixtures inside are changed: + # test/fixtures/blocked_domains.yml + # test/fixtures/reserved_domains.yml + + def test_get_index + get repp_v1_retained_domains_path + response_json = JSON.parse(response.body, symbolize_names: true) + + assert response_json[:count] == 3 + + expected_objects = [{ name: 'blocked.test', + status: 'blocked', + punycode_name: 'blocked.test' }, + { name: 'blockedäöüõ.test', + status: 'blocked', + punycode_name: 'xn--blocked-cxa7mj0e.test' }, + { name: 'reserved.test', + status: 'reserved', + punycode_name: 'reserved.test' }] + + assert_equal response_json[:domains], expected_objects + end + + def test_cors_preflight + process :options, repp_v1_retained_domains_path, headers: { 'Origin' => 'https://example.com' } + + assert_equal('https://example.com', response.headers['Access-Control-Allow-Origin']) + assert_equal('POST, GET, PUT, PATCH, DELETE, OPTIONS', + response.headers['Access-Control-Allow-Methods']) + assert_equal('Origin, Content-Type, Accept, Authorization, Token, Auth-Token, Email, ' \ + 'X-User-Token, X-User-Email', + response.headers['Access-Control-Allow-Headers']) + assert_equal('3600', response.headers['Access-Control-Max-Age']) + assert_equal('', response.body) + end +end From 88a3a2ebac27536a035d762122adfa5efe90b5ba Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Thu, 14 May 2020 14:59:17 +0300 Subject: [PATCH 2/6] Make Rubocop less whiny about things --- app/models/retained_domains.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/models/retained_domains.rb b/app/models/retained_domains.rb index b0f077919..98fa9f5ff 100644 --- a/app/models/retained_domains.rb +++ b/app/models/retained_domains.rb @@ -41,11 +41,9 @@ class RetainedDomains { name: domain.name, status: status, - punycode_name: punycode + punycode_name: punycode, } end - def count - domains.count - end + delegate :count, to: :domains end From 960e4084e3225287a52bc5f1a504e1439b7925e3 Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Thu, 14 May 2020 15:26:33 +0300 Subject: [PATCH 3/6] Properly match against class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns out I was wrong 😅 --- app/models/retained_domains.rb | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/app/models/retained_domains.rb b/app/models/retained_domains.rb index 98fa9f5ff..e821adbc9 100644 --- a/app/models/retained_domains.rb +++ b/app/models/retained_domains.rb @@ -24,16 +24,9 @@ class RetainedDomains end def domain_to_json(domain) - # Smelly, but ActiveRecord objects are weird and do not respond - # to usual syntax: - # case a - # when Array then "foo" - # when Hash then "bar" - # else "baz" - # end - status = case domain.class.to_s - when 'ReservedDomain' then RESERVED - when 'BlockedDomain' then BLOCKED + status = case domain + when ReservedDomain then RESERVED + when BlockedDomain then BLOCKED end punycode = SimpleIDN.to_ascii(domain.name) From 6e5a97ad4db836f41a0a695024e0b7e95321f606 Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Fri, 15 May 2020 14:43:18 +0300 Subject: [PATCH 4/6] Add handling of type filters and handling of ETags Add API documentation and test cases around ETags for the API. --- .../repp/v1/retained_domains_controller.rb | 6 +- app/models/retained_domains.rb | 47 +++++++-- doc/repp/v1/retained_domains.md | 96 +++++++++++++++++++ doc/repp_doc.md | 17 ++-- .../integration/repp/retained_domains_test.rb | 35 +++++++ 5 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 doc/repp/v1/retained_domains.md diff --git a/app/controllers/repp/v1/retained_domains_controller.rb b/app/controllers/repp/v1/retained_domains_controller.rb index e28bc531f..c1bb458e9 100644 --- a/app/controllers/repp/v1/retained_domains_controller.rb +++ b/app/controllers/repp/v1/retained_domains_controller.rb @@ -2,10 +2,14 @@ module Repp module V1 class RetainedDomainsController < ActionController::API def index - domains = RetainedDomains.new + domains = RetainedDomains.new(query_params) render json: { count: domains.count, domains: domains.to_jsonable } end + + def query_params + params.permit(:type) + end end end end diff --git a/app/models/retained_domains.rb b/app/models/retained_domains.rb index e821adbc9..ea76710ee 100644 --- a/app/models/retained_domains.rb +++ b/app/models/retained_domains.rb @@ -4,26 +4,55 @@ class RetainedDomains RESERVED = 'reserved'.freeze BLOCKED = 'blocked'.freeze - attr_reader :domains + attr_reader :domains, + :type - def initialize + def initialize(params) + @type = establish_type(params) @domains = gather_domains end - def gather_domains - blocked_domains = BlockedDomain.order(name: :desc).all - reserved_domains = ReservedDomain.order(name: :desc).all + delegate :count, to: :domains + def to_jsonable + domains.map { |el| domain_to_jsonable(el) } + end + + private + + def establish_type(params) + type = params[:type] + + case type + when 'reserved' then :reserved + when 'blocked' then :blocked + else :all + end + end + + def gather_domains domains = blocked_domains.to_a.union(reserved_domains.to_a) domains.sort_by(&:name) end - def to_jsonable - domains.map { |el| domain_to_json(el) } + def blocked_domains + if %i[all blocked].include?(type) + BlockedDomain.order(name: :desc).all + else + [] + end end - def domain_to_json(domain) + def reserved_domains + if %i[all reserved].include?(type) + ReservedDomain.order(name: :desc).all + else + [] + end + end + + def domain_to_jsonable(domain) status = case domain when ReservedDomain then RESERVED when BlockedDomain then BLOCKED @@ -37,6 +66,4 @@ class RetainedDomains punycode_name: punycode, } end - - delegate :count, to: :domains end diff --git a/doc/repp/v1/retained_domains.md b/doc/repp/v1/retained_domains.md new file mode 100644 index 000000000..bc930f435 --- /dev/null +++ b/doc/repp/v1/retained_domains.md @@ -0,0 +1,96 @@ +## GET /repp/v1/retained_domains + +Return a list of reserved and blocked domains, along with total count. You can +filter them by type of the domain, which can be either reserved or blocked. + +In contrast with other endpoints in REPP, this one is publicly available for +anyone without authentication. + +#### Parameters + +| Field name | Required | Type | Allowed values | Description | +| ---------- | -------- | ---- | -------------- | ----------- | +| type | false | string | ["reserved", "blocked"] | Type of domains to show | + + +#### Request + +``` +GET /repp/v1/retained_domains?type=reserved HTTP/1.1 +Accept: application/json +User-Agent: curl/7.64.1 +``` + +#### Response + +``` +HTTP/1.1 200 OK +Date: Fri, 15 May 2020 11:30:07 GMT +Content-Type: application/json; charset=utf-8 +ETag: W/"a905b531243a6b0be42beb9d6ce60619" +Cache-Control: max-age=0, private, must-revalidate +Transfer-Encoding: chunked + +{ + "count": 1, + "domains": [ + { + "name": "reserved.test", + "status": "reserved", + "punycode_name": "reserved.test" + } + ] +} +``` + +After you have made the first request, you can save the ETag header, and +send it as If-None-Match in the subsequent request for cache validation. +Due to the fact that the lists are not changing frequently and are quite long, +it is recommended that you take advantage of ETag cache. + +ETag key values depend on the request parameters. A request for only blocked +domains returns different cache key than request for all domains. + +### Cache Request + +``` +GET /repp/v1/retained_domains?type=reserved HTTP/1.1 +Accept: application/json +User-Agent: curl/7.64.1 +If-None-Match: W/"a905b531243a6b0be42beb9d6ce60619" +``` + +#### Cache hit response + +Response with no body and status 304 is sent in case the list have not changed. + +``` +HTTP/1.1 304 Not Modified +Date: Fri, 15 May 2020 11:34:25 GMT +ETag: W/"a905b531243a6b0be42beb9d6ce60619" +Cache-Control: max-age=0, private, must-revalidate +``` + +#### Cache miss response + +Standard 200 response is sent when the list have changed since last requested. + + +``` +HTTP/1.1 200 OK +Date: Fri, 15 May 2020 11:30:07 GMT +Content-Type: application/json; charset=utf-8 +ETag: W/"a905b531243a6b0be42beb9d6ce60619" +Transfer-Encoding: chunked + +{ + "count": 1, + "domains": [ + { + "name": "reserved.test", + "status": "reserved", + "punycode_name": "reserved.test" + } + ] +} +``` diff --git a/doc/repp_doc.md b/doc/repp_doc.md index f01484fc3..0d7cb55f8 100644 --- a/doc/repp_doc.md +++ b/doc/repp_doc.md @@ -1,7 +1,7 @@ # REPP integration specification -REPP uses HTTP/1.1 protocol (http://tools.ietf.org/html/rfc2616) and -Basic Authentication (http://tools.ietf.org/html/rfc2617#section-2) using +REPP uses HTTP/1.1 protocol (http://tools.ietf.org/html/rfc2616) and +Basic Authentication (http://tools.ietf.org/html/rfc2617#section-2) using Secure Transport (https://tools.ietf.org/html/rfc5246) with certificate and key (https://tools.ietf.org/html/rfc5280). Credentials and certificate are issued by EIS (in an exchange for desired API username, CSR and IP). @@ -10,13 +10,14 @@ To quickly test the API, use curl: curl -q -k --cert user.crt.pem --key user.key.pem https://TBA/repp/v1/accounts/balance -u username:password -Test API endpoint: https://testepp.internet.ee/repp/v1 +Test API endpoint: https://testepp.internet.ee/repp/v1 Production API endpoint: TBA Main communication specification through Restful EPP (REPP): -[Contact related functions](repp/v1/contact.md) -[Domain related functions](repp/v1/domain.md) -[Domain transfers](repp/v1/domain_transfers.md) -[Account related functions](repp/v1/account.md) -[Nameservers](repp/v1/nameservers.md) +[Contact related functions](repp/v1/contact.md) +[Domain related functions](repp/v1/domain.md) +[Domain transfers](repp/v1/domain_transfers.md) +[Account related functions](repp/v1/account.md) +[Nameservers](repp/v1/nameservers.md) +[Retained domains](repp/v1/retained_domains.md) diff --git a/test/integration/repp/retained_domains_test.rb b/test/integration/repp/retained_domains_test.rb index c30af001e..dc3a9fd0f 100644 --- a/test/integration/repp/retained_domains_test.rb +++ b/test/integration/repp/retained_domains_test.rb @@ -24,6 +24,41 @@ class ReppV1RetainedDomainsTest < ActionDispatch::IntegrationTest assert_equal response_json[:domains], expected_objects end + def test_get_index_with_type_parameter + get repp_v1_retained_domains_path({ 'type' => 'reserved' }) + response_json = JSON.parse(response.body, symbolize_names: true) + + assert response_json[:count] == 1 + + expected_objects = [{ name: 'reserved.test', + status: 'reserved', + punycode_name: 'reserved.test' }] + + assert_equal response_json[:domains], expected_objects + end + + def test_etags_cache + get repp_v1_retained_domains_path({ 'type' => 'reserved' }) + etag = response.headers['ETag'] + + get repp_v1_retained_domains_path({ 'type' => 'reserved' }), + headers: { 'If-None-Match' => etag } + + assert_equal response.status, 304 + assert_equal response.body, '' + end + + def test_etags_cache_valid_for_type_only + get repp_v1_retained_domains_path({ 'type' => 'blocked' }) + etag = response.headers['ETag'] + + get repp_v1_retained_domains_path, headers: { 'If-None-Match' => etag } + + assert_equal response.status, 200 + response_json = JSON.parse(response.body, symbolize_names: true) + assert response_json[:count] == 3 + end + def test_cors_preflight process :options, repp_v1_retained_domains_path, headers: { 'Origin' => 'https://example.com' } From 56e3784aa4245984219f5b2a363ef2a7a85dc704 Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Fri, 15 May 2020 14:52:11 +0300 Subject: [PATCH 5/6] Use constants instead of strings --- app/models/retained_domains.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/retained_domains.rb b/app/models/retained_domains.rb index ea76710ee..b26bbab40 100644 --- a/app/models/retained_domains.rb +++ b/app/models/retained_domains.rb @@ -24,8 +24,8 @@ class RetainedDomains type = params[:type] case type - when 'reserved' then :reserved - when 'blocked' then :blocked + when RESERVED then :reserved + when BLOCKED then :blocked else :all end end From 09b4575a7137a412b1f8548aaf4da1bf4bd560e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20V=C3=B5hmar?= Date: Mon, 18 May 2020 12:46:52 +0300 Subject: [PATCH 6/6] Update retained_domains.md --- doc/repp/v1/retained_domains.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/repp/v1/retained_domains.md b/doc/repp/v1/retained_domains.md index bc930f435..4209b0f58 100644 --- a/doc/repp/v1/retained_domains.md +++ b/doc/repp/v1/retained_domains.md @@ -73,7 +73,7 @@ Cache-Control: max-age=0, private, must-revalidate #### Cache miss response -Standard 200 response is sent when the list have changed since last requested. +Standard 200 response (with the current complete list) is sent when the list have changed since last requested. ```