active_sanction 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. metadata +236 -0
@@ -0,0 +1,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