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,721 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/error"
|
|
7
|
+
require "active_sanction/normalizer"
|
|
8
|
+
require "active_sanction/scorer/weights"
|
|
9
|
+
require "active_sanction/version"
|
|
10
|
+
|
|
11
|
+
module ActiveSanction
|
|
12
|
+
# Library-wide settings, set once at boot:
|
|
13
|
+
#
|
|
14
|
+
# ActiveSanction.configure do |c|
|
|
15
|
+
# c.user_agent = "my-app/1.0 (compliance@example.com)"
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# The fetch layer's settings live here, plus which sources a sync runs,
|
|
19
|
+
# where they are stored, and the thresholds a screening call defaults to.
|
|
20
|
+
# Every value has a working default, so an application that configures
|
|
21
|
+
# nothing still runs -- the point of `configure` is that a caller *can*
|
|
22
|
+
# identify itself, not that it must recite the whole schema.
|
|
23
|
+
class Configuration
|
|
24
|
+
extend T::Sig
|
|
25
|
+
|
|
26
|
+
# OFAC returns 403 to a request with no User-Agent, so this cannot be nil.
|
|
27
|
+
# The default identifies the library and points at its source, which is
|
|
28
|
+
# what a publisher watching its logs actually wants; applications should
|
|
29
|
+
# still override it with their own contact address, because a publisher
|
|
30
|
+
# that needs to reach whoever is hammering an endpoint can only reach the
|
|
31
|
+
# gem's repository otherwise.
|
|
32
|
+
DEFAULT_USER_AGENT = T.let(
|
|
33
|
+
-"active_sanction/#{VERSION} (+https://github.com/Babystep-Technologies/active_sanction)", String
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Generous by list-download standards: these are government file servers
|
|
37
|
+
# redirecting to blob storage, and a 126 MB XML body arrives in bursts with
|
|
38
|
+
# real gaps between them. The read timeout is per-read, not per-request, so
|
|
39
|
+
# it does not cap how long a large download may take overall.
|
|
40
|
+
DEFAULT_OPEN_TIMEOUT = T.let(10, Numeric)
|
|
41
|
+
|
|
42
|
+
# Seconds to wait for the next chunk of a body, not for the whole of one.
|
|
43
|
+
DEFAULT_READ_TIMEOUT = T.let(60, Numeric)
|
|
44
|
+
|
|
45
|
+
# OFAC's download URLs 302 to blob storage, one hop. Five leaves room for
|
|
46
|
+
# a publisher to add a vanity domain or a region redirect without a release
|
|
47
|
+
# of this gem, and still stops a redirect chain from becoming a crawl.
|
|
48
|
+
DEFAULT_MAX_REDIRECTS = T.let(5, Integer)
|
|
49
|
+
|
|
50
|
+
# Three attempts total for a transient failure. Sanctions syncs are batch
|
|
51
|
+
# work with no user waiting on them, but they are also not worth an hour of
|
|
52
|
+
# a government server's patience.
|
|
53
|
+
DEFAULT_MAX_RETRIES = T.let(2, Integer)
|
|
54
|
+
|
|
55
|
+
# Seconds before the first retry, doubling on each one after it.
|
|
56
|
+
DEFAULT_RETRY_BACKOFF = T.let(1.0, Numeric)
|
|
57
|
+
|
|
58
|
+
# Cache validators and raw payloads live under here. The XDG base directory
|
|
59
|
+
# is the right default for data a user is entitled to delete: everything
|
|
60
|
+
# here is recoverable by fetching again, with one honest caveat -- once a
|
|
61
|
+
# publisher overwrites its file the previous payload is gone, whether or
|
|
62
|
+
# not this directory still holds a copy. PayloadCache is a bounded aid to
|
|
63
|
+
# re-parsing and auditing, not the system of record; storage (#24) is.
|
|
64
|
+
#
|
|
65
|
+
# @api private
|
|
66
|
+
XDG_CACHE_HOME = T.let("XDG_CACHE_HOME", String)
|
|
67
|
+
|
|
68
|
+
# The directory this gem takes for itself under whichever cache root wins.
|
|
69
|
+
DEFAULT_CACHE_DIRNAME = T.let("active_sanction", String)
|
|
70
|
+
|
|
71
|
+
# Where Storage::FileSystem (#24) keeps parsed snapshots. Deliberately not
|
|
72
|
+
# under `cache_dir`, and the difference is the whole distinction between
|
|
73
|
+
# the two directories: everything under `~/.cache` is recoverable by
|
|
74
|
+
# fetching again and a user is entitled to delete it, while a stored
|
|
75
|
+
# snapshot is the system of record -- once a publisher overwrites its file,
|
|
76
|
+
# the list version a past decision was screened against exists only here.
|
|
77
|
+
DEFAULT_STORAGE_DIRNAME = T.let(".active_sanction", String)
|
|
78
|
+
|
|
79
|
+
# How many raw payloads PayloadCache keeps per source. Three is enough to
|
|
80
|
+
# diff a suspicious list against the two that came before it, and small
|
|
81
|
+
# enough that a cache directory does not quietly grow by 126 MB a day. An
|
|
82
|
+
# installation that must keep every version it ever screened against wants
|
|
83
|
+
# its own retention storage, not a bigger number here.
|
|
84
|
+
DEFAULT_RETAIN_PAYLOADS = T.let(3, Integer)
|
|
85
|
+
|
|
86
|
+
# How many publishers a sync fetches from at once. One, because these are
|
|
87
|
+
# government file servers with nobody waiting on the result, and a library
|
|
88
|
+
# that opens four connections to Treasury by default is a library that gets
|
|
89
|
+
# a jurisdiction's operators asking who we are. Raising it fetches from
|
|
90
|
+
# more publishers at a time and never harder from any one of them -- Sync
|
|
91
|
+
# groups sources by the host they download from and runs each group in
|
|
92
|
+
# order -- so the number is a bound on how many governments are being asked
|
|
93
|
+
# at once, not on how fast any one of them is asked.
|
|
94
|
+
DEFAULT_SYNC_CONCURRENCY = T.let(1, Integer)
|
|
95
|
+
|
|
96
|
+
# How far one of Doctor's measurements may move from what it was at the
|
|
97
|
+
# last sync before the run says so. A tenth, because these lists move by
|
|
98
|
+
# single-digit percentages between syncs -- a designation round is dozens
|
|
99
|
+
# of records against tens of thousands -- while the changes this is looking
|
|
100
|
+
# for halve a fill rate. Tightening it finds drift sooner and reports more
|
|
101
|
+
# of the movement that is just the list changing; loosening it does the
|
|
102
|
+
# reverse, and a diagnostic nobody reads because it always says something
|
|
103
|
+
# is worse than one that says slightly less.
|
|
104
|
+
DEFAULT_DOCTOR_TOLERANCE = T.let(0.10, Float)
|
|
105
|
+
|
|
106
|
+
# The launch lists change roughly daily at most, so a source last confirmed
|
|
107
|
+
# within a day is not worth asking about again when a caller is only trying
|
|
108
|
+
# to decide whether a sync is due. This is what #stale? measures against;
|
|
109
|
+
# it does not cap how long a cached copy may be used, which is the calling
|
|
110
|
+
# application's policy to set.
|
|
111
|
+
DEFAULT_STALE_AFTER = T.let(86_400, T.nilable(Numeric))
|
|
112
|
+
|
|
113
|
+
# Which XML library the XML toolkit parses with. REXML is stdlib, needs no
|
|
114
|
+
# build step, and -- the part that decides it -- gives every installation
|
|
115
|
+
# the same answer. Snapshot checksums a list's parsed content and a
|
|
116
|
+
# screening decision has to be re-derivable months later, so the parser
|
|
117
|
+
# must not be chosen by whether the host app happened to load Nokogiri.
|
|
118
|
+
#
|
|
119
|
+
# c.xml_backend = :nokogiri # libxml2, for a host parsing OFAC's 126 MB XML
|
|
120
|
+
#
|
|
121
|
+
# See Parsers::XmlRecords::Backends for the contract a backend implements.
|
|
122
|
+
DEFAULT_XML_BACKEND = T.let(:rexml, Symbol)
|
|
123
|
+
|
|
124
|
+
# How many names the index (#31) hands the scorer per query.
|
|
125
|
+
#
|
|
126
|
+
# 200 is where the recall curve flattens on this corpus, and a
|
|
127
|
+
# deliberately damaged query still finds its own name inside the first
|
|
128
|
+
# handful. Raising it buys precision nothing -- the scorer already sees
|
|
129
|
+
# everything that could clear a threshold -- and spends milliseconds a
|
|
130
|
+
# service does not have.
|
|
131
|
+
#
|
|
132
|
+
# What it costs is the scorer's cost, and that depends on the threshold
|
|
133
|
+
# rather than on this number alone: 200 candidates is roughly 16 ms of
|
|
134
|
+
# comparison under YJIT at a threshold of 75 and roughly 46 ms with no
|
|
135
|
+
# threshold at all, because the early exits are what stop the expensive
|
|
136
|
+
# comparisons running on candidates that cannot clear. See
|
|
137
|
+
# Scorer::NameScore, and `rake benchmark:scorer`.
|
|
138
|
+
DEFAULT_CANDIDATE_LIMIT = T.let(200, Integer)
|
|
139
|
+
|
|
140
|
+
# The lowest score a screening call reports, on the scorer's 0..100 scale.
|
|
141
|
+
#
|
|
142
|
+
# 75 is where the scorer's own table separates the two things it has to
|
|
143
|
+
# separate. An inverted name blends to 90.4 and a company named by half
|
|
144
|
+
# its words to 84.2 -- both true matches, both reported. `kim jong un`
|
|
145
|
+
# against `kim yong chol` blends to 54.8, and a query of one common given
|
|
146
|
+
# name against a full listed name lands in the high seventies with nothing
|
|
147
|
+
# but the name agreeing, which is why the identifiers exist and why this
|
|
148
|
+
# is a floor rather than a verdict.
|
|
149
|
+
#
|
|
150
|
+
# It is also most of what a screening call costs. Everything a threshold
|
|
151
|
+
# turns off is a comparison that could not have changed the answer -- see
|
|
152
|
+
# Scorer::NameScore -- so 75 is roughly a third of the work of screening
|
|
153
|
+
# with no threshold at all, and returns the same scores.
|
|
154
|
+
DEFAULT_SCREENING_THRESHOLD = T.let(75.0, Float)
|
|
155
|
+
|
|
156
|
+
# How many results a screening call returns, highest score first.
|
|
157
|
+
#
|
|
158
|
+
# Ten is a review queue rather than a report: a human clears alerts one at
|
|
159
|
+
# a time, and a call that returned every name over the threshold would
|
|
160
|
+
# bury the one that matters under the fifty that share a given name. A
|
|
161
|
+
# caller writing an investigation tool rather than an onboarding check
|
|
162
|
+
# raises it per query.
|
|
163
|
+
DEFAULT_SCREENING_LIMIT = T.let(10, Integer)
|
|
164
|
+
|
|
165
|
+
# Which lists a sync runs, by key. nil means every registered source,
|
|
166
|
+
# which is what an application that has not thought about it should get:
|
|
167
|
+
# requiring an explicit list would mean a gem adding a jurisdiction had no
|
|
168
|
+
# way to take effect without an edit to the host app's initializer.
|
|
169
|
+
DEFAULT_SOURCES = T.let(nil, T.nilable(T::Array[Symbol]))
|
|
170
|
+
|
|
171
|
+
sig { returns(String) }
|
|
172
|
+
attr_reader :user_agent
|
|
173
|
+
|
|
174
|
+
# Seconds. Numeric rather than Integer because the writers run every value
|
|
175
|
+
# through Float(), so a timeout set to 2.5 stays 2.5.
|
|
176
|
+
sig { returns(Numeric) }
|
|
177
|
+
attr_reader :open_timeout
|
|
178
|
+
|
|
179
|
+
sig { returns(Numeric) }
|
|
180
|
+
attr_reader :read_timeout
|
|
181
|
+
|
|
182
|
+
sig { returns(Integer) }
|
|
183
|
+
attr_reader :max_redirects
|
|
184
|
+
|
|
185
|
+
sig { returns(Integer) }
|
|
186
|
+
attr_reader :max_retries
|
|
187
|
+
|
|
188
|
+
sig { returns(Numeric) }
|
|
189
|
+
attr_reader :retry_backoff
|
|
190
|
+
|
|
191
|
+
sig { returns(String) }
|
|
192
|
+
attr_reader :cache_dir
|
|
193
|
+
|
|
194
|
+
sig { returns(String) }
|
|
195
|
+
attr_reader :storage_dir
|
|
196
|
+
|
|
197
|
+
sig { returns(Integer) }
|
|
198
|
+
attr_reader :retain_payloads
|
|
199
|
+
|
|
200
|
+
# nil disables the staleness clock entirely -- see #stale_after=.
|
|
201
|
+
sig { returns(T.nilable(Numeric)) }
|
|
202
|
+
attr_reader :stale_after
|
|
203
|
+
|
|
204
|
+
# nil means every registered source. Keys are not resolved here; see
|
|
205
|
+
# #sources=.
|
|
206
|
+
sig { returns(T.nilable(T::Array[Symbol])) }
|
|
207
|
+
attr_reader :sources
|
|
208
|
+
|
|
209
|
+
sig { returns(Symbol) }
|
|
210
|
+
attr_reader :xml_backend
|
|
211
|
+
|
|
212
|
+
# See DEFAULT_DOCTOR_TOLERANCE. A per-run `tolerance:` overrides it.
|
|
213
|
+
sig { returns(Float) }
|
|
214
|
+
attr_reader :doctor_tolerance
|
|
215
|
+
|
|
216
|
+
# See DEFAULT_SYNC_CONCURRENCY. A per-run `concurrency:` overrides it.
|
|
217
|
+
sig { returns(Integer) }
|
|
218
|
+
attr_reader :sync_concurrency
|
|
219
|
+
|
|
220
|
+
# See DEFAULT_CANDIDATE_LIMIT. A per-query `limit:` overrides it.
|
|
221
|
+
sig { returns(Integer) }
|
|
222
|
+
attr_reader :candidate_limit
|
|
223
|
+
|
|
224
|
+
# See DEFAULT_SCREENING_THRESHOLD. A per-query `threshold:` overrides it.
|
|
225
|
+
sig { returns(Float) }
|
|
226
|
+
attr_reader :screening_threshold
|
|
227
|
+
|
|
228
|
+
# See DEFAULT_SCREENING_LIMIT. A per-query `limit:` overrides it.
|
|
229
|
+
sig { returns(Integer) }
|
|
230
|
+
attr_reader :screening_limit
|
|
231
|
+
|
|
232
|
+
# The token lists the normalizer strips per entity type. Defaults to the
|
|
233
|
+
# shipped ones; see #normalizer_dictionary= and Normalizer::Dictionary.
|
|
234
|
+
sig { returns(Normalizer::Dictionary) }
|
|
235
|
+
attr_reader :normalizer_dictionary
|
|
236
|
+
|
|
237
|
+
# What each signal the scorer reads is worth. See #scorer_weights=.
|
|
238
|
+
sig { returns(Scorer::Weights) }
|
|
239
|
+
attr_reader :scorer_weights
|
|
240
|
+
|
|
241
|
+
# Anything Logger-shaped, which is what #logger= checks for and all this
|
|
242
|
+
# library ever asks of it. Declaring `::Logger` would make a host's
|
|
243
|
+
# wrapper, a Rails logger broadcast or a test spy a type error rather than
|
|
244
|
+
# the perfectly good logger each of them is.
|
|
245
|
+
sig { returns(T.untyped) }
|
|
246
|
+
attr_reader :logger
|
|
247
|
+
|
|
248
|
+
sig { void }
|
|
249
|
+
def initialize
|
|
250
|
+
@user_agent = T.let(DEFAULT_USER_AGENT, String)
|
|
251
|
+
@open_timeout = T.let(DEFAULT_OPEN_TIMEOUT, Numeric)
|
|
252
|
+
@read_timeout = T.let(DEFAULT_READ_TIMEOUT, Numeric)
|
|
253
|
+
@max_redirects = T.let(DEFAULT_MAX_REDIRECTS, Integer)
|
|
254
|
+
@max_retries = T.let(DEFAULT_MAX_RETRIES, Integer)
|
|
255
|
+
@retry_backoff = T.let(DEFAULT_RETRY_BACKOFF, Numeric)
|
|
256
|
+
@cache_dir = T.let(self.class.default_cache_dir, String)
|
|
257
|
+
@storage_dir = T.let(self.class.default_storage_dir, String)
|
|
258
|
+
@retain_payloads = T.let(DEFAULT_RETAIN_PAYLOADS, Integer)
|
|
259
|
+
@stale_after = T.let(DEFAULT_STALE_AFTER, T.nilable(Numeric))
|
|
260
|
+
@sources = T.let(DEFAULT_SOURCES, T.nilable(T::Array[Symbol]))
|
|
261
|
+
@xml_backend = T.let(DEFAULT_XML_BACKEND, Symbol)
|
|
262
|
+
@sync_concurrency = T.let(DEFAULT_SYNC_CONCURRENCY, Integer)
|
|
263
|
+
@doctor_tolerance = T.let(DEFAULT_DOCTOR_TOLERANCE, Float)
|
|
264
|
+
@candidate_limit = T.let(DEFAULT_CANDIDATE_LIMIT, Integer)
|
|
265
|
+
@screening_threshold = T.let(DEFAULT_SCREENING_THRESHOLD, Float)
|
|
266
|
+
@screening_limit = T.let(DEFAULT_SCREENING_LIMIT, Integer)
|
|
267
|
+
@normalizer_dictionary = T.let(Normalizer::Dictionary.default, Normalizer::Dictionary)
|
|
268
|
+
@scorer_weights = T.let(Scorer::Weights.default, Scorer::Weights)
|
|
269
|
+
@logger = T.let(nil, T.untyped)
|
|
270
|
+
@storage = T.let(nil, T.nilable(Storage::Base))
|
|
271
|
+
@default_storage = T.let(nil, T.nilable(Storage::Base))
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# A copy starts unfrozen -- that is Ruby's rule for `dup`, and the reason
|
|
275
|
+
# `with` can derive a mutable configuration from a frozen one -- and drops
|
|
276
|
+
# the store that was derived from `storage_dir`, so a copy that moves the
|
|
277
|
+
# directory reads the directory it names. A store the caller assigned is
|
|
278
|
+
# not derived and is carried over.
|
|
279
|
+
sig { params(other: Configuration).void }
|
|
280
|
+
def initialize_copy(other)
|
|
281
|
+
super
|
|
282
|
+
@default_storage = nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
sig { params(value: T.untyped).void }
|
|
286
|
+
def user_agent=(value)
|
|
287
|
+
@user_agent = self.class.user_agent!(value)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
sig { params(value: T.untyped).void }
|
|
291
|
+
def open_timeout=(value)
|
|
292
|
+
@open_timeout = positive_number!(:open_timeout, value)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
sig { params(value: T.untyped).void }
|
|
296
|
+
def read_timeout=(value)
|
|
297
|
+
@read_timeout = positive_number!(:read_timeout, value)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
sig { params(value: T.untyped).void }
|
|
301
|
+
def max_redirects=(value)
|
|
302
|
+
@max_redirects = non_negative_integer!(:max_redirects, value)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
sig { params(value: T.untyped).void }
|
|
306
|
+
def max_retries=(value)
|
|
307
|
+
@max_retries = non_negative_integer!(:max_retries, value)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
sig { params(value: T.untyped).void }
|
|
311
|
+
def retry_backoff=(value)
|
|
312
|
+
@retry_backoff = positive_number!(:retry_backoff, value)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
sig { params(value: T.untyped).void }
|
|
316
|
+
def cache_dir=(value)
|
|
317
|
+
@cache_dir = -File.expand_path(directory!(:cache_dir, value))
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
sig { params(value: T.untyped).void }
|
|
321
|
+
def storage_dir=(value)
|
|
322
|
+
@storage_dir = -File.expand_path(directory!(:storage_dir, value))
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
sig { params(value: T.untyped).void }
|
|
326
|
+
def retain_payloads=(value)
|
|
327
|
+
@retain_payloads = self.class.retain_payloads!(value)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# `nil` disables the staleness clock entirely: a source with stored
|
|
331
|
+
# validators is then never stale, and the publisher's 304 is the only thing
|
|
332
|
+
# that decides whether a sync did any work.
|
|
333
|
+
sig { params(value: T.untyped).void }
|
|
334
|
+
def stale_after=(value)
|
|
335
|
+
@stale_after = value.nil? ? nil : positive_number!(:stale_after, value)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# The lists to sync, named by the keys their adapters declare:
|
|
339
|
+
#
|
|
340
|
+
# c.sources = %i[ofac_sdn my_internal_watchlist]
|
|
341
|
+
#
|
|
342
|
+
# Keys are not resolved here. An initializer runs before a gem that
|
|
343
|
+
# registers a source may have been required, and rejecting a key at
|
|
344
|
+
# assignment would make the order of an application's requires decide
|
|
345
|
+
# whether its configuration is valid. Sources.enabled resolves them at the
|
|
346
|
+
# start of a run instead, where an unknown key is an error about a typo
|
|
347
|
+
# rather than about load order.
|
|
348
|
+
sig { params(value: T.untyped).void }
|
|
349
|
+
def sources=(value)
|
|
350
|
+
@sources = value.nil? ? nil : source_keys!(value)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Not resolved here, for the reason `sources=` is not: an initializer runs
|
|
354
|
+
# before a gem that registers a backend may have been required, and load
|
|
355
|
+
# order should not decide whether a configuration is valid. XmlRecords
|
|
356
|
+
# resolves the name when it parses, where an unknown one is an error about
|
|
357
|
+
# a typo and lists what is registered.
|
|
358
|
+
sig { params(value: T.untyped).void }
|
|
359
|
+
def xml_backend=(value)
|
|
360
|
+
name = value.to_s.strip
|
|
361
|
+
raise ConfigurationError, "xml_backend cannot be blank" if name.empty?
|
|
362
|
+
|
|
363
|
+
@xml_backend = name.to_sym
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
sig { params(value: T.untyped).void }
|
|
367
|
+
def sync_concurrency=(value)
|
|
368
|
+
@sync_concurrency = self.class.sync_concurrency!(value)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
sig { params(value: T.untyped).void }
|
|
372
|
+
def doctor_tolerance=(value)
|
|
373
|
+
@doctor_tolerance = self.class.doctor_tolerance!(value)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Raising this trades milliseconds for recall and lowering it does the
|
|
377
|
+
# reverse, which is why it is a number a host can set rather than a
|
|
378
|
+
# constant. Zero is refused: an index that returns nothing screens nobody,
|
|
379
|
+
# and a configuration that turns screening off has to be a typo.
|
|
380
|
+
sig { params(value: T.untyped).void }
|
|
381
|
+
def candidate_limit=(value)
|
|
382
|
+
integer = begin
|
|
383
|
+
Integer(value)
|
|
384
|
+
rescue TypeError, ArgumentError
|
|
385
|
+
raise ConfigurationError, "candidate_limit must be a whole number of names, got #{value.inspect}"
|
|
386
|
+
end
|
|
387
|
+
raise ConfigurationError, "candidate_limit must be at least 1, got #{integer}" unless integer.positive?
|
|
388
|
+
|
|
389
|
+
@candidate_limit = integer
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# A Hash adds to the shipped lists, which is what a host almost always
|
|
393
|
+
# wants -- one more legal form its market uses, one more particle its names
|
|
394
|
+
# carry:
|
|
395
|
+
#
|
|
396
|
+
# c.normalizer_dictionary = { legal_forms: %w[OYJ TBK] }
|
|
397
|
+
#
|
|
398
|
+
# A Normalizer::Dictionary replaces them outright, and has to spell out all
|
|
399
|
+
# four lists including the particles. Replacing is the rarer and more
|
|
400
|
+
# dangerous operation -- a set of lists that forgot to carry the preserve
|
|
401
|
+
# list over would strip `al` out of several hundred SDN names -- so it is
|
|
402
|
+
# the one that has to be written out in full.
|
|
403
|
+
#
|
|
404
|
+
# Set this at boot, before anything is folded. A dictionary changed later
|
|
405
|
+
# is honoured from the next call, but names folded under the old one are
|
|
406
|
+
# already in an index (#31) and were already screened against, and the two
|
|
407
|
+
# folds do not compare.
|
|
408
|
+
sig { params(value: T.untyped).void }
|
|
409
|
+
def normalizer_dictionary=(value)
|
|
410
|
+
@normalizer_dictionary = normalizer_dictionary!(value)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# A Hash replaces the numbers it names and leaves the rest, which is what
|
|
414
|
+
# a host tuning one signal wants:
|
|
415
|
+
#
|
|
416
|
+
# c.scorer_weights = { dob_conflict: -20.0, identifier_match: 50.0 }
|
|
417
|
+
#
|
|
418
|
+
# A Scorer::Weights is taken as it stands. Unlike the normalizer's
|
|
419
|
+
# dictionaries there is no partial-replacement hazard here -- a number
|
|
420
|
+
# left out is the shipped one, and the five name shares are checked to sum
|
|
421
|
+
# to 1 whichever way they arrived.
|
|
422
|
+
#
|
|
423
|
+
# Set this at boot. Changing it later is honoured from the next call, and
|
|
424
|
+
# every score already recorded was made under the old numbers; a stored
|
|
425
|
+
# screening decision has to say which set it used, which is what #33's
|
|
426
|
+
# reproducibility stamp is for.
|
|
427
|
+
sig { params(value: T.untyped).void }
|
|
428
|
+
def scorer_weights=(value)
|
|
429
|
+
@scorer_weights = begin
|
|
430
|
+
Scorer::Weights.build(value)
|
|
431
|
+
rescue ArgumentError => e
|
|
432
|
+
raise ConfigurationError, "scorer_weights: #{e.message}"
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# The lowest score a screening call reports, unless a query names its own.
|
|
437
|
+
# Refused outside 0..100 by the scorer's own check, which is what catches
|
|
438
|
+
# a similarity on the 0..1 scale arriving where a percentage was meant.
|
|
439
|
+
sig { params(value: T.untyped).void }
|
|
440
|
+
def screening_threshold=(value)
|
|
441
|
+
number = begin
|
|
442
|
+
Float(value)
|
|
443
|
+
rescue TypeError, ArgumentError
|
|
444
|
+
raise ConfigurationError, "screening_threshold must be a number between 0 and 100, got #{value.inspect}"
|
|
445
|
+
end
|
|
446
|
+
unless number.between?(0.0, 100.0)
|
|
447
|
+
raise ConfigurationError,
|
|
448
|
+
"screening_threshold must be between 0 and 100, got #{value.inspect} -- " \
|
|
449
|
+
"a screening score is a percentage, not a similarity on a 0..1 scale"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
@screening_threshold = number
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# How many results a screening call returns. Zero is refused for the
|
|
456
|
+
# reason `candidate_limit` refuses it: a screening call that can return
|
|
457
|
+
# nothing reports every customer clear.
|
|
458
|
+
sig { params(value: T.untyped).void }
|
|
459
|
+
def screening_limit=(value)
|
|
460
|
+
integer = begin
|
|
461
|
+
Integer(value)
|
|
462
|
+
rescue TypeError, ArgumentError
|
|
463
|
+
raise ConfigurationError, "screening_limit must be a whole number of results, got #{value.inspect}"
|
|
464
|
+
end
|
|
465
|
+
raise ConfigurationError, "screening_limit must be at least 1, got #{integer}" unless integer.positive?
|
|
466
|
+
|
|
467
|
+
@screening_limit = integer
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Where synced lists are read from and written to. Defaults to gzipped
|
|
471
|
+
# JSON under `storage_dir`, which is what makes this library screen a name
|
|
472
|
+
# without an application having provisioned anything first.
|
|
473
|
+
#
|
|
474
|
+
# c.storage = ActiveSanction::Storage::Memory.new
|
|
475
|
+
#
|
|
476
|
+
# Built on first use rather than at boot, because constructing it touches
|
|
477
|
+
# the filesystem and a process that never screens should not pay for a
|
|
478
|
+
# directory it will not read. `Client.new` reads it once on the way to
|
|
479
|
+
# freezing, so a client's store is settled before any thread can race for
|
|
480
|
+
# it -- see #freeze.
|
|
481
|
+
sig { returns(Storage::Base) }
|
|
482
|
+
def storage
|
|
483
|
+
@storage || default_storage
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# A Storage::Base subclass, which is the contract the whole query path is
|
|
487
|
+
# written against -- see Storage::Base, and the conformance group an
|
|
488
|
+
# adapter that is not this is held to.
|
|
489
|
+
sig { params(value: T.untyped).void }
|
|
490
|
+
def storage=(value)
|
|
491
|
+
unless value.is_a?(Storage::Base)
|
|
492
|
+
raise ConfigurationError,
|
|
493
|
+
"storage must be an ActiveSanction::Storage::Base subclass, got #{value.class}"
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
@storage = value
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Every setting a caller may name, which is what `Client.new` and
|
|
500
|
+
# `Configuration#with` accept as keyword arguments and what an unknown one
|
|
501
|
+
# is reported against. Derived from the writers rather than listed, so a
|
|
502
|
+
# setting added below is accepted here without anything remembering to say
|
|
503
|
+
# so twice.
|
|
504
|
+
sig { returns(T::Array[Symbol]) }
|
|
505
|
+
def self.settings
|
|
506
|
+
@settings ||= T.let(
|
|
507
|
+
public_instance_methods(false).grep(/=\z/).map { |name| name.to_s.chomp("=").to_sym }.sort.freeze,
|
|
508
|
+
T.nilable(T::Array[Symbol])
|
|
509
|
+
)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# A copy of these settings with some of them changed:
|
|
513
|
+
#
|
|
514
|
+
# audit = ActiveSanction.config.with(storage: pinned_store, sources: %i[ofac_sdn])
|
|
515
|
+
#
|
|
516
|
+
# The copy is mutable and unfrozen whatever this one is, which is what
|
|
517
|
+
# makes a frozen configuration a value object rather than a dead end: a
|
|
518
|
+
# client derives its neighbour from it instead of rebuilding the schema.
|
|
519
|
+
sig { params(overrides: T.untyped).returns(Configuration) }
|
|
520
|
+
def with(**overrides)
|
|
521
|
+
dup.tap { |copy| copy.apply(**overrides) }
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Assigns through the writers, so a value given to `Client.new` is held to
|
|
525
|
+
# exactly the rule the same value set in a `configure` block is held to,
|
|
526
|
+
# and fails with the same message.
|
|
527
|
+
sig { params(overrides: T.untyped).returns(T.self_type) }
|
|
528
|
+
def apply(**overrides)
|
|
529
|
+
unknown = overrides.keys - self.class.settings
|
|
530
|
+
if unknown.any?
|
|
531
|
+
raise ConfigurationError,
|
|
532
|
+
"unknown setting(s): #{unknown.join(", ")}. Expected any of #{self.class.settings.join(", ")}"
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
overrides.each { |name, value| public_send(:"#{name}=", value) }
|
|
536
|
+
self
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# A built configuration is frozen, and a client freezes the one it holds.
|
|
540
|
+
#
|
|
541
|
+
# The default store is resolved on the way through, because it is the one
|
|
542
|
+
# thing here that is built lazily and a frozen object cannot memoize. That
|
|
543
|
+
# is also the point: a store settled at build time is a store no two
|
|
544
|
+
# threads can race to construct, and a client that never screens pays for
|
|
545
|
+
# a `File.expand_path` rather than for a directory.
|
|
546
|
+
#
|
|
547
|
+
# The dictionary and the weights are already frozen value objects, and the
|
|
548
|
+
# source list is frozen here so that a caller holding the array it passed
|
|
549
|
+
# in cannot edit the lists a running client syncs.
|
|
550
|
+
sig { returns(T.self_type) }
|
|
551
|
+
def freeze
|
|
552
|
+
return self if frozen?
|
|
553
|
+
|
|
554
|
+
storage
|
|
555
|
+
@sources = T.let(@sources&.dup&.freeze, T.nilable(T::Array[Symbol]))
|
|
556
|
+
super
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Anything Logger-shaped. The fetch layer says what it did at `info` --
|
|
560
|
+
# which list was downloaded, which came back 304 -- because a sync that
|
|
561
|
+
# transfers nothing looks identical to a sync that did not run, and an
|
|
562
|
+
# operator needs to tell those apart.
|
|
563
|
+
sig { params(value: T.untyped).void }
|
|
564
|
+
def logger=(value)
|
|
565
|
+
unless value.nil? || value.respond_to?(:info)
|
|
566
|
+
raise ConfigurationError, "logger must respond to #info, got #{value.class}"
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
@logger = value
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
sig { returns(String) }
|
|
573
|
+
def self.default_storage_dir
|
|
574
|
+
-File.expand_path(File.join(Dir.home, DEFAULT_STORAGE_DIRNAME))
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
sig { returns(String) }
|
|
578
|
+
def self.default_cache_dir
|
|
579
|
+
home = ENV.fetch(XDG_CACHE_HOME, nil)
|
|
580
|
+
home = File.join(Dir.home, ".cache") if home.nil? || home.strip.empty?
|
|
581
|
+
-File.expand_path(File.join(home, DEFAULT_CACHE_DIRNAME))
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Shared by PayloadCache, so a cache built with an explicit `retain:` fails
|
|
585
|
+
# the same way as a misconfigured global. Zero is not allowed: a cache that
|
|
586
|
+
# keeps nothing still writes every payload to disk before deleting it, and
|
|
587
|
+
# an installation that wants no payload cache should not build one.
|
|
588
|
+
sig { params(value: T.untyped).returns(Integer) }
|
|
589
|
+
def self.retain_payloads!(value)
|
|
590
|
+
integer = begin
|
|
591
|
+
Integer(value)
|
|
592
|
+
rescue TypeError, ArgumentError
|
|
593
|
+
raise ConfigurationError, "retain_payloads must be a whole number of payloads, got #{value.inspect}"
|
|
594
|
+
end
|
|
595
|
+
raise ConfigurationError, "retain_payloads must be at least 1, got #{integer}" unless integer.positive?
|
|
596
|
+
|
|
597
|
+
integer
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Shared by Sync, so a run given an explicit `concurrency:` fails the same
|
|
601
|
+
# way as a misconfigured global. Zero is refused rather than read as "no
|
|
602
|
+
# parallelism": a sync that runs no sources is a typo, and one is what
|
|
603
|
+
# sequential is spelled as.
|
|
604
|
+
sig { params(value: T.untyped).returns(Integer) }
|
|
605
|
+
def self.sync_concurrency!(value)
|
|
606
|
+
integer = begin
|
|
607
|
+
Integer(value)
|
|
608
|
+
rescue TypeError, ArgumentError
|
|
609
|
+
raise ConfigurationError, "sync_concurrency must be a whole number of sources, got #{value.inspect}"
|
|
610
|
+
end
|
|
611
|
+
raise ConfigurationError, "sync_concurrency must be at least 1, got #{integer}" unless integer.positive?
|
|
612
|
+
|
|
613
|
+
integer
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# Shared with Doctor, so a per-run `tolerance:` is held to the same rule as
|
|
617
|
+
# the configured default. A share of what a measurement was, so 1.0 is
|
|
618
|
+
# "report nothing short of a doubling or a disappearance" and 0.0 is
|
|
619
|
+
# "report every movement at all", both of which are legitimate settings for
|
|
620
|
+
# somebody and neither of which is a default.
|
|
621
|
+
sig { params(value: T.untyped).returns(Float) }
|
|
622
|
+
def self.doctor_tolerance!(value)
|
|
623
|
+
ratio = begin
|
|
624
|
+
Float(value)
|
|
625
|
+
rescue TypeError, ArgumentError
|
|
626
|
+
raise ConfigurationError, "doctor_tolerance must be a share between 0 and 1, got #{value.inspect}"
|
|
627
|
+
end
|
|
628
|
+
return ratio if ratio.between?(0.0, 1.0)
|
|
629
|
+
|
|
630
|
+
raise ConfigurationError, "doctor_tolerance must be a share between 0 and 1, got #{value.inspect}"
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Shared by HttpClient, so a client built with an explicit `user_agent:`
|
|
634
|
+
# fails the same way and with the same message as a misconfigured global.
|
|
635
|
+
# Whitespace counts as blank: a header of `" "` is what a publisher sees as
|
|
636
|
+
# no header at all, and it would earn the same 403.
|
|
637
|
+
sig { params(value: T.untyped).returns(String) }
|
|
638
|
+
def self.user_agent!(value)
|
|
639
|
+
string = value.to_s.strip
|
|
640
|
+
if string.empty?
|
|
641
|
+
raise ConfigurationError,
|
|
642
|
+
"user_agent is required -- OFAC and other publishers reject requests without one. " \
|
|
643
|
+
'Set ActiveSanction.configure { |c| c.user_agent = "my-app/1.0 (you@example.com)" }'
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
-string
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
private
|
|
650
|
+
|
|
651
|
+
# The store `storage_dir` names, built once. Memoized rather than built on
|
|
652
|
+
# every read because a FileSystem store is a directory and a checksum
|
|
653
|
+
# pattern, and two of them would be two objects saying the same thing.
|
|
654
|
+
sig { returns(Storage::Base) }
|
|
655
|
+
def default_storage
|
|
656
|
+
@default_storage ||= T.let(Storage::FileSystem.new(root: storage_dir), T.nilable(Storage::Base))
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
sig { params(value: T.untyped).returns(Normalizer::Dictionary) }
|
|
660
|
+
def normalizer_dictionary!(value)
|
|
661
|
+
return value if value.is_a?(Normalizer::Dictionary)
|
|
662
|
+
|
|
663
|
+
unless value.is_a?(Hash)
|
|
664
|
+
raise ConfigurationError,
|
|
665
|
+
"normalizer_dictionary must be an ActiveSanction::Normalizer::Dictionary, or a Hash of " \
|
|
666
|
+
"lists to add to the shipped ones, got #{value.class}"
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
lists = value.to_h { |list, entries| [list.to_s.to_sym, entries] }
|
|
670
|
+
unknown = lists.keys - Normalizer::Dictionary::LISTS
|
|
671
|
+
if unknown.any?
|
|
672
|
+
raise ConfigurationError,
|
|
673
|
+
"unknown normalizer dictionary list(s): #{unknown.join(", ")}. " \
|
|
674
|
+
"Expected any of #{Normalizer::Dictionary::LISTS.join(", ")}"
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
T.unsafe(Normalizer::Dictionary.default).merge(**lists)
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
sig { params(name: Symbol, value: T.untyped).returns(String) }
|
|
681
|
+
def directory!(name, value)
|
|
682
|
+
path = value.to_s.strip
|
|
683
|
+
raise ConfigurationError, "#{name} cannot be blank" if path.empty?
|
|
684
|
+
|
|
685
|
+
path
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
sig { params(value: T.untyped).returns(T::Array[Symbol]) }
|
|
689
|
+
def source_keys!(value)
|
|
690
|
+
keys = Array(value).map { |key| key.to_s.strip }
|
|
691
|
+
raise ConfigurationError, "sources cannot be empty -- use nil to mean every registered source" if keys.empty?
|
|
692
|
+
raise ConfigurationError, "sources cannot contain a blank key, got #{value.inspect}" if keys.any?(&:empty?)
|
|
693
|
+
|
|
694
|
+
keys.map(&:to_sym).uniq
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
sig { params(name: Symbol, value: T.untyped).returns(Numeric) }
|
|
698
|
+
def positive_number!(name, value)
|
|
699
|
+
number = begin
|
|
700
|
+
Float(value)
|
|
701
|
+
rescue TypeError, ArgumentError
|
|
702
|
+
raise ConfigurationError, "#{name} must be a number of seconds, got #{value.inspect}"
|
|
703
|
+
end
|
|
704
|
+
raise ConfigurationError, "#{name} must be greater than zero, got #{value.inspect}" unless number.positive?
|
|
705
|
+
|
|
706
|
+
number
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
sig { params(name: Symbol, value: T.untyped).returns(Integer) }
|
|
710
|
+
def non_negative_integer!(name, value)
|
|
711
|
+
integer = begin
|
|
712
|
+
Integer(value)
|
|
713
|
+
rescue TypeError, ArgumentError
|
|
714
|
+
raise ConfigurationError, "#{name} must be a whole number, got #{value.inspect}"
|
|
715
|
+
end
|
|
716
|
+
raise ConfigurationError, "#{name} cannot be negative, got #{value.inspect}" if integer.negative?
|
|
717
|
+
|
|
718
|
+
integer
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
end
|