active_sanction 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 (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. metadata +236 -0
@@ -0,0 +1,116 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ class Fetcher
8
+ # What one conditional fetch came back with, in the terms a caller cares
9
+ # about: did the list change, and if not, may we keep what we have.
10
+ #
11
+ # result = fetcher.fetch(url, key: :ofac_sdn)
12
+ # return storage.latest(:ofac_sdn) if result.unchanged?
13
+ #
14
+ # parse(result.body)
15
+ #
16
+ # Three outcomes, not two. `changed?` is a fresh payload to parse,
17
+ # `unchanged?` is the publisher confirming the copy already held, and
18
+ # `failed?` is any status that is neither -- a 403 for a missing header, a
19
+ # 404 for a retired URL, a 500 the retries could not get past. Sync
20
+ # orchestration (#34) needs to tell the second from the third: one source
21
+ # answering 304 while another answers 500 is a successful run with one
22
+ # failure in it, not a run that transferred nothing.
23
+ #
24
+ # `body` is nil for both an unchanged result -- a 304 has none by
25
+ # definition -- and a streamed download, where the bytes went to disk.
26
+ #
27
+ # Instances are frozen on construction.
28
+ class Result
29
+ extend T::Sig
30
+
31
+ # `key` is what the validators were filed under, `url` the URL asked for.
32
+ # `validators` is what is now filed there: after a 304 the previous
33
+ # record with its clock moved forward, after a 200 the publisher's new
34
+ # ETag and Last-Modified, after a failure whatever was already stored --
35
+ # a bad afternoon at a file server teaches us nothing. It is nil only
36
+ # when nothing is stored, which for a 200 means a publisher that served
37
+ # no validators at all, and so a full download again next time.
38
+ sig { returns(T.untyped) }
39
+ attr_reader :key
40
+
41
+ sig { returns(T.untyped) }
42
+ attr_reader :url
43
+
44
+ sig { returns(HttpClient::Response) }
45
+ attr_reader :response
46
+
47
+ sig { returns(T.nilable(Validators)) }
48
+ attr_reader :validators
49
+
50
+ sig do
51
+ params(key: T.untyped, url: T.untyped, response: HttpClient::Response,
52
+ validators: T.nilable(Validators)).void
53
+ end
54
+ def initialize(key:, url:, response:, validators: nil)
55
+ @key = T.let(key, T.untyped)
56
+ @url = T.let(url, T.untyped)
57
+ @response = T.let(response, HttpClient::Response)
58
+ @validators = T.let(validators, T.nilable(Validators))
59
+ freeze
60
+ end
61
+
62
+ sig { returns(T::Boolean) }
63
+ def unchanged? = response.not_modified?
64
+
65
+ sig { returns(T::Boolean) }
66
+ def changed? = response.success?
67
+
68
+ sig { returns(T::Boolean) }
69
+ def failed? = !changed? && !unchanged?
70
+
71
+ sig { returns(Integer) }
72
+ def status = response.status
73
+
74
+ sig { returns(T.nilable(String)) }
75
+ def body = response.body
76
+
77
+ sig { returns(URI::Generic) }
78
+ def uri = response.uri
79
+
80
+ sig { returns(T.nilable(String)) }
81
+ def etag = response.etag
82
+
83
+ sig { returns(T.nilable(String)) }
84
+ def last_modified = response.last_modified
85
+
86
+ sig { params(name: T.untyped).returns(T.nilable(String)) }
87
+ def [](name) = response[name]
88
+
89
+ # True when the publisher answered at all -- 200 or 304. What a caller
90
+ # checks before deciding a sync succeeded, as against what it checks
91
+ # before deciding there is anything new to parse.
92
+ sig { returns(T::Boolean) }
93
+ def ok? = changed? || unchanged?
94
+
95
+ # For callers that want a failed fetch to be fatal. A 304 passes here,
96
+ # unlike Response#success!, because an unchanged list is the outcome this
97
+ # whole mechanism exists to produce.
98
+ sig { returns(T.self_type) }
99
+ def success!
100
+ return self if ok?
101
+
102
+ response.success!
103
+ self
104
+ end
105
+
106
+ sig { returns(String) }
107
+ def to_s = "#{status} #{url}#{" (unchanged)" if unchanged?}"
108
+
109
+ sig { returns(String) }
110
+ def inspect
111
+ bytes = body
112
+ "#<#{self.class} #{key} #{status}#{" unchanged" if unchanged?}#{" body=#{bytes.bytesize}B" if bytes}>"
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,242 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/error"
7
+ require "active_sanction/http_client"
8
+ require "active_sanction/validators"
9
+ require "active_sanction/validator_store"
10
+ require "active_sanction/fetcher/result"
11
+
12
+ module ActiveSanction
13
+ # An HttpClient that remembers what it fetched last time.
14
+ #
15
+ # fetcher = ActiveSanction::Fetcher.new
16
+ # result = fetcher.fetch(url, key: :ofac_sdn)
17
+ #
18
+ # result.unchanged? # publisher answered 304; keep the snapshot we have
19
+ # result.changed? # a new payload to parse
20
+ #
21
+ # Every launch source serves both `ETag` and `Last-Modified`, verified live:
22
+ #
23
+ # OFAC SDN.CSV "0953154d0fb5aff918c5ec1daf6e9c0e"
24
+ # UN consolidated.xml "0x8DF0558663FC719"
25
+ # Canada sema-lmes "0bf613fb33dd1:0"
26
+ #
27
+ # and they publish changes daily at most. So a sync that runs hourly should
28
+ # move tens of megabytes once a day and three empty 304 responses the other
29
+ # twenty-three times. That is the whole purpose of this class: it holds the
30
+ # validators, sends them, and reports "unchanged" in a form a caller can act
31
+ # on without inspecting a status code.
32
+ #
33
+ # The saving is not only bandwidth. Skipping the download also skips the
34
+ # parse, which for OFAC's SDN join (#18) is the expensive half.
35
+ #
36
+ # What it deliberately does not do is decide what "unchanged" means for the
37
+ # application. It reuses no snapshot and returns no cached body -- storage
38
+ # (#23, #24) owns those, and a fetch layer that quietly handed back a
39
+ # previous payload would make it impossible to tell a list that did not
40
+ # change from a sync that did not run.
41
+ #
42
+ # @api private
43
+ class Fetcher
44
+ extend T::Sig
45
+
46
+ # Sent by us unless the caller sent its own. A caller doing its own
47
+ # conditional request -- a range fetch, a probe against a mirror -- has a
48
+ # reason we do not know, and layering a stored ETag on top of it would
49
+ # produce a request neither side meant.
50
+ CONDITIONAL_HEADERS = T.let(%w[if-none-match if-modified-since].freeze, T::Array[String])
51
+
52
+ sig { returns(HttpClient) }
53
+ attr_reader :client
54
+
55
+ # Any store answering the ValidatorStore contract -- see #initialize.
56
+ sig { returns(T.untyped) }
57
+ attr_reader :store
58
+
59
+ # Seconds, or nil to disable the staleness clock -- see #stale?.
60
+ sig { returns(T.nilable(Numeric)) }
61
+ attr_reader :stale_after
62
+
63
+ # Anything Logger-shaped, or nil, as Configuration#logger has it.
64
+ sig { returns(T.untyped) }
65
+ attr_reader :logger
66
+
67
+ # The store defaults to disk, so the second run of a cron job benefits and
68
+ # not merely the second call in one process. A caller that would rather
69
+ # keep nothing between runs passes ValidatorStore::Memory.new.
70
+ sig do
71
+ params(client: HttpClient, store: T.untyped, stale_after: T.nilable(Numeric), logger: T.untyped).void
72
+ end
73
+ def initialize(client: HttpClient.new,
74
+ store: ValidatorStore::FileSystem.new,
75
+ stale_after: ActiveSanction.config.stale_after,
76
+ logger: ActiveSanction.config.logger)
77
+ @client = T.let(client, HttpClient)
78
+ @store = T.let(store, T.untyped)
79
+ @stale_after = T.let(stale_after, T.nilable(Numeric))
80
+ @logger = T.let(logger, T.untyped)
81
+ end
82
+
83
+ # Fetches conditionally and buffers the body, like HttpClient#get.
84
+ #
85
+ # `key` is what the validators are filed under, defaulting to the URL. A
86
+ # source adapter (#12) passes its own name instead, so that a publisher
87
+ # moving a file changes which URL is fetched without orphaning the record
88
+ # of what was fetched -- the stored URL is compared before its validators
89
+ # are used, and a moved file downloads in full exactly once.
90
+ #
91
+ # `force: true` sends no validators, so the publisher has no way to answer
92
+ # 304. For the operator who suspects the cached copy is wrong and wants the
93
+ # bytes regardless of what the ETag says.
94
+ sig do
95
+ params(url: T.untyped, key: T.untyped, force: T::Boolean, headers: T::Hash[T.untyped, T.untyped])
96
+ .returns(Result)
97
+ end
98
+ def fetch(url, key: url, force: false, headers: {})
99
+ conditional(url, key, force, headers) { |request| client.get(url, headers: request) }
100
+ end
101
+
102
+ # Streams conditionally to disk, like HttpClient#download. A 304 writes
103
+ # nothing: HttpClient only streams a 2xx body, so the file already at `to:`
104
+ # is left exactly as the last download left it.
105
+ sig do
106
+ params(url: T.untyped, to: T.untyped, key: T.untyped, force: T::Boolean,
107
+ headers: T::Hash[T.untyped, T.untyped]).returns(Result)
108
+ end
109
+ def download(url, to:, key: url, force: false, headers: {})
110
+ conditional(url, key, force, headers) { |request| client.download(url, to: to, headers: request) }
111
+ end
112
+
113
+ # Whether a sync is due, answered locally and without a request.
114
+ #
115
+ # True when the key has never been fetched, when its validators were stored
116
+ # against a different URL, or when nothing has confirmed the copy within
117
+ # `stale_after` (default 24 hours; nil disables the clock). It is a
118
+ # scheduling predicate -- what #36's `sources` command prints, and what a
119
+ # caller checks before deciding to spend a round-trip -- not a claim about
120
+ # the publisher's current file. Only a fetch can make that claim, and a
121
+ # cheap #fetch that comes back `unchanged?` is how to ask for it.
122
+ sig { params(key: T.untyped, url: T.untyped).returns(T::Boolean) }
123
+ def stale?(key, url: nil)
124
+ stored = store[key]
125
+ return true if stored.nil? || stored.empty?
126
+ return true if url && !stored.for?(url)
127
+
128
+ after = stale_after
129
+ return false if after.nil?
130
+
131
+ Time.now - stored.checked_at >= after.to_f
132
+ end
133
+
134
+ sig { params(key: T.untyped, url: T.untyped).returns(T::Boolean) }
135
+ def fresh?(key, url: nil) = !stale?(key, url: url)
136
+
137
+ # What is stored for a key, or nil. Mostly for a CLI that wants to print
138
+ # when a source was last confirmed.
139
+ sig { params(key: T.untyped).returns(T.nilable(Validators)) }
140
+ def validators(key) = store[key]
141
+
142
+ # Drops a key's validators, so the next fetch downloads in full. The
143
+ # supported way to do what deleting the store file does for every source at
144
+ # once.
145
+ sig { params(key: T.untyped).returns(T.untyped) }
146
+ def forget(key) = store.delete(key)
147
+
148
+ private
149
+
150
+ sig do
151
+ params(url: T.untyped, key: T.untyped, force: T::Boolean, headers: T::Hash[T.untyped, T.untyped],
152
+ block: T.proc.params(request: T::Hash[T.untyped, T.untyped]).returns(HttpClient::Response))
153
+ .returns(Result)
154
+ end
155
+ def conditional(url, key, force, headers, &block)
156
+ stored = force ? nil : usable(key, url)
157
+ log_request(key, url, stored, force)
158
+ response = block.call(merge(headers, stored))
159
+ record(key, url, stored, response)
160
+ end
161
+
162
+ # Validators stored against a different URL are not merely useless, they
163
+ # are dangerous: a 304 from the new address would say "the file you have is
164
+ # current" about a file that came from somewhere else.
165
+ sig { params(key: T.untyped, url: T.untyped).returns(T.nilable(Validators)) }
166
+ def usable(key, url)
167
+ stored = store[key]
168
+ return nil if stored.nil? || stored.empty?
169
+ return stored if stored.for?(url)
170
+
171
+ logger&.info("[active_sanction] #{key} moved from #{stored.url} to #{url}; fetching in full")
172
+ nil
173
+ end
174
+
175
+ sig do
176
+ params(headers: T::Hash[T.untyped, T.untyped], stored: T.nilable(Validators))
177
+ .returns(T::Hash[T.untyped, T.untyped])
178
+ end
179
+ def merge(headers, stored)
180
+ return headers.to_h if stored.nil?
181
+
182
+ supplied = headers.to_h.keys.map { |name| name.to_s.downcase }
183
+ return headers.to_h if CONDITIONAL_HEADERS.any? { |name| supplied.include?(name) }
184
+
185
+ stored.request_headers.merge(headers.to_h)
186
+ end
187
+
188
+ # Only an answer the publisher stands behind updates the store. A 500 that
189
+ # outlived its retries, or a 403, says nothing about whether the list
190
+ # changed, and letting one clear the validators would turn a bad afternoon
191
+ # at a government file server into a full re-download of every list.
192
+ sig do
193
+ params(key: T.untyped, url: T.untyped, stored: T.nilable(Validators),
194
+ response: HttpClient::Response).returns(Result)
195
+ end
196
+ def record(key, url, stored, response)
197
+ learned = validators_for(url, stored, response)
198
+ store[key] = learned if learned
199
+ log_response(key, response, learned)
200
+ Result.new(key: key, url: url, response: response, validators: store[key])
201
+ end
202
+
203
+ sig do
204
+ params(url: T.untyped, stored: T.nilable(Validators), response: HttpClient::Response)
205
+ .returns(T.nilable(Validators))
206
+ end
207
+ def validators_for(url, stored, response)
208
+ if response.not_modified?
209
+ # A caller may have sent its own conditional headers, in which case a
210
+ # 304 arrives with nothing stored behind it; the response still says
211
+ # what the current validators are.
212
+ stored ? stored.confirmed_by(response) : Validators.from_response(response, url: url)
213
+ elsif response.success?
214
+ Validators.from_response(response, url: url)
215
+ end
216
+ end
217
+
218
+ sig { params(key: T.untyped, url: T.untyped, stored: T.nilable(Validators), force: T::Boolean).void }
219
+ def log_request(key, url, stored, force)
220
+ return unless logger
221
+
222
+ logger.info("[active_sanction] fetching #{key} #{url}#{" (forced)" if force}" \
223
+ "#{" if-none-match=#{stored.etag}" if stored&.etag}")
224
+ end
225
+
226
+ # The line an operator greps for to answer "did last night's sync actually
227
+ # transfer anything?".
228
+ sig { params(key: T.untyped, response: HttpClient::Response, learned: T.nilable(Validators)).void }
229
+ def log_response(key, response, learned)
230
+ return unless logger
231
+
232
+ if response.not_modified?
233
+ logger.info("[active_sanction] #{key} 304 Not Modified; unchanged since " \
234
+ "#{learned&.updated_at&.iso8601 || "the last download"}")
235
+ elsif response.success?
236
+ logger.info("[active_sanction] #{key} #{response.status} changed; etag=#{response.etag.inspect}")
237
+ else
238
+ logger.info("[active_sanction] #{key} #{response.status}; keeping stored validators")
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,94 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/error"
7
+
8
+ module ActiveSanction
9
+ class HttpClient
10
+ # Base for every failure this client raises, all of them cases where the
11
+ # server never produced a status to hand back. Statuses it *did* produce --
12
+ # 403, 500, 304 -- come back as a Response instead; see Response.
13
+ #
14
+ # A FetchError, so a caller backing off on "the bytes could not be
15
+ # obtained" does not have to know that this library speaks `net/http`. The
16
+ # subclasses below split it further only where the fix differs.
17
+ #
18
+ # @api public
19
+ class Error < FetchError; end
20
+
21
+ # The connection or a read exceeded its timeout, and retries did not save
22
+ # it. Kept distinct from ConnectionError because it is the failure that
23
+ # usually means "the publisher is slow today", not "the URL is wrong".
24
+ #
25
+ # @api public
26
+ class TimeoutError < Error
27
+ extend T::Sig
28
+
29
+ # No status to reason from, and the answer is still yes: a government
30
+ # file server that timed out this afternoon serves the same file
31
+ # tomorrow morning.
32
+ sig { returns(T::Boolean) }
33
+ def retryable? = retryable_or(true)
34
+ end
35
+
36
+ # The request never completed: DNS failure, refused or reset connection,
37
+ # TLS failure.
38
+ #
39
+ # @api public
40
+ class ConnectionError < Error
41
+ extend T::Sig
42
+
43
+ sig { returns(T::Boolean) }
44
+ def retryable? = retryable_or(true)
45
+ end
46
+
47
+ # The redirect chain exceeded `max_redirects` without reaching a body. A
48
+ # cap rather than an unbounded follow, because a publisher misconfiguring
49
+ # a redirect should cost one request too many and not a crawl.
50
+ #
51
+ # Not retryable, and neither are the two below: a misrouted URL is routed
52
+ # the same way on the next attempt, and this is the shape of failure that
53
+ # needs somebody to look at where the publisher moved the file to.
54
+ #
55
+ # @api public
56
+ class TooManyRedirects < Error; end
57
+
58
+ # A redirect chain that returns to a URL already visited. It would trip the
59
+ # hop cap on its own, but a loop and a genuinely long chain call for
60
+ # different fixes, so they get different errors.
61
+ #
62
+ # @api public
63
+ class RedirectLoop < Error; end
64
+
65
+ # A `Location` that cannot be resolved, or that leaves HTTP entirely. A
66
+ # sanctions file served over `ftp://` is a sign something is wrong upstream,
67
+ # not an opportunity to be accommodating.
68
+ #
69
+ # @api public
70
+ class InvalidRedirect < Error; end
71
+
72
+ # Raised by Response#success! for a status the caller declared fatal. It
73
+ # carries the response, so a rescuer can still log what came back, and the
74
+ # status, so `retryable?` answers from it without anybody unwrapping the
75
+ # response to look: a 503 is retryable, a 403 for a missing User-Agent is
76
+ # not. See FetchError::RETRYABLE_STATUSES.
77
+ #
78
+ # @api public
79
+ class ResponseError < Error
80
+ extend T::Sig
81
+
82
+ sig { returns(Response) }
83
+ attr_reader :response
84
+
85
+ # Which list this was is stamped on afterwards, by the adapter -- see
86
+ # Error#in_source. This layer has a URL and no idea what is behind it.
87
+ sig { params(response: Response).void }
88
+ def initialize(response)
89
+ @response = T.let(response, Response)
90
+ super("#{response.uri} returned #{response.status}", status: response.status)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,129 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ class HttpClient
8
+ # What a server said, after redirects were followed and retries exhausted.
9
+ #
10
+ # Any status the server actually produced comes back as one of these
11
+ # rather than as an exception, including 403 and 500. The fetch layer has
12
+ # to reason about statuses -- conditional GET (#10) treats 304 as
13
+ # success-unchanged, sync orchestration (#34) isolates a single source's
14
+ # failure rather than aborting the run -- and a client that raised on
15
+ # everything but 200 would force each of them to rescue what it wanted
16
+ # back. Failures the server never got to answer (timeouts, refused
17
+ # connections, redirect loops) do raise: there is no status to hand over.
18
+ #
19
+ # `body` is nil for a streamed download, where the bytes went to disk
20
+ # instead, and for a 304, which has none by definition.
21
+ #
22
+ # Instances are frozen on construction.
23
+ #
24
+ # @api private
25
+ class Response
26
+ extend T::Sig
27
+
28
+ # `uri` is where the response finally came from, which is not the URL
29
+ # asked for when redirects were followed, and `redirects` is the hops it
30
+ # took to get there. #11 stores both beside the cached payload: a bug
31
+ # report about an OFAC download is much easier to read when it names the
32
+ # blob-storage host that actually served the bytes.
33
+ sig { returns(Integer) }
34
+ attr_reader :status
35
+
36
+ # Downcased, and each value joined the way the wire format does -- see
37
+ # #normalize.
38
+ sig { returns(T::Hash[String, String]) }
39
+ attr_reader :headers
40
+
41
+ # nil for a streamed download, where the bytes went to disk, and for a
42
+ # 304, which has none by definition.
43
+ sig { returns(T.nilable(String)) }
44
+ attr_reader :body
45
+
46
+ sig { returns(URI::Generic) }
47
+ attr_reader :uri
48
+
49
+ sig { returns(T::Array[URI::Generic]) }
50
+ attr_reader :redirects
51
+
52
+ sig do
53
+ params(status: T.untyped, headers: T.untyped, uri: URI::Generic, body: T.nilable(String),
54
+ redirects: T::Array[URI::Generic]).void
55
+ end
56
+ def initialize(status:, headers:, uri:, body: nil, redirects: [])
57
+ @status = T.let(Integer(status), Integer)
58
+ @headers = T.let(normalize(headers), T::Hash[String, String])
59
+ @body = T.let(body, T.nilable(String))
60
+ @uri = T.let(uri.freeze, URI::Generic)
61
+ @redirects = T.let(redirects.dup.freeze, T::Array[URI::Generic])
62
+ freeze
63
+ end
64
+
65
+ # Header lookup is case-insensitive because HTTP header names are, and
66
+ # because the same header reaches us capitalized differently depending on
67
+ # which CDN a publisher put in front of its file this quarter.
68
+ sig { params(name: T.untyped).returns(T.nilable(String)) }
69
+ def [](name)
70
+ headers[name.to_s.downcase]
71
+ end
72
+
73
+ sig { returns(T::Boolean) }
74
+ def success? = status.between?(200, 299)
75
+
76
+ sig { returns(T::Boolean) }
77
+ def not_modified? = status == 304
78
+
79
+ sig { returns(T::Boolean) }
80
+ def redirect? = REDIRECT_STATUSES.include?(status)
81
+
82
+ sig { returns(T::Boolean) }
83
+ def client_error? = status.between?(400, 499)
84
+
85
+ sig { returns(T::Boolean) }
86
+ def server_error? = status.between?(500, 599)
87
+
88
+ sig { returns(T.nilable(String)) }
89
+ def content_type = self["content-type"]
90
+
91
+ sig { returns(T.nilable(String)) }
92
+ def etag = self["etag"]
93
+
94
+ sig { returns(T.nilable(String)) }
95
+ def last_modified = self["last-modified"]
96
+
97
+ # For callers that want a non-2xx to be fatal without writing the check
98
+ # themselves. Deliberately not the default path -- see the class comment.
99
+ sig { returns(T.self_type) }
100
+ def success!
101
+ return self if success?
102
+
103
+ raise ResponseError, self
104
+ end
105
+
106
+ sig { returns(String) }
107
+ def to_s = "#{status} #{uri}"
108
+
109
+ sig { returns(String) }
110
+ def inspect
111
+ bytes = body
112
+ "#<#{self.class} #{status} #{uri}#{" body=#{bytes.bytesize}B" if bytes}>"
113
+ end
114
+
115
+ private
116
+
117
+ # Net::HTTPResponse hands back each header as an array of values, since
118
+ # a header may legally repeat. Repeats are vanishingly rare on the ones
119
+ # this library reads, so they are joined the way the wire format does it
120
+ # and the caller sees a plain string.
121
+ sig { params(headers: T.untyped).returns(T::Hash[String, String]) }
122
+ def normalize(headers)
123
+ headers.to_h do |name, value|
124
+ [-name.to_s.downcase, value.is_a?(Array) ? value.join(", ") : value.to_s]
125
+ end.freeze
126
+ end
127
+ end
128
+ end
129
+ end