active_typed_params 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,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveTypedParams
4
+ class Schema
5
+ OPTIONS = %i[unknown required memoize root source].freeze
6
+ UNKNOWN_MODES = %i[ignore report reject].freeze
7
+ MUTEX = Mutex.new
8
+
9
+ attr_reader :owner, :name, :options, :compiled_module
10
+
11
+ def initialize(name:, owner: nil, options: {}, block: nil, nodes: nil, module_name: nil)
12
+ raise SchemaError, "typed_params #{name.inspect} needs a block" if block.nil? && nodes.nil?
13
+
14
+ unknown = options.keys - OPTIONS
15
+ raise SchemaError, "typed_params #{name.inspect} does not accept #{unknown.inspect}" if unknown.any?
16
+ unless UNKNOWN_MODES.include?(options.fetch(:unknown, :ignore))
17
+ raise SchemaError, "typed_params #{name.inspect} unknown: must be one of #{UNKNOWN_MODES.inspect}"
18
+ end
19
+
20
+ raise SchemaError, "typed_params #{name.inspect} source: must be a proc" if options.key?(:source) && !options[:source].is_a?(Proc)
21
+
22
+ @owner = owner
23
+ @name = name.to_sym
24
+ @options = options.freeze
25
+ @block = block
26
+ @nodes = nodes&.freeze
27
+ @module_name = module_name
28
+ end
29
+
30
+ # With `root:`, the block declares the keys of that required hash, as
31
+ # `params.require(:root).permit(...)` does, and the result is the inner hash.
32
+ def nodes
33
+ @nodes ||= begin
34
+ nodes = Builder.run(@block, required: options.fetch(:required, false), owner:)
35
+ root ? [Node.build(key: root, type: :hash, required: true, children: nodes)] : nodes
36
+ end
37
+ end
38
+
39
+ def root
40
+ options[:root]&.to_sym
41
+ end
42
+
43
+ # A proc run on the controller for the params to validate, such as
44
+ # `-> { params.fetch(:contact, {}) }`, instead of all of `params`.
45
+ def params_source
46
+ options[:source]
47
+ end
48
+
49
+ def unknown_mode
50
+ options.fetch(:unknown, :ignore)
51
+ end
52
+
53
+ # One line per key with its type, nested keys indented and `?` marking an
54
+ # optional key, so a snapshot shows what a request can set.
55
+ def describe
56
+ Schema.describe_nodes(nodes, 0).join("\n")
57
+ end
58
+
59
+ def self.describe_nodes(nodes, depth)
60
+ nodes.flat_map { |node| ["#{" " * depth}#{node.key}: #{describe_type(node)}#{"?" unless node.required}", *describe_children(node, depth + 1)] }
61
+ end
62
+
63
+ def self.describe_type(node)
64
+ case node.type
65
+ when :array then "array(#{describe_type(node.item)}#{describe_cap(node.max_length)})"
66
+ when :map then "map(#{node.map_key.type} => #{describe_type(node.item)}#{describe_cap(node.max_length)})"
67
+ when :hash then node.open ? "hash(open)" : "hash"
68
+ when :one_of then "one_of(#{node.children.map { |alternative| describe_type(alternative) }.join(", ")})"
69
+ when :string then describe_string(node.max_length)
70
+ else node.type.to_s
71
+ end
72
+ end
73
+
74
+ def self.describe_cap(max_length)
75
+ case max_length
76
+ when Builder::DEFAULT_COLLECTION_MAX_LENGTH then ""
77
+ when nil then ", uncapped"
78
+ else ", #{max_length}"
79
+ end
80
+ end
81
+
82
+ def self.describe_string(max_length)
83
+ case max_length
84
+ when Builder::DEFAULT_STRING_MAX_LENGTH then "string"
85
+ when ActiveTypedParams.config.text_max_length then "text"
86
+ when nil then "string(uncapped)"
87
+ else "string(#{max_length})"
88
+ end
89
+ end
90
+
91
+ def self.describe_children(node, depth)
92
+ holder = (node.array? || node.map?) ? node.item : node
93
+ holder.hash? ? describe_nodes(holder.children, depth) : []
94
+ end
95
+
96
+ def memoize?
97
+ options.fetch(:memoize, false)
98
+ end
99
+
100
+ def memo_ivar
101
+ "@__typed_params_#{name}"
102
+ end
103
+
104
+ # Validates params outside a controller, as a definition owned by a plain
105
+ # object does. Procs and conditions run on the context, which also names
106
+ # the report.
107
+ def validate(params, context: nil, request: nil)
108
+ call(context || Object.new, params, request)
109
+ end
110
+
111
+ def source
112
+ (@compiler || Compiler.new(self)).source
113
+ end
114
+
115
+ def installed?
116
+ @installed
117
+ end
118
+
119
+ # Defines the real method on a module included in the owner after the stub
120
+ # module, so it takes precedence over the stub while an override on the
121
+ # owner or a subclass still reaches it with `super`.
122
+ def install!
123
+ return if @installed
124
+
125
+ MUTEX.synchronize do
126
+ return if @installed
127
+
128
+ @compiled_module = compile
129
+ @method = @compiled_module.instance_method(name)
130
+ owner&.include(@compiled_module)
131
+ @compiler = nil
132
+ @installed = true
133
+ end
134
+ end
135
+
136
+ # Runs the installed method on any object, bypassing an override on a
137
+ # controller; a definition validates whatever params it is given.
138
+ def call(context, *arguments)
139
+ install!
140
+ @method.bind_call(context, *arguments)
141
+ end
142
+
143
+ def request_available?
144
+ return @request_available if defined?(@request_available)
145
+
146
+ @request_available = case owner
147
+ when nil then false
148
+ when Class then owner.method_defined?(:request) || owner.private_method_defined?(:request)
149
+ else true
150
+ end
151
+ end
152
+
153
+ def module_name
154
+ @module_name ||= begin
155
+ base = owner.nil? ? "Definition" : (owner.name || "Anonymous_#{owner.object_id}")
156
+ "#{base.gsub("::", "_")}__#{name}"
157
+ end
158
+ end
159
+
160
+ private
161
+
162
+ def compiler
163
+ @compiler ||= Compiler.new(self)
164
+ end
165
+
166
+ def compile
167
+ generated = compiler.source
168
+ Module.new.tap do |mod|
169
+ mod.set_temporary_name("active_typed_params/#{module_name}")
170
+ compiler.constants.each { |constant, value| mod.const_set(constant, value) }
171
+ mod.module_eval(generated, "active_typed_params/#{module_name}", 1)
172
+ mod.send(:private, name)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveTypedParams
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "bigdecimal"
6
+ require "date"
7
+ require "time"
8
+ require "active_support"
9
+ require "active_support/core_ext/hash/indifferent_access"
10
+ require "active_support/notifications"
11
+ require "action_controller"
12
+
13
+ require "active_typed_params/version"
14
+ require "active_typed_params/errors"
15
+ require "active_typed_params/node"
16
+ require "active_typed_params/enum_adapter"
17
+ require "active_typed_params/runtime"
18
+ require "active_typed_params/declaration"
19
+ require "active_typed_params/builder"
20
+ require "active_typed_params/compiler"
21
+ require "active_typed_params/schema"
22
+ require "active_typed_params/inline"
23
+ require "active_typed_params/controller"
24
+ require "active_typed_params/inference"
25
+ require "active_typed_params/railtie" if defined?(Rails::Railtie)
26
+
27
+ # Typed, compiled request parameters: see the `typed_params` controller DSL.
28
+ module ActiveTypedParams
29
+ class Configuration
30
+ # Each adapter receives the source given to `enum` and returns a Hash from
31
+ # raw request value to returned value, or nil when it does not handle it.
32
+ attr_accessor :enum_adapters
33
+
34
+ attr_accessor :file_classes
35
+
36
+ # The cap for `text` and for strings inference leaves to the model or the
37
+ # interaction: long, but bounded.
38
+ attr_accessor :text_max_length
39
+
40
+ # Each inferrer receives an ActiveModel attribute type and returns a
41
+ # Declaration, or nil when it does not handle it.
42
+ attr_accessor :type_inferrers
43
+
44
+ # Filter names `inputs_from` never declares, for inputs every interaction shares.
45
+ attr_accessor :ignored_filters
46
+
47
+ # Each inferrer receives an ActiveInteraction filter and returns a
48
+ # Declaration, or nil when it does not handle it.
49
+ attr_accessor :filter_inferrers
50
+
51
+ def initialize
52
+ @enum_adapters = []
53
+ @type_inferrers = []
54
+ @ignored_filters = []
55
+ @filter_inferrers = []
56
+ @text_max_length = 65_535
57
+ @file_classes = []
58
+ @file_classes << ActionDispatch::Http::UploadedFile if defined?(ActionDispatch::Http::UploadedFile)
59
+ @file_classes << Rack::Test::UploadedFile if defined?(Rack::Test::UploadedFile)
60
+ end
61
+ end
62
+
63
+ class << self
64
+ def config
65
+ @config ||= Configuration.new
66
+ end
67
+
68
+ def configure
69
+ yield config
70
+ end
71
+
72
+ # A definition survives code reloading, so one declared in an initializer
73
+ # must not reference reloadable classes, such as models or enums. Declare
74
+ # it beside the model instead, where reloading declares it again.
75
+ def define(name, **options, &block)
76
+ definitions[name.to_sym] = Schema.new(name:, options:, block:)
77
+ end
78
+
79
+ # Keys to merge with `use`, kept beside the model they describe, with no
80
+ # method and no global name: `IDEA_PARAMS = ActiveTypedParams.fragment { … }`.
81
+ def fragment(&block)
82
+ Schema.new(name: :fragment, block:)
83
+ end
84
+
85
+ def definition(name)
86
+ definitions.fetch(name.to_sym) { raise SchemaError, "no schema defined as #{name.inspect}" }
87
+ end
88
+
89
+ def register(schema)
90
+ (registry[schema.owner] ||= {})[schema.name] = schema
91
+ end
92
+
93
+ def schema(owner, name)
94
+ registry.dig(owner, name.to_sym) || raise(SchemaError, "#{owner} declares no typed_params #{name.inspect}")
95
+ end
96
+
97
+ # A schema declared on the owner or one of its ancestors, or else a global definition.
98
+ def find_schema(owner, name)
99
+ owner&.ancestors&.each do |ancestor|
100
+ schema = registry.dig(ancestor, name.to_sym)
101
+ return schema if schema
102
+ end
103
+ definition(name)
104
+ end
105
+
106
+ def source(owner, name)
107
+ schema(owner, name).source
108
+ end
109
+
110
+ def schemas
111
+ registry.values.flat_map(&:values)
112
+ end
113
+
114
+ # Every schema a controller or a global definition declares, as a snapshot spec lists them.
115
+ def all_schemas
116
+ schemas + definitions.values
117
+ end
118
+
119
+ def compile_all!
120
+ all_schemas.each(&:install!)
121
+ end
122
+
123
+ def inline_schema(key)
124
+ inline_schemas[key] ||= yield
125
+ end
126
+
127
+ # Code reloading replaces controller classes, which register their schemas
128
+ # again on load; schemas registered with `define` live in initializers and stay.
129
+ def unload!
130
+ registry.clear
131
+ inline_schemas.clear
132
+ end
133
+
134
+ private
135
+
136
+ def registry
137
+ @registry ||= {}
138
+ end
139
+
140
+ def definitions
141
+ @definitions ||= {}
142
+ end
143
+
144
+ def inline_schemas
145
+ @inline_schemas ||= {}
146
+ end
147
+ end
148
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_typed_params
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Máximo Mussini
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionpack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ description: Declare the keys and types a controller action accepts. Bad requests
41
+ are rejected with a 400 that names the parameter, and each declaration is compiled
42
+ into a single method, several times faster than params.permit.
43
+ email:
44
+ - maximomussini@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files:
48
+ - README.md
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - lib/active_typed_params.rb
54
+ - lib/active_typed_params/builder.rb
55
+ - lib/active_typed_params/compiler.rb
56
+ - lib/active_typed_params/controller.rb
57
+ - lib/active_typed_params/declaration.rb
58
+ - lib/active_typed_params/enum_adapter.rb
59
+ - lib/active_typed_params/errors.rb
60
+ - lib/active_typed_params/inference.rb
61
+ - lib/active_typed_params/inline.rb
62
+ - lib/active_typed_params/node.rb
63
+ - lib/active_typed_params/railtie.rb
64
+ - lib/active_typed_params/runtime.rb
65
+ - lib/active_typed_params/schema.rb
66
+ - lib/active_typed_params/version.rb
67
+ homepage: https://github.com/ElMassimo/active_typed_params
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ source_code_uri: https://github.com/ElMassimo/active_typed_params
72
+ changelog_uri: https://github.com/ElMassimo/active_typed_params/blob/main/CHANGELOG.md
73
+ allowed_push_host: https://rubygems.org
74
+ rubygems_mfa_required: 'true'
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 3.3.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 4.0.20
90
+ specification_version: 4
91
+ summary: Typed strong parameters for Rails controllers, compiled into plain Ruby methods.
92
+ test_files: []