params-registry 0.1.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.
@@ -0,0 +1,403 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'registry/version'
4
+ require_relative 'registry/error'
5
+ require_relative 'registry/template'
6
+ require_relative 'registry/instance'
7
+
8
+ require 'uri'
9
+
10
+ # {Params::Registry} is intended to contain an organization-wide
11
+ # registry of reusable named parameters. The initial purpose of such a
12
+ # thing is to control the lexical representation of serialization
13
+ # schemes of, e.g., {::URI} query parameters and
14
+ # `application/x-www-form-urlencoded` Web form input, such that they
15
+ # can be _round-tripped_ from a canonicalized string representation to
16
+ # a processed, in-memory representation and back.
17
+ #
18
+ class Params::Registry
19
+
20
+ # A group is an identifiable sequence of parameters.
21
+ class Group
22
+ # Create a new group.
23
+ #
24
+ # @param registry [Params::Registry] the registry
25
+ #
26
+ def initialize registry, id, templates: nil
27
+ @id = id
28
+ @registry = registry
29
+ @templates = {} # main mapping
30
+ @aliases = {} # alternate mapping
31
+ @ranks = {} # internal ranking for dependencies
32
+
33
+ templates = (Types::Array|Types::TemplateMap)[templates]
34
+
35
+ # use the internal subscript assignment
36
+ templates.each { |t, spec| self[t] = spec || t }
37
+ end
38
+
39
+ # !@attribute [r] id
40
+ # @return [Object] the identifier of the group.
41
+ #
42
+ # !@attribute [r] registry
43
+ # @return [Params::Registry] the associated registry.
44
+
45
+ attr_reader :id, :registry
46
+
47
+ # Retrieve a template.
48
+ #
49
+ # @param id [Object] the template identifier, either canonical or an alias.
50
+ #
51
+ # @return [Params::Registry::Template, nil] the template, if one is found.
52
+ #
53
+ def [] id
54
+ @templates[id] || @aliases[id]
55
+ end
56
+
57
+ # Add a parameter template to the group. The `spec` can be a
58
+ # template specification, or it can be an already-instantiated
59
+ # template, or it can be the same as `id`, or it can be `nil`. In
60
+ # the first case, the template will be created and added to the
61
+ # registry, replacing any template with the same ID. In the case
62
+ # that it's a {Params::Registry::Template} instance, its ID must
63
+ # match `id` and it must come from the same registry as the
64
+ # group. In the latter two cases, the parameter is retrieved from
65
+ # the registry, raising an exception if not.
66
+ #
67
+ # @param id [Object] the template's canonical identifier.
68
+ # @param spec [Hash{Symbol => Object}, Params::Registry::Template, nil]
69
+ # the template specification, as described above.
70
+ #
71
+ # @return [Params::Registry::Template] the new template, assigned to
72
+ # the registry.
73
+ #
74
+ def []= id, spec
75
+ case spec
76
+ when nil, id
77
+ template = registry.templates[id]
78
+ raise ArgumentError, "Could not find template #{id}" unless template
79
+ when Template
80
+ raise ArgumentError,
81
+ "Template #{id} supplied from some other registry" unless
82
+ registry.equal? spec.registry
83
+ raise ArgumentError,
84
+ "Identifier #{id} does not match template (#{spec.id})" unless
85
+ id == spec.id
86
+ template = spec
87
+ else
88
+ Types::Hash[spec]
89
+ template = registry.template_class.new registry, id, **spec
90
+ end
91
+
92
+ # amke sure we aren't calling ourselves
93
+ registry.templates[id] = template unless registry.templates.equal? self
94
+
95
+ # okay now actually assign
96
+ @templates[id] = template
97
+
98
+ # then map all the aliases and crap
99
+ @aliases[template.slug] = template if template.slug
100
+ # we use a conditional assign here since aliases take a lower priority
101
+ template.aliases.each { |a| @aliases[a] ||= template }
102
+
103
+ # now we compute the rank, but first we need the dependencies
104
+ deps = template.depends.map do |t|
105
+ registry.templates[t]
106
+ end.compact.map(&:id)
107
+
108
+ # warn deps.inspect
109
+
110
+ # XXX this does not do cycles; we should really do cycles.
111
+ rank = @ranks.values_at(*deps).compact.max
112
+ @ranks[id] = rank.nil? ? 0 : rank + 1
113
+
114
+ # warn template.id
115
+ template
116
+ end
117
+
118
+ # Return whether the group has a given key.
119
+ #
120
+ # @return [false, true] what I said.
121
+ #
122
+ def key? id
123
+ !!self[id]
124
+ end
125
+
126
+ # Return the canonical template identifiers.
127
+ #
128
+ # @return [Array] the keys.
129
+ #
130
+ def keys ; @templates.keys; end
131
+
132
+ # Return the canonical identifier for the template.
133
+ #
134
+ # @param id [Object] the identifier, canonical or otherwise.
135
+ #
136
+ # @return [Object, nil] the canonical identifier, if found.
137
+ #
138
+ def canonical id
139
+ return id if @templates.key? id
140
+ @aliases[id].id if @aliases.key? id
141
+ end
142
+
143
+ # Return an array of arrays of templates sorted by rank. A higher
144
+ # rank means a parameter depends on one or more parameters with a
145
+ # lower rank.
146
+ #
147
+ # @return [Array<Hash{Object => Params::Registry::Template}>] the
148
+ # ranked parameter templates.
149
+ #
150
+ def ranked
151
+ # warn @ranks.inspect
152
+
153
+ out = Array.new((@ranks.values.max || -1) + 1) { {} }
154
+
155
+ # warn out.inspect
156
+
157
+ @templates.values.reject do |t|
158
+ # skip the complement as it's handled out of band
159
+ t.equal? registry.complement
160
+ end.each { |t| out[@ranks[t.id]][t.id] = t }
161
+
162
+ out
163
+ end
164
+
165
+ # Delete a template from the group.
166
+ #
167
+ # @param id [Object] the canonical identifier for the template, or an alias.
168
+ #
169
+ # @return [Params::Registry::Template, nil] the removed template,
170
+ # if there was one present to be removed.
171
+ #
172
+ def delete id
173
+ # first we have to find it
174
+ return unless template = self[id]
175
+
176
+ @templates.delete template.id
177
+ @ranks.delete template.id
178
+ @aliases.delete template.slug if template.slug
179
+
180
+ # XXX i feel like we should try to find other parameters that
181
+ # may have an alias that's the same as the one we just deleted
182
+ # and give (the first matching one) the now-empty slot, but
183
+ # that's not urgent so i'll leave it for now.
184
+ template.aliases.each do |a|
185
+ @aliases.delete a if template.equal? @aliases[a]
186
+ end
187
+
188
+ # if we are the main registry group we have to do extra stuff
189
+ if registry.templates.equal? self
190
+ registry.groups.each { |g| g.delete template.id }
191
+ end
192
+
193
+ # this leaves us with an unbound template which i gueessss we
194
+ # could reinsert?
195
+ template
196
+ end
197
+
198
+ # Return a suitable representation for debugging.
199
+ #
200
+ # @return [String] the object.
201
+ #
202
+ def inspect
203
+ "#<#{self.class}: #{id} {#{keys.join ', '}}>"
204
+ end
205
+
206
+ end
207
+
208
+ private
209
+
210
+ # The complement can be either an identifier or it can be a
211
+ # (partial) template spec with one additional `:id` member.
212
+ def coerce_complement complement
213
+ complement ||= :complement
214
+ # complement can be a name, or it can be a structure which is
215
+ # merged with the boilerplate below.
216
+ if complement.is_a? Hash
217
+ complement = Types::TemplateSpec[complement]
218
+ raise ArgumentError, 'Complement hash is missing :id' unless
219
+ complement.key? :id
220
+ spec = complement.except :id
221
+ complement = complement[:id]
222
+ else
223
+ spec = {}
224
+ end
225
+
226
+ # for the closures
227
+ ts = templates
228
+
229
+ # we always want these closures so we steamroll over whatever the
230
+ # user might have put in these slots
231
+ spec.merge!({
232
+ composite: Types::Set.constructor { |set|
233
+ # warn "heyooo #{set.inspect}"
234
+ raise Dry::Types::ConstraintError,
235
+ "#{complement} has values not found in templates" unless
236
+ set.all? { |t| ts.select { |_, x| x.complement? }.key? t }
237
+ Set[*set]
238
+ },
239
+ unwind: -> set {
240
+ # XXX do we want to sort this lexically or do we want it in
241
+ # the same order as the keys?
242
+ [set.to_a.map { |t| t = ts[t]; (t.slug || t.id).to_s }.sort, false]
243
+ }
244
+ })
245
+
246
+ [complement, spec]
247
+ end
248
+
249
+ public
250
+
251
+ # Initialize the registry. You will need to supply a set of specs
252
+ # that will become {::Params::Registry::Template} objects. You can
253
+ # also supply groups which you can use how you like.
254
+ #
255
+ # Parameters can be defined within groups or separately from them.
256
+ # This allows subsets of parameters to be easily hived off from the
257
+ # main {Params::Registry::Instance}.
258
+ #
259
+ # There is a special meta-parameter `:complement` which takes as its
260
+ # values the names of other parameters. This is intended to be used
261
+ # to signal that the parameters so named, assumed to be some kind of
262
+ # composite, like a {::Set} or {::Range}, are to be complemented or
263
+ # negated. The purpose of this is, for instance, if you want to
264
+ # express a parameter that is a set with many values, and rather
265
+ # than having to enumerate each of the values in something like a
266
+ # query string, you only have to enumerate the values you *don't*
267
+ # want in the set.
268
+ #
269
+ # @note The complement parameter is always set (last), and its
270
+ # identifier defaults, unsurprisingly, to `:complement`. This can
271
+ # be overridden by specifying an identifier you prefer. If you want
272
+ # a slug that is distinct from the canonical identifier, or if you
273
+ # want aliases, pass in a spec like this: `{ id:
274
+ # URI('https://my.schema/parameters#complement'), slug:
275
+ # :complement, aliases: %i[invert negate] }`
276
+ #
277
+ # @param templates [Hash] the hash of template specifications.
278
+ # @param groups [Hash, Array] the hash of groups.
279
+ # @param complement [Object, Hash] the identifier for the parameter
280
+ # for complementing composites, or otherwise a partial specification.
281
+ #
282
+ def initialize templates: nil, groups: nil, complement: nil
283
+ # initialize the object state with an empty default group
284
+ @groups = { nil => Group.new(self, nil) }
285
+
286
+ # coerce these guys
287
+ templates = Types::TemplateMap[templates]
288
+ groups = Types::GroupMap[groups]
289
+
290
+ # now load templates
291
+ templates.each { |id, spec| self.templates[id] = spec }
292
+
293
+ # now load groups
294
+ groups.each { |id, specs| self[id] = specs }
295
+
296
+ # now deal with complement
297
+ cid, cspec = coerce_complement complement
298
+ self.templates[cid] = cspec # XXX note leaky abstraction
299
+ # warn wtf.inspect
300
+ @complement = self.templates[cid]
301
+ end
302
+
303
+ # @!attribute [r] complement
304
+ # The `complement` template.
305
+ # @return [Params::Registry::Template]
306
+ attr_reader :complement
307
+
308
+ # Retrieve a group.
309
+ #
310
+ # @return [Params::Registry::Group] the group.
311
+ #
312
+ def [] id
313
+ @groups[id]
314
+ end
315
+
316
+ # Assign a group.
317
+ #
318
+ # @return [Params::Registry::Group] the new group.
319
+ #
320
+ def []= id, spec
321
+ # the null id is special; you can't assign to it
322
+ id = Types::NonNil[id]
323
+
324
+ @groups[id] = Group.new self, id, templates: spec
325
+ end
326
+
327
+ # Retrieve the names of the groups.
328
+ #
329
+ # @return [Array] the group names.
330
+ #
331
+ def keys
332
+ @groups.keys.reject(&:nil?)
333
+ end
334
+
335
+ # Retrieve the groups themselves.
336
+ #
337
+ # @return [Array<Params::Registry::Group>] the groups.
338
+ #
339
+ def groups
340
+ @groups.values_at(*keys)
341
+ end
342
+
343
+ # Retrieve the master template group.
344
+ #
345
+ # @return [Params::Registry::Group] the master group.
346
+ #
347
+ def templates
348
+ # XXX is this dumb? would it be better off as its own member?
349
+ @groups[nil]
350
+ end
351
+
352
+ # Process the parameters and return a {Params::Registry::Instance}.
353
+ #
354
+ # @param params
355
+ # [String, URI, Hash{#to_sym => Array}, Array<Array<(#to_sym, Object)>>]
356
+ # the parameter set, in a dizzying variety of inputs.
357
+ #
358
+ # @return [Params::Registry::Instance] the instance.
359
+ #
360
+ def process params
361
+ instance_class.new self, Types::Input[params]
362
+ end
363
+
364
+ # Refresh any stateful elements of the templates.
365
+ #
366
+ # @return [void]
367
+ #
368
+ def refresh!
369
+ templates.each { |t| t.refresh! }
370
+ nil
371
+ end
372
+
373
+ # @!group Quasi-static methods to override in subclasses
374
+
375
+ # The template class to use. Override this in a subclass if you want
376
+ # to use a custom one.
377
+ #
378
+ # @return [Class] the template class, {Params::Registry::Template}.
379
+ #
380
+ def template_class
381
+ Template
382
+ end
383
+
384
+ # The instance class to use. Override this in a subclass if you want
385
+ # to use a custom one.
386
+ #
387
+ # @return [Class] the instance class, {Params::Registry::Instance}.
388
+ #
389
+ def instance_class
390
+ Instance
391
+ end
392
+
393
+ # The group class to use. Override this in a subclass if you want
394
+ # to use a custom one.
395
+ #
396
+ # @return [Class] the group class, {Params::Registry::Group}.
397
+ #
398
+ def group_class
399
+ Group
400
+ end
401
+
402
+ # @!endgroup
403
+ end
@@ -0,0 +1 @@
1
+ require 'params/registry'
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/params/registry/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "params-registry"
7
+ spec.version = Params::Registry::VERSION
8
+ spec.authors = ["Dorian Taylor"]
9
+ spec.email = ["code@doriantaylor.com"]
10
+
11
+ spec.summary = "Params::Registry: a registry for URI query parameters"
12
+ spec.description =
13
+ File.open('README.md').read.split(/(\r?\n){2,}/).take(2).join("\n\n")
14
+
15
+ spec.homepage = "https://github.com/doriantaylor/rb-params-registry"
16
+ spec.license = "Apache-2.0"
17
+ spec.required_ruby_version = ">= 2.7"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = spec.homepage
21
+ # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(__dir__) do
26
+ `git ls-files -z`.split("\x0").reject do |f|
27
+ (File.expand_path(f) == __FILE__) ||
28
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
29
+ end
30
+ end
31
+ spec.bindir = "exe"
32
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
+ spec.require_paths = ["lib"]
34
+
35
+ # Uncomment to register a new dependency of your gem
36
+ # spec.add_dependency "example-gem", "~> 1.0"
37
+ spec.add_dependency 'dry-types', '~> 1.7'
38
+
39
+ # For more information and examples about making a new gem, check out our
40
+ # guide at: https://bundler.io/guides/creating_gem.html
41
+ end
@@ -0,0 +1,6 @@
1
+ module Params
2
+ module Registry
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: params-registry
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dorian Taylor
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-11-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-types
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.7'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.7'
27
+ description: |+
28
+ # `Params::Registry`: A registry for named parameters
29
+
30
+
31
+ email:
32
+ - code@doriantaylor.com
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - ".rspec"
38
+ - ".yardopts"
39
+ - LICENSE
40
+ - README.md
41
+ - Rakefile
42
+ - lib/params-registry.rb
43
+ - lib/params/registry.rb
44
+ - lib/params/registry/error.rb
45
+ - lib/params/registry/instance.rb
46
+ - lib/params/registry/template.rb
47
+ - lib/params/registry/types.rb
48
+ - lib/params/registry/version.rb
49
+ - params-registry.gemspec
50
+ - sig/params/registry.rbs
51
+ homepage: https://github.com/doriantaylor/rb-params-registry
52
+ licenses:
53
+ - Apache-2.0
54
+ metadata:
55
+ homepage_uri: https://github.com/doriantaylor/rb-params-registry
56
+ source_code_uri: https://github.com/doriantaylor/rb-params-registry
57
+ post_install_message:
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '2.7'
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 3.3.15
73
+ signing_key:
74
+ specification_version: 4
75
+ summary: 'Params::Registry: a registry for URI query parameters'
76
+ test_files: []
77
+ ...