params-registry 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+ ...