spec_forge 0.7.1 → 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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -1
  3. data/README.md +124 -202
  4. data/bin/spec_forge +1 -1
  5. data/flake.lock +76 -4
  6. data/flake.nix +5 -4
  7. data/lib/spec_forge/attribute/chainable.rb +6 -6
  8. data/lib/spec_forge/attribute/environment.rb +45 -0
  9. data/lib/spec_forge/attribute/factory.rb +26 -17
  10. data/lib/spec_forge/attribute/faker.rb +6 -1
  11. data/lib/spec_forge/attribute/generate.rb +114 -0
  12. data/lib/spec_forge/attribute/literal.rb +1 -14
  13. data/lib/spec_forge/attribute/matcher.rb +6 -2
  14. data/lib/spec_forge/attribute/parameterized.rb +20 -22
  15. data/lib/spec_forge/attribute/resolvable_array.rb +16 -16
  16. data/lib/spec_forge/attribute/resolvable_hash.rb +17 -16
  17. data/lib/spec_forge/attribute/resolvable_struct.rb +67 -0
  18. data/lib/spec_forge/attribute/template.rb +118 -0
  19. data/lib/spec_forge/attribute/transform.rb +14 -19
  20. data/lib/spec_forge/attribute/variable.rb +31 -31
  21. data/lib/spec_forge/attribute.rb +54 -100
  22. data/lib/spec_forge/blueprint.rb +27 -0
  23. data/lib/spec_forge/cli/docs/generate.rb +28 -8
  24. data/lib/spec_forge/cli/docs.rb +5 -2
  25. data/lib/spec_forge/cli/init.rb +4 -4
  26. data/lib/spec_forge/cli/new.rb +78 -27
  27. data/lib/spec_forge/cli/run.rb +84 -52
  28. data/lib/spec_forge/cli/serve.rb +5 -0
  29. data/lib/spec_forge/cli.rb +6 -14
  30. data/lib/spec_forge/configuration.rb +209 -79
  31. data/lib/spec_forge/documentation/{loader → builder}/cache.rb +26 -23
  32. data/lib/spec_forge/documentation/builder/compiler.rb +373 -0
  33. data/lib/spec_forge/documentation/builder/extractor.rb +75 -0
  34. data/lib/spec_forge/documentation/builder.rb +77 -329
  35. data/lib/spec_forge/documentation/document/operation.rb +4 -4
  36. data/lib/spec_forge/documentation/document.rb +0 -6
  37. data/lib/spec_forge/documentation/generator.rb +88 -0
  38. data/lib/spec_forge/documentation/{generators/openapi → openapi/v3_0}/error_formatter.rb +2 -2
  39. data/lib/spec_forge/documentation/openapi/v3_0/example.rb +1 -1
  40. data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +1 -1
  41. data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +21 -5
  42. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +28 -6
  43. data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +20 -2
  44. data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +1 -1
  45. data/lib/spec_forge/documentation/openapi/v3_0.rb +116 -0
  46. data/lib/spec_forge/documentation/openapi.rb +40 -12
  47. data/lib/spec_forge/documentation.rb +1 -7
  48. data/lib/spec_forge/error.rb +215 -41
  49. data/lib/spec_forge/factory.rb +38 -18
  50. data/lib/spec_forge/forge/action.rb +41 -0
  51. data/lib/spec_forge/forge/actions/call.rb +33 -0
  52. data/lib/spec_forge/forge/actions/debug.rb +47 -0
  53. data/lib/spec_forge/forge/actions/expect.rb +44 -0
  54. data/lib/spec_forge/forge/actions/request.rb +65 -0
  55. data/lib/spec_forge/forge/actions/store.rb +31 -0
  56. data/lib/spec_forge/forge/callbacks.rb +80 -0
  57. data/lib/spec_forge/forge/context.rb +41 -0
  58. data/lib/spec_forge/forge/display.rb +503 -0
  59. data/lib/spec_forge/forge/hooks.rb +131 -0
  60. data/lib/spec_forge/forge/runner/array_io.rb +81 -0
  61. data/lib/spec_forge/forge/runner/content_validator.rb +92 -0
  62. data/lib/spec_forge/forge/runner/header_validator.rb +66 -0
  63. data/lib/spec_forge/forge/runner/reporter.rb +56 -0
  64. data/lib/spec_forge/forge/runner/schema_validator.rb +113 -0
  65. data/lib/spec_forge/forge/runner.rb +118 -0
  66. data/lib/spec_forge/forge/timer.rb +94 -0
  67. data/lib/spec_forge/forge/variables.rb +38 -0
  68. data/lib/spec_forge/forge.rb +207 -133
  69. data/lib/spec_forge/http/backend.rb +49 -146
  70. data/lib/spec_forge/http/client.rb +14 -17
  71. data/lib/spec_forge/http/request.rb +37 -84
  72. data/lib/spec_forge/http/verb.rb +4 -0
  73. data/lib/spec_forge/http.rb +0 -5
  74. data/lib/spec_forge/loader/filter.rb +85 -0
  75. data/lib/spec_forge/loader/step_processor.rb +282 -0
  76. data/lib/spec_forge/loader.rb +105 -220
  77. data/lib/spec_forge/normalizer/default.rb +1 -1
  78. data/lib/spec_forge/normalizer/structure.rb +140 -0
  79. data/lib/spec_forge/normalizer/transformers.rb +168 -0
  80. data/lib/spec_forge/normalizer/validators.rb +50 -8
  81. data/lib/spec_forge/normalizer.rb +76 -119
  82. data/lib/spec_forge/normalizers/callback.yml +38 -0
  83. data/lib/spec_forge/normalizers/configuration.yml +59 -9
  84. data/lib/spec_forge/normalizers/factory.yml +53 -2
  85. data/lib/spec_forge/normalizers/factory_reference.yml +63 -2
  86. data/lib/spec_forge/normalizers/json_schema.yml +79 -0
  87. data/lib/spec_forge/normalizers/step.yml +506 -0
  88. data/lib/spec_forge/step/call.rb +36 -0
  89. data/lib/spec_forge/step/expect.rb +110 -0
  90. data/lib/spec_forge/step/source.rb +22 -0
  91. data/lib/spec_forge/step.rb +129 -0
  92. data/lib/spec_forge/type.rb +115 -66
  93. data/lib/spec_forge/version.rb +1 -1
  94. data/lib/spec_forge.rb +44 -106
  95. data/lib/templates/forge_helper.rb.tt +43 -22
  96. data/lib/templates/new_blueprint.yml.tt +54 -0
  97. metadata +75 -44
  98. data/lib/spec_forge/attribute/global.rb +0 -96
  99. data/lib/spec_forge/attribute/store.rb +0 -65
  100. data/lib/spec_forge/backtrace_formatter.rb +0 -50
  101. data/lib/spec_forge/callbacks.rb +0 -88
  102. data/lib/spec_forge/context/callbacks.rb +0 -91
  103. data/lib/spec_forge/context/global.rb +0 -72
  104. data/lib/spec_forge/context/store.rb +0 -131
  105. data/lib/spec_forge/context/variables.rb +0 -91
  106. data/lib/spec_forge/context.rb +0 -36
  107. data/lib/spec_forge/core_ext/rspec.rb +0 -55
  108. data/lib/spec_forge/core_ext.rb +0 -5
  109. data/lib/spec_forge/documentation/generators/base.rb +0 -81
  110. data/lib/spec_forge/documentation/generators/openapi/base.rb +0 -100
  111. data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +0 -65
  112. data/lib/spec_forge/documentation/generators/openapi.rb +0 -59
  113. data/lib/spec_forge/documentation/generators.rb +0 -17
  114. data/lib/spec_forge/documentation/loader.rb +0 -159
  115. data/lib/spec_forge/documentation/openapi/base.rb +0 -33
  116. data/lib/spec_forge/filter.rb +0 -86
  117. data/lib/spec_forge/normalizer/definition.rb +0 -248
  118. data/lib/spec_forge/normalizers/_shared.yml +0 -76
  119. data/lib/spec_forge/normalizers/constraint.yml +0 -8
  120. data/lib/spec_forge/normalizers/expectation.yml +0 -47
  121. data/lib/spec_forge/normalizers/global_context.yml +0 -28
  122. data/lib/spec_forge/normalizers/spec.yml +0 -50
  123. data/lib/spec_forge/runner/adapter.rb +0 -181
  124. data/lib/spec_forge/runner/callbacks.rb +0 -246
  125. data/lib/spec_forge/runner/debug_proxy.rb +0 -215
  126. data/lib/spec_forge/runner/listener.rb +0 -54
  127. data/lib/spec_forge/runner/metadata.rb +0 -58
  128. data/lib/spec_forge/runner/state.rb +0 -98
  129. data/lib/spec_forge/runner.rb +0 -75
  130. data/lib/spec_forge/spec/expectation/constraint.rb +0 -127
  131. data/lib/spec_forge/spec/expectation.rb +0 -68
  132. data/lib/spec_forge/spec.rb +0 -68
  133. data/lib/templates/new_spec.yml.tt +0 -43
@@ -3,7 +3,7 @@
3
3
  module SpecForge
4
4
  module Documentation
5
5
  module OpenAPI
6
- module V3_0 # standard:disable Naming/ClassAndModuleCamelCase
6
+ class V30
7
7
  #
8
8
  # Represents an OpenAPI 3.0 Response object
9
9
  #
@@ -12,7 +12,23 @@ module SpecForge
12
12
  #
13
13
  # @see https://spec.openapis.org/oas/v3.0.4.html#response-object
14
14
  #
15
- class Response < OpenAPI::Base
15
+ class Response
16
+ #
17
+ # The document object containing structured API data
18
+ #
19
+ # @return [Object] The document with endpoint information
20
+ #
21
+ attr_reader :document
22
+
23
+ #
24
+ # Creates a new Response from a document
25
+ #
26
+ # @param document [Object] The document containing response data
27
+ #
28
+ def initialize(document)
29
+ @document = document
30
+ end
31
+
16
32
  #
17
33
  # Converts the response to an OpenAPI-compliant hash
18
34
  #
@@ -38,10 +54,12 @@ module SpecForge
38
54
  # Creates media type objects with schemas and merges with any
39
55
  # documentation-provided content definitions.
40
56
  #
41
- # @return [Hash] Content definitions by media type
57
+ # @return [Hash, nil] Content definitions by media type
42
58
  #
43
59
  def content
44
- schema = Schema.new(type: document.body.type).to_h
60
+ return nil if document.content_type.blank?
61
+
62
+ schema = Schema.new(type: document.body.type, content: document.body.content).to_h
45
63
 
46
64
  {
47
65
  document.content_type => MediaType.new(schema:).to_h
@@ -51,12 +69,16 @@ module SpecForge
51
69
  #
52
70
  # Returns header definitions for the response
53
71
  #
54
- # Merges document headers with documentation-provided headers.
72
+ # Transforms document headers into OpenAPI format with schema wrappers.
55
73
  #
56
74
  # @return [Hash, nil] Header definitions
57
75
  #
58
76
  def headers
59
- document.headers.presence
77
+ return nil if document.headers.blank?
78
+
79
+ document.headers.transform_values do |header|
80
+ {schema: header}
81
+ end
60
82
  end
61
83
  end
62
84
  end
@@ -3,7 +3,7 @@
3
3
  module SpecForge
4
4
  module Documentation
5
5
  module OpenAPI
6
- module V3_0 # standard:disable Naming/ClassAndModuleCamelCase
6
+ class V30
7
7
  #
8
8
  # Represents an OpenAPI 3.0 Schema object
9
9
  #
@@ -27,16 +27,25 @@ module SpecForge
27
27
  #
28
28
  attr_reader :format
29
29
 
30
+ #
31
+ # The schema content (for arrays/objects)
32
+ #
33
+ # @return [Object, nil] The schema content
34
+ #
35
+ attr_reader :content
36
+
30
37
  #
31
38
  # Creates a new OpenAPI schema object
32
39
  #
33
40
  # @param options [Hash] Schema configuration options
34
41
  # @option options [String] :type The data type to convert to OpenAPI format
42
+ # @option options [Object] :content The content/items for arrays or properties for objects
35
43
  #
36
44
  # @return [Schema] A new schema instance
37
45
  #
38
46
  def initialize(options = {})
39
47
  @type, @format = transform_type(options[:type])
48
+ @content = options[:content]
40
49
  end
41
50
 
42
51
  #
@@ -45,10 +54,19 @@ module SpecForge
45
54
  # @return [Hash] OpenAPI-formatted schema object
46
55
  #
47
56
  def to_h
48
- {
57
+ base = {
49
58
  type:,
50
59
  format:
51
60
  }.compact_blank!
61
+
62
+ # Add items for arrays
63
+ if type == "array" && content.present?
64
+ # Content is an array like [{type: "string"}], take first element as items schema
65
+ items_type = content.first&.dig(:type) || "object"
66
+ base[:items] = {type: items_type}
67
+ end
68
+
69
+ base
52
70
  end
53
71
 
54
72
  private
@@ -3,7 +3,7 @@
3
3
  module SpecForge
4
4
  module Documentation
5
5
  module OpenAPI
6
- module V3_0 # standard:disable Naming/ClassAndModuleCamelCase
6
+ class V30
7
7
  #
8
8
  # Represents an OpenAPI 3.0 Tag object
9
9
  #
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ module Documentation
5
+ module OpenAPI
6
+ # https://spec.openapis.org/oas/v3.0.4.html
7
+ class V30 < Generator
8
+ #
9
+ # Current OpenAPI 3.0 version supported by this generator
10
+ #
11
+ # @api private
12
+ #
13
+ CURRENT_VERSION = "3.0.4"
14
+
15
+ #
16
+ # Validates an OpenAPI specification against the standard
17
+ #
18
+ # Uses the openapi3_parser gem to validate the generated specification
19
+ # and provides detailed error reporting if validation fails.
20
+ #
21
+ # @param output [Hash] The OpenAPI specification to validate
22
+ #
23
+ # @return [void]
24
+ #
25
+ # @raise [Error::InvalidOASDocument] If the specification is invalid
26
+ #
27
+ def self.validate!(output)
28
+ document = Openapi3Parser.load(output)
29
+ if document.valid?
30
+ puts "✅ No validation errors found!"
31
+ return
32
+ end
33
+
34
+ puts ErrorFormatter.format(document.errors.errors)
35
+ raise Error::InvalidOASDocument
36
+ end
37
+
38
+ #
39
+ # Generates an OpenAPI 3.0 specification from the input document
40
+ #
41
+ # Creates a complete OpenAPI specification by combining the document's
42
+ # endpoint data with configuration files and ensuring compliance with
43
+ # OpenAPI 3.0.4 standards.
44
+ #
45
+ # @return [Hash] Complete OpenAPI 3.0 specification
46
+ #
47
+ def generate
48
+ output = {
49
+ openapi: CURRENT_VERSION,
50
+ paths:
51
+ }
52
+
53
+ output.deep_stringify_keys!
54
+ output.deep_merge!(config)
55
+
56
+ output
57
+ end
58
+
59
+ #
60
+ # Transforms document endpoints into OpenAPI paths structure
61
+ #
62
+ # Converts the internal endpoint representation into the OpenAPI paths
63
+ # format, with each path containing operations organized by HTTP method.
64
+ #
65
+ # @return [Hash] OpenAPI paths object with operations
66
+ #
67
+ def paths
68
+ paths = input.endpoints.deep_dup
69
+
70
+ paths.each do |path, operations|
71
+ operations.transform_values! do |document|
72
+ Operation.new(document).to_h
73
+ end
74
+ end
75
+ end
76
+
77
+ protected
78
+
79
+ #
80
+ # Loads OpenAPI configuration from YAML
81
+ #
82
+ # @return [Hash] The normalized OpenAPI configuration
83
+ #
84
+ # @api private
85
+ #
86
+ def config
87
+ @config ||= begin
88
+ file_extension_glob = "*.{yml,yaml}"
89
+ base_path = SpecForge.openapi_path.join("config")
90
+
91
+ root_paths = base_path.join(file_extension_glob)
92
+ path_paths = base_path.join("paths", "**", file_extension_glob)
93
+ component_paths = base_path.join("components", "**", file_extension_glob)
94
+
95
+ config = load_yml_from_paths(root_paths).to_merged_h
96
+ paths_config = load_yml_from_paths(path_paths).to_merged_h
97
+ component_config = load_yml_from_paths(component_paths).to_merged_h
98
+
99
+ (config["paths"] ||= {}).deep_merge!(paths_config)
100
+ (config["components"] ||= {}).deep_merge!(component_config)
101
+
102
+ config
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def load_yml_from_paths(paths)
109
+ Dir[paths].map do |path|
110
+ YAML.safe_load_file(path)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -3,21 +3,49 @@
3
3
  module SpecForge
4
4
  module Documentation
5
5
  #
6
- # OpenAPI documentation generation functionality
6
+ # OpenAPI specification generation and version management
7
7
  #
8
- # Contains classes and modules for generating OpenAPI specifications
9
- # from SpecForge test data. Supports multiple OpenAPI versions.
8
+ # Provides version-aware access to OpenAPI generators and validators.
9
+ # Supports multiple OpenAPI versions through semantic versioning matching.
10
10
  #
11
11
  module OpenAPI
12
+ #
13
+ # Current OpenAPI version used as default
14
+ #
15
+ # Points to the latest supported OpenAPI version for new specifications.
16
+ #
17
+ # @api private
18
+ #
19
+ CURRENT_VERSION = V30::CURRENT_VERSION
20
+
21
+ #
22
+ # Mapping of semantic versions to their generator classes
23
+ #
24
+ # @return [Hash{SemVersion => Class}]
25
+ #
26
+ VERSIONS = {
27
+ V30.to_sem_version => V30
28
+ }.freeze
29
+
30
+ #
31
+ # Returns the generator class for the specified OpenAPI version
32
+ #
33
+ # @param version [String, SemVersion] The OpenAPI version (e.g., "3.0")
34
+ #
35
+ # @return [Class] The generator class for the requested version
36
+ #
37
+ # @raise [ArgumentError] If the version is not supported
38
+ #
39
+ def self.[](version)
40
+ version = SemVersion.from_loose_version(version)
41
+ generator = VERSIONS.value_where { |k, _v| k.satisfies?("~> #{version}") }
42
+
43
+ if generator.nil?
44
+ raise ArgumentError, "Invalid OpenAPI version provided: #{version.to_s.in_quotes}"
45
+ end
46
+
47
+ generator
48
+ end
12
49
  end
13
50
  end
14
51
  end
15
-
16
- require_relative "openapi/base"
17
-
18
- require_relative "openapi/v3_0/example"
19
- require_relative "openapi/v3_0/media_type"
20
- require_relative "openapi/v3_0/operation"
21
- require_relative "openapi/v3_0/response"
22
- require_relative "openapi/v3_0/schema"
23
- require_relative "openapi/v3_0/tag"
@@ -14,14 +14,8 @@ module SpecForge
14
14
  #
15
15
  # # Programmatically
16
16
  # document = Documentation::Loader.load_document
17
- # spec = Documentation::Generators::OpenAPI["3.0"].new(document).generate
17
+ # spec = Documentation::OpenAPI["3.0"].new(document).generate
18
18
  #
19
19
  module Documentation
20
20
  end
21
21
  end
22
-
23
- require_relative "documentation/builder"
24
- require_relative "documentation/document"
25
- require_relative "documentation/loader"
26
- require_relative "documentation/openapi"
27
- require_relative "documentation/generators"
@@ -5,17 +5,6 @@ module SpecForge
5
5
  # Base error class for all SpecForge-specific exceptions
6
6
  #
7
7
  class Error < StandardError
8
- # Pass into to_sentence
9
- OR_CONNECTOR = {
10
- last_word_connector: ", or ",
11
- two_words_connector: " or ",
12
- # This is a minor performance improvement to avoid locales being loaded
13
- # This will need to be removed if locales are added
14
- locale: false
15
- }.freeze
16
-
17
- private_constant :OR_CONNECTOR
18
-
19
8
  #
20
9
  # Raised when a provided Faker class name doesn't exist
21
10
  # Provides helpful suggestions for similar class names
@@ -40,7 +29,7 @@ module SpecForge
40
29
  super(<<~STRING.chomp
41
30
  Undefined Faker class "#{input}". #{DidYouMean::Formatter.message_for(corrections)}
42
31
 
43
- For available classes, please check https://github.com/faker-ruby/faker#generators.
32
+ For available classes, please check https://github.com/faker-ruby/faker/blob/main/GENERATORS.md.
44
33
  STRING
45
34
  )
46
35
  end
@@ -74,12 +63,30 @@ module SpecForge
74
63
  # Indicates when a transform name isn't supported
75
64
  #
76
65
  class InvalidTransformFunctionError < Error
77
- def initialize(input)
78
- # TODO: Update link to docs
66
+ def initialize(input, valid_functions)
67
+ formatted_functions = valid_functions.join_map(", ") { |f| "transform.#{f}".in_quotes }
68
+
79
69
  super(<<~STRING.chomp
80
70
  Undefined transform function "#{input}".
81
71
 
82
- For available functions, please check https://github.com/itsthedevman/spec_forge.
72
+ Valid functions: #{formatted_functions}
73
+ STRING
74
+ )
75
+ end
76
+ end
77
+
78
+ #
79
+ # Raised when an unknown generation function is referenced
80
+ # Indicates when a generation name isn't supported
81
+ #
82
+ class InvalidGenerateFunctionError < Error
83
+ def initialize(input, valid_functions)
84
+ formatted_functions = valid_functions.join_map(", ") { |f| "generate.#{f}".in_quotes }
85
+
86
+ super(<<~STRING.chomp
87
+ Undefined generate function "#{input}".
88
+
89
+ Valid functions: #{formatted_functions}
83
90
  STRING
84
91
  )
85
92
  end
@@ -151,23 +158,48 @@ module SpecForge
151
158
  class InvalidTypeError < Error
152
159
  def initialize(object, expected_type, **opts)
153
160
  if expected_type.instance_of?(Array)
154
- expected_type = expected_type.to_sentence(**OR_CONNECTOR)
161
+ expected_type = expected_type.to_or_sentence
155
162
  end
156
163
 
157
164
  message = "Expected #{expected_type}, got #{object.class}"
158
165
  message += " for #{opts[:for]}" if opts[:for].present?
159
166
 
167
+ if opts[:description] || opts[:examples]
168
+ message += "\n"
169
+
170
+ if opts[:description]
171
+ message += "\nAbout #{opts[:attribute_name].in_quotes}:"
172
+ message += "\n #{opts[:description].gsub("\n", "\n ")}"
173
+ end
174
+
175
+ if opts[:examples].present?
176
+ message += "\n\nExamples:\n #{opts[:examples].join("\n\n").gsub("\n", "\n ")}"
177
+ end
178
+ end
179
+
160
180
  super(message)
161
181
  end
162
182
  end
163
183
 
164
184
  #
165
- # Raised when a variable reference cannot be resolved
166
- # Indicates when a spec or expectation references an undefined variable
185
+ # Raised when a referenced variable is not defined in the current context
186
+ #
187
+ # Provides helpful suggestions for similar variable names using spell checking.
167
188
  #
168
189
  class MissingVariableError < Error
169
- def initialize(variable_name)
170
- super("Undefined variable \"#{variable_name}\" referenced in expectation")
190
+ def initialize(variable_name, available_variables: [])
191
+ message = "Undefined variable \"#{variable_name}\""
192
+
193
+ checker = DidYouMean::SpellChecker.new(dictionary: available_variables)
194
+ suggestions = checker.correct(variable_name.to_s)
195
+
196
+ message += ". #{DidYouMean::Formatter.message_for(suggestions)}" if suggestions.size > 0
197
+
198
+ if available_variables.size > 0 && available_variables.size <= 5
199
+ message += ".\nAvailable: #{available_variables.join_map(", ", &:in_quotes)}"
200
+ end
201
+
202
+ super(message)
171
203
  end
172
204
  end
173
205
 
@@ -195,7 +227,7 @@ module SpecForge
195
227
  #
196
228
  class InvalidBuildStrategy < Error
197
229
  def initialize(build_strategy)
198
- valid_strategies = Attribute::Factory::BUILD_STRATEGIES.to_sentence(**OR_CONNECTOR)
230
+ valid_strategies = Attribute::Factory::BUILD_STRATEGIES.to_or_sentence
199
231
 
200
232
  super(<<~STRING.chomp
201
233
  Unknown build strategy "#{build_strategy}" referenced in spec.
@@ -207,37 +239,53 @@ module SpecForge
207
239
  end
208
240
 
209
241
  #
210
- # Raised when a spec file cannot be loaded
211
- # Provides detailed information about the cause of the loading error
242
+ # Raised when a step has an invalid configuration
212
243
  #
213
- class SpecLoadError < Error
214
- def initialize(error, file_path, spec: nil)
215
- message =
216
- if spec
217
- "Error loading spec #{spec[:name].in_quotes} in file #{file_path.in_quotes} (line #{spec[:line_number]})"
218
- else
219
- "Error loading spec file #{file_path.in_quotes}"
220
- end
221
-
222
- causes = error.message.split("\n").map(&:strip).reject(&:empty?)
244
+ # Common cases:
245
+ # - Action attributes (request, expect, call, debug, store) combined with steps
246
+ # - Expects defined without a corresponding request
247
+ #
248
+ class InvalidStepError < Error
249
+ def initialize(message, step = nil)
250
+ if step
251
+ step_name = step[:name].presence || "(unnamed)"
223
252
 
224
- message +=
225
- if causes.size > 1
226
- "\nCauses:\n - #{causes.join_map("\n - ")}"
253
+ line_info = if (source = step[:source])
254
+ "#{source[:file_name]}:#{source[:line_number]}"
227
255
  else
228
- "\nCause: #{error}"
256
+ "unknown location"
229
257
  end
230
258
 
259
+ message = "Step #{step_name.in_quotes} [#{line_info}]: #{message}"
260
+ end
261
+
231
262
  super(message)
232
263
  end
233
264
  end
234
265
 
235
266
  #
236
- # Raised when the provided namespace is not defined on the global context
267
+ # Raised when an error occurs while loading a step during blueprint processing
268
+ #
269
+ # Wraps the original error with step context information to help identify
270
+ # which step caused the problem.
237
271
  #
238
- class InvalidGlobalNamespaceError < Error
239
- def initialize(provided_namespace)
240
- super("Invalid global namespace #{provided_namespace.in_quotes}. Currently supported namespaces are: \"variables\"")
272
+ class LoadStepError < Error
273
+ def initialize(error, step, depth = 0)
274
+ step_name = step[:name].presence || "(unnamed)"
275
+
276
+ line_info = if (source = step[:source])
277
+ "#{source[:file_name]}:#{source[:line_number]}"
278
+ end
279
+
280
+ message = "Step: #{step_name.in_quotes} [#{line_info}]"
281
+
282
+ cause_message = if error.is_a?(LoadStepError)
283
+ "\n#{error.message}"
284
+ else
285
+ "\n\nCaused by: \n #{error.message.gsub("\n", "\n ")}"
286
+ end
287
+
288
+ super(message + cause_message)
241
289
  end
242
290
  end
243
291
 
@@ -317,5 +365,131 @@ module SpecForge
317
365
  #
318
366
  class InvalidOASDocument < Error
319
367
  end
368
+
369
+ #
370
+ # Raised when one or more expectations fail during step execution
371
+ #
372
+ # Contains the list of failed RSpec examples for reporting purposes.
373
+ #
374
+ class ExpectationFailure < Error
375
+ attr_reader :failed_examples
376
+
377
+ def initialize(failed_examples)
378
+ @failed_examples = failed_examples
379
+
380
+ super("Failed expectations (#{@failed_examples.size})")
381
+ end
382
+ end
383
+
384
+ #
385
+ # Raised when JSON shape validation fails
386
+ #
387
+ # Contains structured failure information for all validation errors
388
+ # discovered during shape checking.
389
+ #
390
+ # @example Single failure
391
+ # failures = [{path: ".id", expected_type: String, actual_type: Integer, actual_value: 42}]
392
+ # raise SchemaValidationFailure.new(failures)
393
+ #
394
+ # @example Multiple failures
395
+ # failures = [
396
+ # {path: ".id", expected_type: String, actual_type: Integer, actual_value: 42},
397
+ # {path: ".email", expected_type: String, actual_type: NilClass, actual_value: nil}
398
+ # ]
399
+ # raise SchemaValidationFailure.new(failures)
400
+ #
401
+ class SchemaValidationFailure < Error
402
+ attr_reader :failures
403
+
404
+ def initialize(failures)
405
+ @failures = failures
406
+
407
+ message =
408
+ if failures.size == 1
409
+ format_failure(failures.first)
410
+ else
411
+ failures.join_map("\n") do |failure|
412
+ format_failure(failure)
413
+ end
414
+ end
415
+
416
+ super(message)
417
+ end
418
+
419
+ private
420
+
421
+ def format_failure(failure)
422
+ expected_types =
423
+ if failure[:expected_type].size == 1
424
+ failure[:expected_type].first
425
+ else
426
+ failure[:expected_type].to_or_sentence
427
+ end
428
+
429
+ "#{failure[:path]}: expected #{expected_types}, got #{failure[:actual_type]} (#{failure[:actual_value].inspect})"
430
+ end
431
+ end
432
+
433
+ #
434
+ # Raised when JSON content validation fails
435
+ #
436
+ # Contains structured failure information for content mismatches.
437
+ #
438
+ class ContentValidationFailure < Error
439
+ attr_reader :failures
440
+
441
+ def initialize(failures)
442
+ @failures = failures
443
+ super(format_failures(failures))
444
+ end
445
+
446
+ private
447
+
448
+ def format_failures(failures)
449
+ if failures.size == 1
450
+ failure = failures.first
451
+ "#{failure[:path]}: #{failure[:message]}"
452
+ else
453
+ failures.join_map("\n") { |f| "#{f[:path]}: #{f[:message]}" }
454
+ end
455
+ end
456
+ end
457
+
458
+ #
459
+ # Raised when HTTP header validation fails
460
+ #
461
+ # Contains structured failure information for header mismatches.
462
+ #
463
+ class HeaderValidationFailure < Error
464
+ attr_reader :failures
465
+
466
+ def initialize(failures)
467
+ @failures = failures
468
+ super(format_failures(failures))
469
+ end
470
+
471
+ private
472
+
473
+ def format_failures(failures)
474
+ if failures.size == 1
475
+ failure = failures.first
476
+ "#{failure[:header].in_quotes}: #{failure[:message]}"
477
+ else
478
+ failures.join_map("\n") { |f| "#{f[:header].in_quotes}: #{f[:message]}" }
479
+ end
480
+ end
481
+ end
482
+
483
+ #
484
+ # Raised when no blueprints are found during loading
485
+ #
486
+ # This typically occurs when attempting to generate documentation
487
+ # but the blueprints directory is empty or doesn't contain valid files.
488
+ #
489
+ class NoBlueprintsError < Error
490
+ def initialize
491
+ super("No blueprints found. Please ensure your blueprints directory contains valid blueprint files.")
492
+ end
493
+ end
320
494
  end
321
495
  end