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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- 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
|