monovm-whois-ruby 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +75 -0
  3. data/LICENSE +21 -0
  4. data/README.md +348 -0
  5. data/data/rdap_bootstrap.json +5337 -0
  6. data/data/whois_servers.json +1460 -0
  7. data/exe/monovm-whois +6 -0
  8. data/lib/monovm/whois/availability/analyzer.rb +91 -0
  9. data/lib/monovm/whois/availability/context.rb +137 -0
  10. data/lib/monovm/whois/availability/patterns.rb +415 -0
  11. data/lib/monovm/whois/availability/rule.rb +57 -0
  12. data/lib/monovm/whois/availability/rule_set.rb +137 -0
  13. data/lib/monovm/whois/availability/rules/availability_keywords.rb +32 -0
  14. data/lib/monovm/whois/availability/rules/explicit_unavailability.rb +43 -0
  15. data/lib/monovm/whois/availability/rules/no_match.rb +31 -0
  16. data/lib/monovm/whois/availability/rules/premium_name.rb +35 -0
  17. data/lib/monovm/whois/availability/rules/rdap_object.rb +94 -0
  18. data/lib/monovm/whois/availability/rules/recordless.rb +45 -0
  19. data/lib/monovm/whois/availability/rules/registration_fields.rb +37 -0
  20. data/lib/monovm/whois/availability/rules/registry_marker.rb +38 -0
  21. data/lib/monovm/whois/availability/rules/server_refusal.rb +46 -0
  22. data/lib/monovm/whois/availability/rules/status_field.rb +42 -0
  23. data/lib/monovm/whois/availability/rules/tld_specific.rb +38 -0
  24. data/lib/monovm/whois/availability/rules/wrong_registry.rb +48 -0
  25. data/lib/monovm/whois/availability/verdict.rb +100 -0
  26. data/lib/monovm/whois/checker.rb +165 -0
  27. data/lib/monovm/whois/cli.rb +250 -0
  28. data/lib/monovm/whois/client.rb +227 -0
  29. data/lib/monovm/whois/configuration.rb +160 -0
  30. data/lib/monovm/whois/domain_name.rb +168 -0
  31. data/lib/monovm/whois/endpoint.rb +131 -0
  32. data/lib/monovm/whois/errors.rb +63 -0
  33. data/lib/monovm/whois/parser/base.rb +126 -0
  34. data/lib/monovm/whois/parser/icann_rdd.rb +79 -0
  35. data/lib/monovm/whois/parser/key_value.rb +169 -0
  36. data/lib/monovm/whois/parser/rdap_json.rb +170 -0
  37. data/lib/monovm/whois/parser/record.rb +165 -0
  38. data/lib/monovm/whois/parser/selector.rb +74 -0
  39. data/lib/monovm/whois/paths.rb +31 -0
  40. data/lib/monovm/whois/punycode.rb +206 -0
  41. data/lib/monovm/whois/referral/follower.rb +90 -0
  42. data/lib/monovm/whois/registry/definition.rb +119 -0
  43. data/lib/monovm/whois/registry/resolution.rb +57 -0
  44. data/lib/monovm/whois/registry/server_registry.rb +164 -0
  45. data/lib/monovm/whois/registry/sources/base.rb +58 -0
  46. data/lib/monovm/whois/registry/sources/iana_bootstrap.rb +142 -0
  47. data/lib/monovm/whois/registry/sources/json_file.rb +137 -0
  48. data/lib/monovm/whois/response.rb +89 -0
  49. data/lib/monovm/whois/result.rb +114 -0
  50. data/lib/monovm/whois/transport/base.rb +51 -0
  51. data/lib/monovm/whois/transport/factory.rb +51 -0
  52. data/lib/monovm/whois/transport/middleware/base.rb +55 -0
  53. data/lib/monovm/whois/transport/middleware/cache.rb +92 -0
  54. data/lib/monovm/whois/transport/middleware/instrumentation.rb +63 -0
  55. data/lib/monovm/whois/transport/middleware/retry.rb +56 -0
  56. data/lib/monovm/whois/transport/middleware/throttle.rb +62 -0
  57. data/lib/monovm/whois/transport/rdap_http.rb +146 -0
  58. data/lib/monovm/whois/transport/whois_socket.rb +130 -0
  59. data/lib/monovm/whois/version.rb +7 -0
  60. data/lib/monovm/whois/whois_handler.rb +157 -0
  61. data/lib/monovm/whois.rb +142 -0
  62. data/lib/monovm-whois-ruby.rb +5 -0
  63. data/lib/monovm-whois.rb +5 -0
  64. metadata +114 -0
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MonoVM
4
+ module Whois
5
+ # Base class for every error this gem raises.
6
+ #
7
+ # A library that raises bare exceptions leaves callers rescuing everything
8
+ # or nothing. Here the taxonomy is meaningful: callers who
9
+ # just want "did it blow up" rescue {Error}, while callers that retry on a
10
+ # busy registry but give up on a bad domain can discriminate.
11
+ class Error < StandardError; end
12
+
13
+ # The input is not a usable domain name.
14
+ class InvalidDomainError < Error
15
+ attr_reader :domain
16
+
17
+ def initialize(domain, message = nil)
18
+ @domain = domain
19
+ super(message || "not a usable domain name: #{domain.inspect}")
20
+ end
21
+ end
22
+
23
+ # No WHOIS or RDAP endpoint is known for this TLD.
24
+ class UnsupportedTldError < Error
25
+ attr_reader :tld
26
+
27
+ def initialize(tld, message = nil)
28
+ @tld = tld
29
+ super(message || "no whois server known for #{tld.nil? ? "a name without a TLD" : tld}")
30
+ end
31
+ end
32
+
33
+ # A server definition file is missing, unreadable or malformed.
34
+ class DefinitionsError < Error; end
35
+
36
+ # Raised when the transport could not reach the server at all.
37
+ class ConnectionError < Error; end
38
+
39
+ # The connection succeeded but the exchange exceeded the configured timeout.
40
+ class TimeoutError < ConnectionError; end
41
+
42
+ # The server answered, but refused to give a verdict: rate limiting, a
43
+ # blocked client, a retired port 43 endpoint, or an HTTP status that is not
44
+ # an answer (401/403/405/406/429/5xx).
45
+ #
46
+ # This is deliberately distinct from "the domain is registered". Conflating
47
+ # the two is how a permissive detector ends up reporting registered domains
48
+ # as free.
49
+ class ServerRefusedError < Error
50
+ attr_reader :endpoint
51
+
52
+ def initialize(message, endpoint: nil)
53
+ @endpoint = endpoint
54
+ super(message)
55
+ end
56
+ end
57
+
58
+ # The server closed the connection without sending anything. An empty record
59
+ # is not evidence that a domain is unregistered, so this is an error rather
60
+ # than a verdict.
61
+ class EmptyResponseError < Error; end
62
+ end
63
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "record"
5
+
6
+ module MonoVM
7
+ module Whois
8
+ module Parser
9
+ # Shared scaffolding for record parsers.
10
+ #
11
+ # Subclasses say which responses they understand ({#applicable?}) and how to
12
+ # turn one into a {Record} ({#parse}). Date parsing lives here because it is
13
+ # the one job every registry does differently and none of them do well: the
14
+ # same field arrives as +2026-08-13T04:00:00Z+, +13-Aug-2026+, +2026.08.13+ or
15
+ # +13/08/2026+, and getting it wrong by a month is worse than returning nil.
16
+ class Base
17
+ # @param response [Response]
18
+ # @return [Boolean]
19
+ def applicable?(response)
20
+ raise NotImplementedError, "#{self.class} must implement #applicable?"
21
+ end
22
+
23
+ # @param response [Response]
24
+ # @return [Record]
25
+ def parse(response)
26
+ raise NotImplementedError, "#{self.class} must implement #parse"
27
+ end
28
+
29
+ def name
30
+ @name ||= Base.snake_case(self.class)
31
+ end
32
+
33
+ # +IcannRdd+ becomes +"icann_rdd"+. Handles an anonymous class, whose
34
+ # +Module#name+ is nil — which a spec or a host application defining a parser
35
+ # inline would otherwise crash on.
36
+ def self.snake_case(klass)
37
+ base = klass.name.to_s.split("::").last
38
+ base = klass.inspect if base.nil? || base.empty?
39
+ base.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
40
+ end
41
+
42
+ # Explicit formats, tried in order, before falling back to Ruby's parser.
43
+ #
44
+ # Ordering matters for the ambiguous ones: +13/08/2026+ is day-first
45
+ # everywhere that writes it with slashes in a WHOIS record, so that pattern
46
+ # comes before the month-first reading Time.parse would pick.
47
+ DATE_FORMATS = [
48
+ "%Y-%m-%dT%H:%M:%S%z",
49
+ "%Y-%m-%dT%H:%M:%SZ",
50
+ "%Y-%m-%dT%H:%M:%S.%L%z",
51
+ "%Y-%m-%d %H:%M:%S%z",
52
+ "%Y-%m-%d %H:%M:%S",
53
+ "%Y-%m-%d",
54
+ "%Y.%m.%d %H:%M:%S",
55
+ "%Y.%m.%d",
56
+ "%Y/%m/%d",
57
+ "%d-%b-%Y %H:%M:%S",
58
+ "%d-%b-%Y",
59
+ "%d.%m.%Y %H:%M:%S",
60
+ "%d.%m.%Y",
61
+ "%d/%m/%Y %H:%M:%S",
62
+ "%d/%m/%Y",
63
+ "%b %d %Y",
64
+ "%d %b %Y",
65
+ "%Y%m%d"
66
+ ].freeze
67
+
68
+ # Registration dates are never before the DNS existed and never centuries
69
+ # away. The range is what makes the ambiguous formats safe to try in order:
70
+ # "%Y.%m.%d" happily parses "12.5.2015" as year 12, month 5, day 2015 —
71
+ # rolling over into May of year 12 — and without this guard that nonsense wins
72
+ # before "%d.%m.%Y" is ever tried, silently misdating every European record.
73
+ PLAUSIBLE_YEARS = (1980..2200)
74
+
75
+ private
76
+
77
+ # @return [Time, nil]
78
+ def parse_time(value)
79
+ text = value.to_s.strip
80
+ return nil if text.empty?
81
+
82
+ # Registries append notes to dates: "2026-08-13 (registry lock)".
83
+ text = text.sub(/\s*\(.*\)\s*\z/, "").strip
84
+ # A trailing timezone name after an offset confuses every strptime format.
85
+ text = text.sub(/\s+\((?:UTC|GMT)[^)]*\)\z/i, "").strip
86
+ return nil if text.empty?
87
+
88
+ from_formats(text) || from_fallback(text)
89
+ end
90
+
91
+ def from_formats(text)
92
+ DATE_FORMATS.each do |format|
93
+ parsed = begin
94
+ Time.strptime(text, format)
95
+ rescue ArgumentError, RangeError
96
+ nil
97
+ end
98
+
99
+ next if parsed.nil?
100
+ next unless PLAUSIBLE_YEARS.cover?(parsed.year)
101
+ # strptime also parses "2026" with "%Y%m%d" and calls it January 1st;
102
+ # requiring the year to appear in the input rejects that kind of match.
103
+ next unless text.include?(parsed.year.to_s)
104
+
105
+ return parsed
106
+ end
107
+
108
+ nil
109
+ end
110
+
111
+ def from_fallback(text)
112
+ Time.parse(text)
113
+ rescue ArgumentError, RangeError, TypeError
114
+ nil
115
+ end
116
+
117
+ # Registries answer with a literal for "no data"; those must not become
118
+ # strings that look like values.
119
+ def blank?(value)
120
+ text = value.to_s.strip
121
+ text.empty? || Record::REDACTIONS.include?(text.downcase)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "key_value"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Parser
8
+ # The ICANN Registrar Data Directory format used by gTLD registries.
9
+ #
10
+ # Structurally it is {KeyValue}, so almost everything is inherited. What earns a
11
+ # subclass is that this format carries the same fact twice and the two copies
12
+ # disagree: a +.com+ record holds both a +Registry Expiry Date+ and a
13
+ # +Registrar Registration Expiration Date+, and the registrar's copy is
14
+ # frequently stale. The registry is authoritative, so it wins here explicitly
15
+ # rather than by luck of alias ordering.
16
+ #
17
+ # It also drops the +>>> Last update of WHOIS database+ trailer, which is a
18
+ # timestamp for the database rather than for the domain and otherwise lands in
19
+ # {Record#updated_on}.
20
+ class IcannRdd < KeyValue
21
+ # Keys that only this format uses; two of them together is a confident match.
22
+ SIGNATURE_KEYS = [
23
+ "registry domain id",
24
+ "registrar whois server",
25
+ "registrar iana id",
26
+ "registry expiry date"
27
+ ].freeze
28
+
29
+ TRAILER = />>>\s*Last update of (?:the )?WHOIS database.*$/i
30
+
31
+ def applicable?(response)
32
+ return false if response.json?
33
+
34
+ text = response.text.downcase
35
+ SIGNATURE_KEYS.count { |key| text.include?("#{key}:") } >= 2
36
+ end
37
+
38
+ def parse(response)
39
+ record = super
40
+
41
+ # The registry's expiry date is authoritative; only fall back to the
42
+ # registrar's if the registry did not supply one.
43
+ expires = parse_time(record["registry expiry date"]) || record.expires_on
44
+
45
+ Record.new(
46
+ domain: record.domain,
47
+ registry_id: record.registry_id,
48
+ registrar: record.registrar,
49
+ registrar_whois_server: record.registrar_whois_server,
50
+ registrar_url: record.registrar_url,
51
+ registrar_iana_id: record.registrar_iana_id,
52
+ registrant: record.registrant,
53
+ statuses: record.statuses.map { |status| strip_epp_url(status) },
54
+ nameservers: record.nameservers,
55
+ created_on: record.created_on,
56
+ updated_on: record.updated_on,
57
+ expires_on: expires,
58
+ dnssec: record.dnssec,
59
+ contacts: record.contacts,
60
+ fields: record.fields,
61
+ source: name
62
+ )
63
+ end
64
+
65
+ private
66
+
67
+ def extract_pairs(text)
68
+ super(text.sub(TRAILER, ""))
69
+ end
70
+
71
+ # Statuses arrive as "clientTransferProhibited https://icann.org/epp#..." —
72
+ # the URL is documentation, not part of the status.
73
+ def strip_epp_url(status)
74
+ status.split(/\s+/).first
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../availability/patterns"
5
+
6
+ module MonoVM
7
+ module Whois
8
+ module Parser
9
+ # Parses the +Key: value+ shape that nearly every port 43 registry speaks.
10
+ #
11
+ # There is no standard for this format, only a strong convention, so the work
12
+ # is in the disagreements: keys padded with dots to align values, the same field
13
+ # called +Creation Date+, +created+, +registered on+ or +paid-till+, and fields
14
+ # that legitimately repeat (a domain has several nameservers and several EPP
15
+ # statuses). {ALIASES} is where that variation is absorbed; everything else here
16
+ # is mechanical.
17
+ class KeyValue < Base
18
+ # +Key: value+, tolerating padded keys and values that contain colons.
19
+ LINE = /\A(?<key>[^:]{1,64}?)[\s._·-]*:[ \t]*(?<value>.*)\z/
20
+
21
+ # Canonical field name => the key spellings that mean it, most preferred
22
+ # first. Order matters where a record carries two candidates: a gTLD record
23
+ # has both a registry expiry date and the registrar's copy of it, and the
24
+ # registry is authoritative.
25
+ ALIASES = {
26
+ domain: ["domain name", "domain", "ascii", "domain-name", "the domain"],
27
+ registry_id: ["registry domain id"],
28
+ registrar: ["registrar", "sponsoring registrar", "registrar name", "registrar organization"],
29
+ registrar_whois_server: ["registrar whois server", "whois server"],
30
+ registrar_url: ["registrar url", "referral url", "registrar web"],
31
+ registrar_iana_id: ["registrar iana id"],
32
+ registrant: [
33
+ "registrant name", "registrant organization", "registrant organisation",
34
+ "registrant", "holder", "holder name", "organisation", "organization", "org"
35
+ ],
36
+ created_on: [
37
+ "creation date", "created", "created on", "created date", "registered on",
38
+ "registration date", "registered", "domain registration date", "record created",
39
+ "activation date"
40
+ ],
41
+ updated_on: [
42
+ "updated date", "last updated", "last update", "changed", "modified",
43
+ "last modified", "record last updated", "update date"
44
+ ],
45
+ expires_on: [
46
+ "registry expiry date", "expiry date", "expiration date", "expires",
47
+ "expires on", "expire date", "paid-till", "renewal date", "record expires",
48
+ "registrar registration expiration date", "valid until", "expiry"
49
+ ],
50
+ dnssec: ["dnssec", "dnssec signed", "signed"]
51
+ }.freeze
52
+
53
+ # Fields that may appear many times; every occurrence is kept.
54
+ MULTI = {
55
+ statuses: ["domain status", "status", "state", "eppstatus"],
56
+ nameservers: ["name server", "nameserver", "nserver", "ns", "dns", "name servers"]
57
+ }.freeze
58
+
59
+ # Contact blocks, addressed by key prefix: +Admin Email:+, +Tech Name:+.
60
+ CONTACT_ROLES = {
61
+ registrant: %w[registrant],
62
+ admin: ["admin", "administrative contact", "admin contact"],
63
+ tech: ["tech", "technical contact", "tech contact"],
64
+ billing: ["billing", "billing contact"]
65
+ }.freeze
66
+
67
+ CONTACT_ATTRIBUTES = {
68
+ name: "name",
69
+ organization: "organization",
70
+ email: "email",
71
+ phone: "phone",
72
+ country: "country",
73
+ city: "city",
74
+ state: "state/province"
75
+ }.freeze
76
+
77
+ def applicable?(response)
78
+ return false if response.json?
79
+
80
+ # LINE is anchored, so it has to be matched a line at a time — testing it
81
+ # against the whole response fails on anything multi-line, which is every
82
+ # real record.
83
+ response.text.each_line.any? { |line| LINE.match?(line.strip) }
84
+ end
85
+
86
+ def parse(response)
87
+ pairs = extract_pairs(response.text)
88
+
89
+ Record.new(**attributes_from(pairs), fields: flatten(pairs), source: name)
90
+ end
91
+
92
+ private
93
+
94
+ # {ALIASES}'s keys are deliberately {Record}'s keyword names, so resolving the
95
+ # single-valued fields is one transform rather than a line each.
96
+ def attributes_from(pairs)
97
+ resolved = ALIASES.transform_values { |keys| first(pairs, keys) }
98
+
99
+ resolved.merge(
100
+ statuses: all(pairs, MULTI[:statuses]),
101
+ nameservers: all(pairs, MULTI[:nameservers]),
102
+ created_on: parse_time(resolved[:created_on]),
103
+ updated_on: parse_time(resolved[:updated_on]),
104
+ expires_on: parse_time(resolved[:expires_on]),
105
+ contacts: extract_contacts(pairs)
106
+ )
107
+ end
108
+
109
+ # @return [Hash{String => Array<String>}] normalised key => every value seen
110
+ def extract_pairs(text)
111
+ text.split("\n").each_with_object({}) do |raw_line, pairs|
112
+ line = raw_line.strip
113
+ next if line.empty? || comment?(line)
114
+
115
+ match = LINE.match(line)
116
+ next if match.nil?
117
+
118
+ key = normalise_key(match[:key])
119
+ value = match[:value].strip
120
+ next if key.empty? || blank?(value)
121
+
122
+ (pairs[key] ||= []) << value
123
+ end
124
+ end
125
+
126
+ def comment?(line)
127
+ Availability::Patterns::COMMENT_PREFIXES.any? { |prefix| line.start_with?(prefix) }
128
+ end
129
+
130
+ # Strip alignment padding, collapse inner whitespace, downcase.
131
+ def normalise_key(key)
132
+ key.strip.sub(/[\s.·]+\z/, "").gsub(/\s+/, " ").downcase
133
+ end
134
+
135
+ def first(pairs, keys)
136
+ keys.each do |key|
137
+ values = pairs[key]
138
+ return values.first if values && !values.empty?
139
+ end
140
+
141
+ nil
142
+ end
143
+
144
+ def all(pairs, keys)
145
+ keys.flat_map { |key| pairs[key] || [] }
146
+ end
147
+
148
+ def flatten(pairs)
149
+ pairs.transform_values { |values| values.length == 1 ? values.first : values }
150
+ end
151
+
152
+ def extract_contacts(pairs)
153
+ CONTACT_ROLES.each_with_object({}) do |(role, prefixes), contacts|
154
+ details = contact_details(pairs, prefixes)
155
+ contacts[role] = details unless details.empty?
156
+ end
157
+ end
158
+
159
+ def contact_details(pairs, prefixes)
160
+ CONTACT_ATTRIBUTES.each_with_object({}) do |(attribute, suffix), details|
161
+ keys = prefixes.map { |prefix| "#{prefix} #{suffix}" }
162
+ value = first(pairs, keys)
163
+ details[attribute] = value unless value.nil?
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Parser
8
+ # Parses an RDAP domain object (RFC 9083).
9
+ #
10
+ # RDAP is where structured data was the point, so this parser reads fields
11
+ # instead of guessing at prose. Two parts of the format still need translating:
12
+ # dates live in an +events+ array keyed by +eventAction+ rather than in named
13
+ # fields, and contacts are jCard/vCard arrays (RFC 7095) — a nested array of
14
+ # +[name, params, type, value]+ tuples that has to be walked to find an email.
15
+ class RdapJson < Base
16
+ # eventAction values mapped to the record fields they populate.
17
+ EVENTS = {
18
+ "registration" => :created_on,
19
+ "last changed" => :updated_on,
20
+ "last update of rdap database" => nil, # about the database, not the domain
21
+ "expiration" => :expires_on
22
+ }.freeze
23
+
24
+ ROLES = {
25
+ "registrant" => :registrant,
26
+ "administrative" => :admin,
27
+ "technical" => :tech,
28
+ "billing" => :billing,
29
+ "registrar" => :registrar
30
+ }.freeze
31
+
32
+ # jCard property name => the record field it populates. +adr+ is absent because
33
+ # its value is a structured array rather than a plain string.
34
+ VCARD_FIELDS = { "fn" => :fn, "org" => :org, "email" => :email, "tel" => :tel }.freeze
35
+
36
+ def applicable?(response)
37
+ document = response.json
38
+ return false if document.nil?
39
+
40
+ document.key?("objectClassName") || document.key?("ldhName") ||
41
+ document.key?("rdapConformance")
42
+ end
43
+
44
+ def parse(response)
45
+ document = response.json || {}
46
+ events = extract_events(document)
47
+ entities = extract_entities(document)
48
+ registrar = entities[:registrar] || {}
49
+
50
+ Record.new(
51
+ domain: document["unicodeName"] || document["ldhName"],
52
+ registry_id: document["handle"],
53
+ registrar: registrar[:organization] || registrar[:name],
54
+ registrar_iana_id: registrar[:iana_id],
55
+ registrant: entities.dig(:registrant, :organization) || entities.dig(:registrant, :name),
56
+ statuses: Array(document["status"]),
57
+ nameservers: extract_nameservers(document),
58
+ created_on: events[:created_on],
59
+ updated_on: events[:updated_on],
60
+ expires_on: events[:expires_on],
61
+ dnssec: extract_dnssec(document),
62
+ contacts: entities.except(:registrar),
63
+ fields: document,
64
+ source: name
65
+ )
66
+ end
67
+
68
+ private
69
+
70
+ def extract_events(document)
71
+ Array(document["events"]).each_with_object({}) do |event, dates|
72
+ next unless event.is_a?(Hash)
73
+
74
+ field = EVENTS[event["eventAction"].to_s.downcase]
75
+ next if field.nil?
76
+
77
+ dates[field] ||= parse_time(event["eventDate"])
78
+ end
79
+ end
80
+
81
+ def extract_nameservers(document)
82
+ Array(document["nameservers"]).filter_map do |nameserver|
83
+ next unless nameserver.is_a?(Hash)
84
+
85
+ nameserver["unicodeName"] || nameserver["ldhName"]
86
+ end
87
+ end
88
+
89
+ # +secureDNS.delegationSigned+ is the authoritative flag; report it in the
90
+ # same vocabulary the WHOIS parsers use so callers need not special-case RDAP.
91
+ def extract_dnssec(document)
92
+ secure = document["secureDNS"]
93
+ return nil unless secure.is_a?(Hash)
94
+
95
+ signed = secure["delegationSigned"]
96
+ return nil if signed.nil?
97
+
98
+ signed ? "signedDelegation" : "unsigned"
99
+ end
100
+
101
+ def extract_entities(document)
102
+ Array(document["entities"]).each_with_object({}) do |entity, contacts|
103
+ next unless entity.is_a?(Hash)
104
+
105
+ Array(entity["roles"]).each do |raw_role|
106
+ role = ROLES[raw_role.to_s.downcase]
107
+ next if role.nil?
108
+
109
+ contacts[role] ||= entity_details(entity)
110
+ end
111
+ end
112
+ end
113
+
114
+ def entity_details(entity)
115
+ card = parse_vcard(entity["vcardArray"])
116
+
117
+ {
118
+ handle: entity["handle"],
119
+ name: card[:fn],
120
+ organization: card[:org],
121
+ email: card[:email],
122
+ phone: card[:tel],
123
+ country: card[:country],
124
+ iana_id: public_id(entity, "IANA Registrar ID")
125
+ }.compact
126
+ end
127
+
128
+ # A jCard is +["vcard", [[name, params, type, value], ...]]+ (RFC 7095).
129
+ # Addresses carry a structured value whose seventh element is the country.
130
+ def parse_vcard(vcard_array)
131
+ entries = vcard_array.is_a?(Array) ? vcard_array[1] : nil
132
+ return {} unless entries.is_a?(Array)
133
+
134
+ entries.each_with_object({}) do |entry, card|
135
+ next unless entry.is_a?(Array) && entry.length >= 4
136
+
137
+ key = entry[0].to_s.downcase
138
+ field = VCARD_FIELDS[key]
139
+
140
+ if field
141
+ card[field] ||= field == :org ? flatten_value(entry[3]) : entry[3].to_s
142
+ elsif key == "adr"
143
+ card[:country] ||= country_from(entry[3])
144
+ end
145
+ end
146
+ end
147
+
148
+ def flatten_value(value)
149
+ value.is_a?(Array) ? value.flatten.compact.reject(&:empty?).first.to_s : value.to_s
150
+ end
151
+
152
+ def country_from(value)
153
+ return nil unless value.is_a?(Array)
154
+
155
+ country = value[6]
156
+ country.to_s.strip.empty? ? nil : country.to_s.strip
157
+ end
158
+
159
+ def public_id(entity, type)
160
+ Array(entity["publicIds"]).each do |public_id|
161
+ next unless public_id.is_a?(Hash)
162
+ return public_id["identifier"].to_s if public_id["type"].to_s.casecmp?(type)
163
+ end
164
+
165
+ nil
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end