spec_forge 0.6.0 → 0.7.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +112 -2
  3. data/README.md +133 -8
  4. data/flake.lock +3 -3
  5. data/flake.nix +3 -3
  6. data/lib/spec_forge/attribute/factory.rb +1 -1
  7. data/lib/spec_forge/callbacks.rb +9 -0
  8. data/lib/spec_forge/cli/docs/generate.rb +72 -0
  9. data/lib/spec_forge/cli/docs.rb +92 -0
  10. data/lib/spec_forge/cli/init.rb +39 -7
  11. data/lib/spec_forge/cli/new.rb +13 -3
  12. data/lib/spec_forge/cli/run.rb +12 -4
  13. data/lib/spec_forge/cli/serve.rb +155 -0
  14. data/lib/spec_forge/cli.rb +14 -6
  15. data/lib/spec_forge/configuration.rb +2 -2
  16. data/lib/spec_forge/context/store.rb +23 -40
  17. data/lib/spec_forge/core_ext/array.rb +27 -0
  18. data/lib/spec_forge/documentation/builder.rb +383 -0
  19. data/lib/spec_forge/documentation/document/operation.rb +47 -0
  20. data/lib/spec_forge/documentation/document/parameter.rb +22 -0
  21. data/lib/spec_forge/documentation/document/request_body.rb +24 -0
  22. data/lib/spec_forge/documentation/document/response.rb +39 -0
  23. data/lib/spec_forge/documentation/document/response_body.rb +27 -0
  24. data/lib/spec_forge/documentation/document.rb +48 -0
  25. data/lib/spec_forge/documentation/generators/base.rb +81 -0
  26. data/lib/spec_forge/documentation/generators/openapi/base.rb +100 -0
  27. data/lib/spec_forge/documentation/generators/openapi/error_formatter.rb +149 -0
  28. data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +65 -0
  29. data/lib/spec_forge/documentation/generators/openapi.rb +59 -0
  30. data/lib/spec_forge/documentation/generators.rb +17 -0
  31. data/lib/spec_forge/documentation/loader/cache.rb +138 -0
  32. data/lib/spec_forge/documentation/loader.rb +159 -0
  33. data/lib/spec_forge/documentation/openapi/base.rb +33 -0
  34. data/lib/spec_forge/documentation/openapi/v3_0/example.rb +44 -0
  35. data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +42 -0
  36. data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +175 -0
  37. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +65 -0
  38. data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +80 -0
  39. data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +71 -0
  40. data/lib/spec_forge/documentation/openapi.rb +23 -0
  41. data/lib/spec_forge/documentation.rb +27 -0
  42. data/lib/spec_forge/error.rb +17 -0
  43. data/lib/spec_forge/factory.rb +2 -2
  44. data/lib/spec_forge/filter.rb +3 -4
  45. data/lib/spec_forge/forge.rb +5 -4
  46. data/lib/spec_forge/http/backend.rb +2 -0
  47. data/lib/spec_forge/http/request.rb +14 -3
  48. data/lib/spec_forge/loader.rb +14 -24
  49. data/lib/spec_forge/normalizer/default.rb +51 -0
  50. data/lib/spec_forge/normalizer/definition.rb +248 -0
  51. data/lib/spec_forge/normalizer/validators.rb +99 -0
  52. data/lib/spec_forge/normalizer.rb +356 -199
  53. data/lib/spec_forge/normalizers/_shared.yml +74 -0
  54. data/lib/spec_forge/normalizers/configuration.yml +23 -0
  55. data/lib/spec_forge/normalizers/constraint.yml +8 -0
  56. data/lib/spec_forge/normalizers/expectation.yml +47 -0
  57. data/lib/spec_forge/normalizers/factory.yml +12 -0
  58. data/lib/spec_forge/normalizers/factory_reference.yml +15 -0
  59. data/lib/spec_forge/normalizers/global_context.yml +28 -0
  60. data/lib/spec_forge/normalizers/spec.yml +50 -0
  61. data/lib/spec_forge/runner/adapter.rb +183 -0
  62. data/lib/spec_forge/runner/debug_proxy.rb +3 -3
  63. data/lib/spec_forge/runner/state.rb +4 -5
  64. data/lib/spec_forge/runner.rb +40 -124
  65. data/lib/spec_forge/spec/expectation/constraint.rb +13 -5
  66. data/lib/spec_forge/spec/expectation.rb +7 -3
  67. data/lib/spec_forge/spec.rb +13 -58
  68. data/lib/spec_forge/version.rb +1 -1
  69. data/lib/spec_forge.rb +30 -23
  70. data/lib/templates/openapi.yml.tt +22 -0
  71. data/lib/templates/redoc.html.tt +28 -0
  72. data/lib/templates/swagger.html.tt +59 -0
  73. metadata +92 -14
  74. data/lib/spec_forge/normalizer/configuration.rb +0 -90
  75. data/lib/spec_forge/normalizer/constraint.rb +0 -60
  76. data/lib/spec_forge/normalizer/expectation.rb +0 -105
  77. data/lib/spec_forge/normalizer/factory.rb +0 -78
  78. data/lib/spec_forge/normalizer/factory_reference.rb +0 -85
  79. data/lib/spec_forge/normalizer/global_context.rb +0 -88
  80. data/lib/spec_forge/normalizer/spec.rb +0 -97
  81. /data/lib/templates/{forge_helper.tt → forge_helper.rb.tt} +0 -0
  82. /data/lib/templates/{new_factory.tt → new_factory.yml.tt} +0 -0
  83. /data/lib/templates/{new_spec.tt → new_spec.yml.tt} +0 -0
@@ -7,7 +7,15 @@ module SpecForge
7
7
  #
8
8
  # @return [Array<Symbol>]
9
9
  #
10
- REQUEST_ATTRIBUTES = [:base_url, :url, :http_verb, :headers, :query, :body].freeze
10
+ REQUEST_ATTRIBUTES = %i[
11
+ base_url
12
+ url
13
+ http_verb
14
+ content_type
15
+ headers
16
+ query
17
+ body
18
+ ].freeze
11
19
 
12
20
  #
13
21
  # Represents an HTTP request configuration
@@ -52,8 +60,9 @@ module SpecForge
52
60
  query = Attribute.from(options[:query] || {})
53
61
  body = Attribute.from(options[:body] || {})
54
62
  headers = normalize_headers(options[:headers] || {})
63
+ content_type = "application/json"
55
64
 
56
- super(base_url:, url:, http_verb:, headers:, query:, body:)
65
+ super(base_url:, url:, http_verb:, content_type:, headers:, query:, body:)
57
66
  end
58
67
 
59
68
  #
@@ -62,7 +71,9 @@ module SpecForge
62
71
  # @return [Hash] The request data with all dynamic values resolved
63
72
  #
64
73
  def to_h
65
- super.transform_values { |v| v.respond_to?(:resolved) ? v.resolved : v }
74
+ hash = super.transform_values { |v| v.respond_to?(:resolved) ? v.resolved : v }
75
+ hash[:http_verb] = hash[:http_verb].to_s
76
+ hash
66
77
  end
67
78
 
68
79
  private
@@ -23,14 +23,14 @@ module SpecForge
23
23
  load_specs_from_files.map do |global, metadata, specs|
24
24
  global =
25
25
  begin
26
- Normalizer.normalize_global_context!(global)
26
+ Normalizer.normalize!(global, using: :global_context)
27
27
  rescue => e
28
28
  raise Error::SpecLoadError.new(e, metadata[:relative_path])
29
29
  end
30
30
 
31
31
  specs =
32
32
  specs.map do |spec|
33
- Normalizer.normalize_spec!(spec, label: "spec \"#{spec[:name]}\"")
33
+ Normalizer.normalize!(spec, using: :spec, label: "spec \"#{spec[:name]}\"")
34
34
  rescue => e
35
35
  raise Error::SpecLoadError.new(e, metadata[:relative_path], spec:)
36
36
  end
@@ -86,7 +86,7 @@ module SpecForge
86
86
  files.map do |file_path, content|
87
87
  relative_path = Pathname.new(file_path).relative_path_from(base_path)
88
88
 
89
- hash = YAML.load(content).deep_symbolize_keys
89
+ hash = YAML.safe_load(content, symbolize_names: true)
90
90
 
91
91
  file_line_numbers = extract_line_numbers(content, hash)
92
92
 
@@ -103,7 +103,7 @@ module SpecForge
103
103
  hash.map do |spec_name, spec_hash|
104
104
  line_number, *expectation_line_numbers = file_line_numbers[spec_name]
105
105
 
106
- spec_hash[:id] = "spec_#{generate_id(spec_hash)}"
106
+ spec_hash[:id] = "spec_#{SpecForge.generate_id(spec_hash)}"
107
107
  spec_hash[:name] = spec_name.to_s
108
108
  spec_hash[:file_path] = metadata[:file_path]
109
109
  spec_hash[:file_name] = metadata[:file_name]
@@ -112,7 +112,7 @@ module SpecForge
112
112
  # Check for expectations instead of defaulting. I want it to error
113
113
  if (expectations = spec_hash[:expectations])
114
114
  expectations.zip(expectation_line_numbers) do |expectation_hash, line_number|
115
- expectation_hash[:id] = "expect_#{generate_id(expectation_hash)}"
115
+ expectation_hash[:id] = "expect_#{SpecForge.generate_id(expectation_hash)}"
116
116
  expectation_hash[:name] = build_expectation_name(spec_hash, expectation_hash)
117
117
  expectation_hash[:line_number] = line_number
118
118
  end
@@ -191,19 +191,6 @@ module SpecForge
191
191
  keys
192
192
  end
193
193
 
194
- #
195
- # Generates a unique ID for an object based on hash and object_id
196
- #
197
- # @param object [Object] The object to generate an ID for
198
- #
199
- # @return [String] A unique ID string
200
- #
201
- # @private
202
- #
203
- def generate_id(object)
204
- "#{object.hash.abs.to_s(36)}_#{object.object_id.to_s(36)}"
205
- end
206
-
207
194
  #
208
195
  # Builds a name for an expectation based on HTTP verb, URL, and optional name
209
196
  #
@@ -215,14 +202,17 @@ module SpecForge
215
202
  # @private
216
203
  #
217
204
  def build_expectation_name(spec_hash, expectation_hash)
218
- # Create a structure for these two attributes
219
- # Removing the defaults and validators to avoid issues
220
- structure = Normalizer::SHARED_ATTRIBUTES.slice(:http_verb, :url)
205
+ # Create a structure for http_verb and url
206
+ # Removing the defaults and validators to avoid triggering extra logic
207
+ structure = Normalizer.structures[:spec][:structure].slice(:http_verb, :url)
221
208
  .transform_values { |v| v.except(:default, :validator) }
222
209
 
223
- # Ignore any errors. It'll be caught above anyway
224
- normalized_spec, _errors = Normalizer.new("", spec_hash, structure:).normalize
225
- normalized_expectation, _errors = Normalizer.new("", expectation_hash, structure:).normalize
210
+ # Ignore any errors. These will be validated later
211
+ normalized_spec, _ = Normalizer.normalize(spec_hash, using: structure, label: "n/a")
212
+ normalized_expectation, _ = Normalizer.normalize(
213
+ expectation_hash,
214
+ using: structure, label: "n/a"
215
+ )
226
216
 
227
217
  request_data = normalized_spec.deep_merge(normalized_expectation)
228
218
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Normalizer
5
+ #
6
+ # Provides default value generation for Normalizer structures
7
+ #
8
+ # Contains helper methods for creating default values based on type
9
+ # definitions and structure specifications.
10
+ #
11
+ module Default
12
+ private
13
+
14
+ def default_from_structure(structure, include_optional: false)
15
+ structure.each_with_object({}) do |(attribute_name, attribute), hash|
16
+ type = attribute[:type]
17
+ has_default = attribute.key?(:default)
18
+ required = attribute[:required] != false
19
+
20
+ next if !(include_optional || required || has_default)
21
+
22
+ hash[attribute_name] =
23
+ if has_default
24
+ default = attribute[:default]
25
+ next if default.nil?
26
+
27
+ default.dup
28
+ elsif type.instance_of?(Array)
29
+ default_value_for_type(type.first)
30
+ else
31
+ default_value_for_type(type)
32
+ end
33
+ end
34
+ end
35
+
36
+ def default_value_for_type(type_class)
37
+ if type_class == Integer
38
+ 0
39
+ elsif type_class == Proc
40
+ -> {}
41
+ elsif type_class == TrueClass
42
+ true
43
+ elsif type_class == FalseClass
44
+ false
45
+ else
46
+ type_class.new
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Normalizer
5
+ #
6
+ # Manages structure definitions for the Normalizer
7
+ #
8
+ # Handles loading structure definitions from YAML files, processing references
9
+ # between structures, and normalizing structure formats for consistent validation.
10
+ #
11
+ # @example Loading all structure definitions
12
+ # structures = SpecForge::Normalizer::Definition.from_files
13
+ #
14
+ class Definition
15
+ #
16
+ # Mapping of structure names to their human-readable labels
17
+ #
18
+ # @return [Hash<Symbol, String>]
19
+ #
20
+ LABELS = {
21
+ factory_reference: "factory reference",
22
+ global_context: "global context"
23
+ }.freeze
24
+
25
+ #
26
+ # Core structure definition used to validate other structures
27
+ #
28
+ # Defines the valid attributes and types for structure definitions,
29
+ # creating a meta-structure that validates other structure definitions.
30
+ #
31
+ # @return [Hash]
32
+ #
33
+ STRUCTURE = {
34
+ type: {
35
+ type: [String, Array, Class],
36
+ default: nil,
37
+ validator: :present?
38
+ },
39
+ default: {
40
+ type: [String, NilClass, Numeric, Array, Hash, TrueClass, FalseClass],
41
+ required: false
42
+ },
43
+ required: {
44
+ type: [TrueClass, FalseClass],
45
+ required: false
46
+ },
47
+ aliases: {
48
+ type: Array,
49
+ required: false,
50
+ structure: {type: String}
51
+ },
52
+ structure: {
53
+ type: Hash,
54
+ required: false
55
+ },
56
+ validator: {
57
+ type: String,
58
+ required: false
59
+ }
60
+ }.freeze
61
+
62
+ #
63
+ # Loads normalizer definitions from YAML files
64
+ #
65
+ # Reads all YAML files in the normalizers directory, processes shared
66
+ # references, and prepares them for use by the Normalizer.
67
+ #
68
+ # @return [Hash] A hash mapping structure names to their definitions
69
+ #
70
+ def self.from_files
71
+ base_path = Pathname.new(File.expand_path("../normalizers", __dir__))
72
+ paths = Dir[base_path.join("**/*.yml")].sort
73
+
74
+ normalizers =
75
+ paths.each_with_object({}) do |path, hash|
76
+ path = Pathname.new(path)
77
+
78
+ # Include the directory name in the path to include normalizers in directories
79
+ name = path.relative_path_from(base_path).to_s.delete_suffix(".yml").to_sym
80
+
81
+ input = YAML.safe_load_file(path, symbolize_names: true)
82
+ raise Error, "Normalizer defined at #{path.to_s.in_quotes} is empty" if input.blank?
83
+
84
+ hash[name] = new(input, label: LABELS[name] || name.to_s.humanize.downcase)
85
+ end
86
+
87
+ # Pull the shared structures and prepare it
88
+ structures = normalizers.delete(:_shared).normalize
89
+
90
+ # Merge in the normalizers to allow referencing other normalizers
91
+ structures.merge!(normalizers.transform_values(&:input))
92
+
93
+ # Now prepare all of the other definitions with access to references
94
+ normalizers.transform_values!(with_key: true) do |definition, name|
95
+ structure = definition.normalize(structures)
96
+
97
+ {
98
+ label: definition.label,
99
+ structure:
100
+ }
101
+ end
102
+
103
+ normalizers
104
+ end
105
+
106
+ ##########################################################################
107
+
108
+ attr_reader :input, :label
109
+
110
+ def initialize(input, label: "")
111
+ @input = input
112
+ @label = label
113
+ end
114
+
115
+ #
116
+ # Normalizes a structure definition
117
+ #
118
+ # Processes references, resolves types, and ensures all attributes
119
+ # have a consistent format for validation.
120
+ #
121
+ # @param shared_structures [Hash] Optional shared structures for resolving references
122
+ #
123
+ # @return [Hash] The normalized structure definition
124
+ #
125
+ def normalize(shared_structures = {})
126
+ hash = @input.deep_dup
127
+
128
+ # First, we'll deeply replace any references
129
+ replace_references(hash, shared_structures)
130
+
131
+ # Second, normalize the root level keys
132
+ hash.transform_values!(with_key: true) do |attribute, name|
133
+ next if STRUCTURE.key?(name)
134
+
135
+ normalize_attribute(name, attribute)
136
+ end
137
+
138
+ # Third, normalize the underlying structures
139
+ hash.each do |name, attribute|
140
+ next unless attribute.is_a?(Hash)
141
+
142
+ structure = attribute[:structure]
143
+ next if structure.blank?
144
+
145
+ attribute[:structure] = normalize_structure(name, attribute)
146
+ end
147
+
148
+ hash
149
+ end
150
+
151
+ private
152
+
153
+ def replace_references(attributes, shared_structures)
154
+ return if shared_structures.blank?
155
+
156
+ # The goal is to walk down the hash and recursively replace any references
157
+ attributes.each do |attribute_name, attribute|
158
+ # Replace the top level reference
159
+ replace_with_reference(attribute_name, attribute, shared_structures:)
160
+ next unless attribute.is_a?(Hash) && attribute[:structure].present?
161
+
162
+ # Allow structures to reference other structures
163
+ if attribute.dig(:structure, :reference)
164
+ replace_with_reference(
165
+ "#{attribute_name}'s structure",
166
+ attribute[:structure],
167
+ shared_structures:
168
+ )
169
+ end
170
+
171
+ # Recursively replace any structures that have references
172
+ if [Array, "array"].include?(attribute[:type])
173
+ result = replace_references(attribute.slice(:structure), shared_structures)
174
+ attribute.merge!(result)
175
+ elsif [Hash, "hash"].include?(attribute[:type])
176
+ replace_references(attribute[:structure], shared_structures)
177
+ end
178
+ end
179
+ end
180
+
181
+ def replace_with_reference(attribute_name, attribute, shared_structures: {})
182
+ return unless attribute.is_a?(Hash) && attribute.key?(:reference)
183
+
184
+ reference_name = attribute.delete(:reference)
185
+ reference = shared_structures[reference_name.to_sym]
186
+
187
+ if reference.nil?
188
+ structures_names = shared_structures.keys.map(&:in_quotes).to_or_sentence
189
+
190
+ raise Error, "Attribute #{attribute_name.in_quotes}: Invalid reference name. Got #{reference_name&.in_quotes}, expected one of #{structures_names} in #{@label}"
191
+ end
192
+
193
+ # Allows overwriting data on the reference
194
+ attribute.reverse_merge!(reference)
195
+ end
196
+
197
+ def normalize_attribute(attribute_name, attribute)
198
+ case attribute
199
+ when String, Array # Array is multiple types
200
+ hash = {type: resolve_type(attribute)}
201
+
202
+ default = Normalizer.default(structure: STRUCTURE)
203
+ hash.merge!(default)
204
+ when Hash
205
+ hash = Normalizer.raise_errors! do
206
+ Normalizer.new(
207
+ "#{attribute_name.in_quotes} in #{@label}",
208
+ attribute,
209
+ structure: STRUCTURE
210
+ ).normalize
211
+ end
212
+
213
+ hash[:type] = resolve_type(attribute[:type])
214
+
215
+ if hash[:structure].present?
216
+ hash[:structure] = normalize_structure(attribute_name, hash) || {}
217
+ end
218
+
219
+ hash
220
+ else
221
+ raise ArgumentError, "Attribute #{attribute_name.in_quotes}: Expected String or Hash, got #{attribute.inspect}"
222
+ end
223
+ end
224
+
225
+ def normalize_structure(name, hash)
226
+ if hash[:type] == Array
227
+ normalize_attribute(name, hash[:structure])
228
+ elsif hash[:type] == Hash
229
+ hash[:structure].transform_values(with_key: true) { |v, k| normalize_attribute(k, v) }
230
+ end
231
+ end
232
+
233
+ def resolve_type(type)
234
+ if type == "boolean"
235
+ [TrueClass, FalseClass]
236
+ elsif type.instance_of?(Array)
237
+ type.map { |t| resolve_type(t) }
238
+ elsif type.is_a?(String)
239
+ type.classify.constantize
240
+ else
241
+ type
242
+ end
243
+ rescue NameError => e
244
+ raise Error, "#{e}. #{type.inspect} is not a valid type found in #{@label}"
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Normalizer
5
+ #
6
+ # Provides validation methods for Normalizer structures
7
+ #
8
+ # Contains validation functions that can be referenced by name
9
+ # in structure definitions to perform custom validation logic.
10
+ #
11
+ # @example Validator in a structure definition
12
+ # http_verb: {type: String, validator: :http_verb}
13
+ #
14
+ class Validators
15
+ #
16
+ # Calls a validator method with the provided value and context
17
+ #
18
+ # @param method_name [Symbol, String] The validator method to call
19
+ # @param value [Object] The value to validate
20
+ # @param label [String] A descriptive label for error messages
21
+ #
22
+ # @return [void]
23
+ #
24
+ # @raise [Error] If validation fails
25
+ #
26
+ def self.call(method_name, value, label:)
27
+ new(label).public_send(method_name, value)
28
+ end
29
+
30
+ #
31
+ # Initializes a new validator instance with a context label
32
+ #
33
+ # @param label [String] A descriptive label for error messages
34
+ #
35
+ # @return [Validators] A new validator instance
36
+ #
37
+ def initialize(label)
38
+ @label = label
39
+ end
40
+
41
+ #
42
+ # Validates that a value is not blank
43
+ #
44
+ # Ensures the provided value is not nil, empty, or contains only whitespace.
45
+ # This validator is useful for required fields that must have meaningful content.
46
+ #
47
+ # @param value [Object] The value to validate
48
+ #
49
+ # @raise [Error] If the value is blank
50
+ #
51
+ # @example Using the validator in a structure
52
+ # name: {type: String, validator: :present?}
53
+ #
54
+ def present?(value)
55
+ raise Error, "Value cannot be blank for #{@label}" if value.blank?
56
+ end
57
+
58
+ #
59
+ # Validates that a value is a supported HTTP verb
60
+ #
61
+ # Ensures the provided value is one of the supported HTTP methods
62
+ # (GET, POST, PUT, PATCH, DELETE). Case-insensitive matching is used.
63
+ #
64
+ # @param value [String, Symbol, nil] The HTTP verb to validate, or nil
65
+ #
66
+ # @raise [Error] If the value is not a supported HTTP verb
67
+ #
68
+ # @example Using the validator in a structure
69
+ # http_verb: {type: String, validator: :http_verb}
70
+ #
71
+ def http_verb(value)
72
+ valid_verbs = HTTP::Verb::VERBS.values.map(&:to_s)
73
+ return if value.blank? || valid_verbs.include?(value.to_s.upcase)
74
+
75
+ raise Error, "Invalid HTTP verb #{value.in_quotes} for #{@label}. Valid values are: #{valid_verbs.join_map(", ", &:in_quotes)}"
76
+ end
77
+
78
+ #
79
+ # Validates that a callback is registered in the system
80
+ #
81
+ # Ensures the referenced callback name has been registered with SpecForge
82
+ # before it's used in a test configuration.
83
+ #
84
+ # @param value [String, Symbol, nil] The callback name to validate, or nil
85
+ #
86
+ # @raise [Error::UndefinedCallbackError] If the callback is not registered
87
+ #
88
+ # @example Using the validator in a structure
89
+ # before_file: {type: String, validator: :callback}
90
+ #
91
+ def callback(value)
92
+ return if value.blank?
93
+ return if SpecForge::Callbacks.registered?(value)
94
+
95
+ raise Error::UndefinedCallbackError.new(value, SpecForge::Callbacks.registered_names)
96
+ end
97
+ end
98
+ end
99
+ end