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,360 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "uri"
|
|
7
|
+
require "active_sanction/sources"
|
|
8
|
+
|
|
9
|
+
module ActiveSanction
|
|
10
|
+
module Sources
|
|
11
|
+
# What an adapter declares about the list it reads. Extended into
|
|
12
|
+
# Sources::Base, so every adapter's class body reads as a description of
|
|
13
|
+
# the list rather than as a constructor:
|
|
14
|
+
#
|
|
15
|
+
# class UnConsolidated < ActiveSanction::Sources::Base
|
|
16
|
+
# key :un_consolidated
|
|
17
|
+
# jurisdiction :un
|
|
18
|
+
# authority "United Nations Security Council"
|
|
19
|
+
# format :xml
|
|
20
|
+
# url :main, "https://scsanctions.un.org/resources/xml/en/consolidated.xml"
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Every declaration reads back with no argument -- `UnConsolidated.authority`
|
|
24
|
+
# -- and that is not decoration. It is what the registry files the adapter
|
|
25
|
+
# under, what the CLI's `sources` command prints, and what a match result
|
|
26
|
+
# cites when an examiner asks which list a name was found on.
|
|
27
|
+
#
|
|
28
|
+
# ### Multiple URLs
|
|
29
|
+
#
|
|
30
|
+
# OFAC publishes the SDN list as three files that only mean something
|
|
31
|
+
# joined -- the names, their aliases, their addresses -- so a source
|
|
32
|
+
# declares as many as it has:
|
|
33
|
+
#
|
|
34
|
+
# url :sdn, "https://sanctionslistservice.ofac.treas.gov/api/download/SDN.CSV"
|
|
35
|
+
# url :alt, "https://sanctionslistservice.ofac.treas.gov/api/download/ALT.CSV"
|
|
36
|
+
# url :add, "https://sanctionslistservice.ofac.treas.gov/api/download/ADD.CSV"
|
|
37
|
+
#
|
|
38
|
+
# Each is fetched, validated and cached independently, because they change
|
|
39
|
+
# independently: a sync where only ALT.CSV moved should download only
|
|
40
|
+
# ALT.CSV.
|
|
41
|
+
#
|
|
42
|
+
# ### What is inherited, and what is not
|
|
43
|
+
#
|
|
44
|
+
# Declarations resolve up the superclass chain, so adapters sharing a
|
|
45
|
+
# publisher can share a base holding the jurisdiction, the authority and
|
|
46
|
+
# the format. `key` is the exception: it is looked up on the exact class
|
|
47
|
+
# and nowhere else, since a subclass silently inheriting its parent's key
|
|
48
|
+
# would try to register under a name already taken -- which is the one
|
|
49
|
+
# mistake the registry cannot let through.
|
|
50
|
+
module Definition
|
|
51
|
+
extend T::Sig
|
|
52
|
+
extend T::Helpers
|
|
53
|
+
|
|
54
|
+
# Extended into a class, never included into an instance, so `self` in
|
|
55
|
+
# every method below is the adapter class itself -- something Sorbet has
|
|
56
|
+
# no way to know from the module alone. This says the one thing about it
|
|
57
|
+
# the checker cannot do without: whatever extends Definition answers to
|
|
58
|
+
# `raise`. (`Class` cannot be required the same way, so #lineage walks
|
|
59
|
+
# the superclass chain through an untyped local instead.)
|
|
60
|
+
requires_ancestor { Kernel }
|
|
61
|
+
|
|
62
|
+
# @api private
|
|
63
|
+
UNSET = T.let(Object.new.freeze, Object)
|
|
64
|
+
private_constant :UNSET
|
|
65
|
+
|
|
66
|
+
# Keys are typed by people -- into an initializer, into a CLI argument --
|
|
67
|
+
# stored in snapshots, and used as directory names by the payload cache.
|
|
68
|
+
# Lowercase snake_case is the intersection of all of that.
|
|
69
|
+
#
|
|
70
|
+
# @api private
|
|
71
|
+
KEY_PATTERN = T.let(/\A[a-z][a-z0-9_]*\z/, Regexp)
|
|
72
|
+
|
|
73
|
+
# @api private
|
|
74
|
+
URL_SCHEMES = T.let(%w[http https].freeze, T::Array[String])
|
|
75
|
+
|
|
76
|
+
# Shared with the registry, so a source registered without going through
|
|
77
|
+
# Base is held to the same rule as one that declared its key here.
|
|
78
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
79
|
+
def self.key!(value)
|
|
80
|
+
key = value.to_s
|
|
81
|
+
return key.to_sym if key.match?(KEY_PATTERN)
|
|
82
|
+
|
|
83
|
+
raise DeclarationError,
|
|
84
|
+
"#{value.inspect} is not a usable source key: it is typed into configuration and used as a " \
|
|
85
|
+
"directory name, so it must be lowercase snake_case starting with a letter, like :ofac_sdn"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The name this list answers to everywhere. Required, and never inherited.
|
|
89
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
90
|
+
def key(value = UNSET)
|
|
91
|
+
return own(:key) { "does not declare a key. Add `key :something` to its class body" } if unset?(value)
|
|
92
|
+
|
|
93
|
+
declarations[:key] = Definition.key!(value)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Who publishes the list, as a symbol: :us, :un, :ca, :eu. Not validated
|
|
97
|
+
# against a country list -- an internal watchlist's jurisdiction is
|
|
98
|
+
# whatever its owner says it is.
|
|
99
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
100
|
+
def jurisdiction(value = UNSET)
|
|
101
|
+
return required(:jurisdiction) { "does not declare a jurisdiction, e.g. `jurisdiction :un`" } if unset?(value)
|
|
102
|
+
|
|
103
|
+
declarations[:jurisdiction] = symbol!(:jurisdiction, value)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The body behind the list, spelled the way it spells itself. This is
|
|
107
|
+
# what a compliance report prints beside a hit, so "United Nations
|
|
108
|
+
# Security Council", not "UN".
|
|
109
|
+
sig { params(value: T.untyped).returns(String) }
|
|
110
|
+
def authority(value = UNSET)
|
|
111
|
+
return required(:authority) { "does not declare an authority, e.g. `authority \"...\"`" } if unset?(value)
|
|
112
|
+
|
|
113
|
+
declarations[:authority] = string!(:authority, value)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# What the publisher says about reusing its list, and where it says it.
|
|
117
|
+
# Both optional, and both are a *pointer* rather than a legal opinion:
|
|
118
|
+
#
|
|
119
|
+
# licence_notice "Crown copyright. Open Government Licence v3.0."
|
|
120
|
+
# licence_url "https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/"
|
|
121
|
+
#
|
|
122
|
+
# ### Why this is in the gem at all
|
|
123
|
+
#
|
|
124
|
+
# Screening a name against a list is reading it, and nobody needs a
|
|
125
|
+
# licence to read. Publishing what a list *said* is redistribution --
|
|
126
|
+
# which is what a signed bundle (#57) is, what a hosted screening API
|
|
127
|
+
# returning a matched name arguably is, and what any application storing
|
|
128
|
+
# hits in its own audit trail may be. These publishers do not agree with
|
|
129
|
+
# each other about that: two of the seven attach conditions to
|
|
130
|
+
# redistribution that the other five do not.
|
|
131
|
+
#
|
|
132
|
+
# So the notice travels with the source rather than living in a document
|
|
133
|
+
# somebody has to go and find. `ActiveSanction::Sources[:canada_sema]
|
|
134
|
+
# .licence_notice` answers in a console, in a bundle's header, and on the
|
|
135
|
+
# catalogue page, from one declaration.
|
|
136
|
+
#
|
|
137
|
+
# ### What it is not
|
|
138
|
+
#
|
|
139
|
+
# It is not legal advice, it is not this project's reading of the terms,
|
|
140
|
+
# and it is not a grant of anything by us. It is a short, dated summary
|
|
141
|
+
# of what the publisher's own page says, plus the URL of that page, which
|
|
142
|
+
# is the thing that actually governs. A deployment redistributing any of
|
|
143
|
+
# these lists should read the URL and ask its own counsel.
|
|
144
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
145
|
+
def licence_notice(value = UNSET)
|
|
146
|
+
return declared(:licence_notice) if unset?(value)
|
|
147
|
+
|
|
148
|
+
declarations[:licence_notice] = string!(:licence_notice, value)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Where the publisher states its terms. Validated as an http(s) URL for
|
|
152
|
+
# the same reason a list's own URL is: a notice pointing nowhere is worse
|
|
153
|
+
# than no notice, because it reads as though somebody checked.
|
|
154
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
155
|
+
def licence_url(value = UNSET)
|
|
156
|
+
return declared(:licence_url) if unset?(value)
|
|
157
|
+
|
|
158
|
+
declarations[:licence_url] = address!(:licence_url, value)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# What the publisher serves: :csv, :xml, :json. Informational, and
|
|
162
|
+
# deliberately not checked against a list of known formats -- the parser
|
|
163
|
+
# toolkits (#14, #15) are chosen by the adapter, not dispatched from
|
|
164
|
+
# here, and a source arriving as :fixed_width or :xlsx should be able to
|
|
165
|
+
# say so without waiting for a release of this gem. Optional: a source
|
|
166
|
+
# that builds entities from a database has no format to name.
|
|
167
|
+
sig { params(value: T.untyped).returns(T.nilable(Symbol)) }
|
|
168
|
+
def format(value = UNSET)
|
|
169
|
+
return declared(:format) if unset?(value)
|
|
170
|
+
|
|
171
|
+
declarations[:format] = symbol!(:format, value)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Declares a file with two arguments, reads one back with one, and with
|
|
175
|
+
# none returns the primary -- the first declared, conventionally :main.
|
|
176
|
+
sig { params(name: T.untyped, address: T.untyped).returns(String) }
|
|
177
|
+
def url(name = UNSET, address = UNSET)
|
|
178
|
+
return primary_url if unset?(name)
|
|
179
|
+
return read_url(name) if unset?(address)
|
|
180
|
+
|
|
181
|
+
declared_urls[name.to_sym] = address!(name, address)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# A lower bound this list is held to when there is nothing to compare it
|
|
185
|
+
# against -- a first sync, a new source, a store that was cleared:
|
|
186
|
+
#
|
|
187
|
+
# floor :record_count, 400
|
|
188
|
+
# floor :remarks_coverage, 0.90
|
|
189
|
+
# floor :fill_addresses, 0.30
|
|
190
|
+
#
|
|
191
|
+
# The name is a Doctor check name; the value is the least it may be
|
|
192
|
+
# without the diagnosis saying so. Reads back with one argument, and
|
|
193
|
+
# every declared floor with none.
|
|
194
|
+
#
|
|
195
|
+
# These are deliberately coarse and deliberately few. A number committed
|
|
196
|
+
# here goes stale on its own, and the day somebody widens one to make a
|
|
197
|
+
# build pass is the day it stops being read -- which is why Doctor
|
|
198
|
+
# compares against the last stored snapshot instead, and uses these only
|
|
199
|
+
# where there is no snapshot to compare with. Declare a floor no
|
|
200
|
+
# published version of the list has ever come close to, and let the
|
|
201
|
+
# baseline do the real work.
|
|
202
|
+
sig { params(name: T.untyped, value: T.untyped).returns(T.untyped) }
|
|
203
|
+
def floor(name = UNSET, value = UNSET)
|
|
204
|
+
return floors if unset?(name)
|
|
205
|
+
return floors[symbol!(:floor, name)] if unset?(value)
|
|
206
|
+
|
|
207
|
+
declared_floors[symbol!(:floor, name)] = floor!(name, value)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Every floor that applies to this adapter, inherited ones included. A
|
|
211
|
+
# subclass declaring the same name replaces its parent's, so an adapter
|
|
212
|
+
# over a list a tenth the size of its sibling's says so once.
|
|
213
|
+
sig { returns(T::Hash[Symbol, Numeric]) }
|
|
214
|
+
def floors
|
|
215
|
+
lineage.reverse.inject({}) { |all, klass| all.merge(klass.declared_floors) }.freeze
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Every declared file, in declaration order, inherited ones first.
|
|
219
|
+
sig { returns(T::Hash[Symbol, String]) }
|
|
220
|
+
def urls
|
|
221
|
+
lineage.reverse.inject({}) { |all, klass| all.merge(klass.declared_urls) }.freeze
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# A source with several files needs each filed separately -- separate
|
|
225
|
+
# ETags, separate cache entries -- or three files sharing one name would
|
|
226
|
+
# evict each other out of a cache that retains N payloads per name. One
|
|
227
|
+
# file is filed under the source key itself, which keeps the common case
|
|
228
|
+
# legible on disk and in a validators.json somebody is reading to find
|
|
229
|
+
# out why a sync downloaded more than it should have.
|
|
230
|
+
sig { params(name: T.untyped).returns(Symbol) }
|
|
231
|
+
def file_key(name)
|
|
232
|
+
multi_url? ? :"#{key}-#{name}" : key
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
sig { returns(T::Boolean) }
|
|
236
|
+
def multi_url? = urls.size > 1
|
|
237
|
+
|
|
238
|
+
# Whether a declaration was made, without raising if it was not. What a
|
|
239
|
+
# conformance spec (#16) asks before reporting which ones are missing.
|
|
240
|
+
sig { params(name: Symbol).returns(T::Boolean) }
|
|
241
|
+
def declared?(name)
|
|
242
|
+
name == :key ? !declarations[:key].nil? : !declared(name).nil?
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# A summary of the declarations, for a CLI listing or a bug report. Reads
|
|
246
|
+
# what is there rather than insisting: an adapter missing a declaration
|
|
247
|
+
# is exactly what somebody printing this is trying to find out.
|
|
248
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
249
|
+
def to_h
|
|
250
|
+
{ key: declarations[:key], jurisdiction: declared(:jurisdiction), authority: declared(:authority),
|
|
251
|
+
format: declared(:format), urls: urls,
|
|
252
|
+
licence_notice: declared(:licence_notice), licence_url: declared(:licence_url) }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The declarations made on this exact class, ignoring anything inherited.
|
|
256
|
+
# Public because resolving a reader means walking the superclass chain
|
|
257
|
+
# asking each one what it declared.
|
|
258
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
259
|
+
def declarations
|
|
260
|
+
@declarations ||= T.let({}, T.nilable(T::Hash[Symbol, T.untyped]))
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
sig { returns(T::Hash[Symbol, String]) }
|
|
264
|
+
def declared_urls
|
|
265
|
+
@declared_urls ||= T.let({}, T.nilable(T::Hash[Symbol, String]))
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# The floors declared on this exact class, ignoring anything inherited.
|
|
269
|
+
# Public for the same reason #declared_urls is: resolving one means
|
|
270
|
+
# walking the superclass chain asking each class what it declared.
|
|
271
|
+
sig { returns(T::Hash[Symbol, Numeric]) }
|
|
272
|
+
def declared_floors
|
|
273
|
+
@declared_floors ||= T.let({}, T.nilable(T::Hash[Symbol, Numeric]))
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
private
|
|
277
|
+
|
|
278
|
+
# This class and its ancestors that declare, most derived first.
|
|
279
|
+
sig { returns(T::Array[T.untyped]) }
|
|
280
|
+
def lineage
|
|
281
|
+
chain = []
|
|
282
|
+
klass = T.let(self, T.untyped)
|
|
283
|
+
while klass.respond_to?(:declarations)
|
|
284
|
+
chain << klass
|
|
285
|
+
klass = klass.superclass
|
|
286
|
+
end
|
|
287
|
+
chain
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
sig { params(name: Symbol).returns(T.untyped) }
|
|
291
|
+
def declared(name) = lineage.filter_map { |klass| klass.declarations[name] }.first
|
|
292
|
+
|
|
293
|
+
sig { params(value: T.untyped).returns(T::Boolean) }
|
|
294
|
+
def unset?(value) = value.equal?(UNSET)
|
|
295
|
+
|
|
296
|
+
sig { params(name: Symbol, block: T.proc.returns(String)).returns(T.untyped) }
|
|
297
|
+
def own(name, &block)
|
|
298
|
+
declarations[name] || raise(DeclarationError, "#{self} #{block.call}")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Not named `inherited`: that is Class's own subclassing hook, and a
|
|
302
|
+
# module extended into a class must not take it over.
|
|
303
|
+
sig { params(name: Symbol, block: T.proc.returns(String)).returns(T.untyped) }
|
|
304
|
+
def required(name, &block)
|
|
305
|
+
declared(name) || raise(DeclarationError, "#{self} #{block.call}")
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
sig { returns(String) }
|
|
309
|
+
def primary_url
|
|
310
|
+
urls.values.first ||
|
|
311
|
+
raise(DeclarationError,
|
|
312
|
+
"#{self} declares no URL. Add `url :main, \"https://...\"`, or override #retrieve for a source " \
|
|
313
|
+
"that is not fetched over HTTP")
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
sig { params(name: T.untyped).returns(String) }
|
|
317
|
+
def read_url(name)
|
|
318
|
+
urls.fetch(name.to_sym) do
|
|
319
|
+
raise DeclarationError, "#{self} declares no #{name.inspect} URL. Declared: #{urls.keys.join(", ")}"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
sig { params(name: Symbol, value: T.untyped).returns(Symbol) }
|
|
324
|
+
def symbol!(name, value)
|
|
325
|
+
string = value.to_s.strip
|
|
326
|
+
raise DeclarationError, "#{self} #{name} cannot be blank" if string.empty?
|
|
327
|
+
|
|
328
|
+
string.downcase.to_sym
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
sig { params(name: Symbol, value: T.untyped).returns(String) }
|
|
332
|
+
def string!(name, value)
|
|
333
|
+
string = value.to_s.strip
|
|
334
|
+
raise DeclarationError, "#{self} #{name} cannot be blank" if string.empty?
|
|
335
|
+
|
|
336
|
+
-string
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
sig { params(name: T.untyped, value: T.untyped).returns(Numeric) }
|
|
340
|
+
def floor!(name, value)
|
|
341
|
+
raise DeclarationError, "#{self} #{name} floor must be a number, got #{value.inspect}" unless
|
|
342
|
+
value.is_a?(Numeric)
|
|
343
|
+
raise DeclarationError, "#{self} #{name} floor cannot be negative, got #{value}" if value.negative?
|
|
344
|
+
|
|
345
|
+
value
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
sig { params(name: T.untyped, value: T.untyped).returns(String) }
|
|
349
|
+
def address!(name, value)
|
|
350
|
+
uri = URI.parse(value.to_s.strip)
|
|
351
|
+
raise URI::InvalidURIError unless URL_SCHEMES.include?(uri.scheme) && uri.host
|
|
352
|
+
|
|
353
|
+
-uri.to_s
|
|
354
|
+
rescue URI::InvalidURIError
|
|
355
|
+
raise DeclarationError,
|
|
356
|
+
"#{self} #{name} URL #{value.inspect} is not an http(s) URL"
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|