spec_forge 0.5.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/.standard.yml +3 -3
  3. data/CHANGELOG.md +217 -2
  4. data/README.md +162 -25
  5. data/flake.lock +3 -3
  6. data/flake.nix +11 -5
  7. data/lib/spec_forge/attribute/chainable.rb +208 -20
  8. data/lib/spec_forge/attribute/factory.rb +92 -15
  9. data/lib/spec_forge/attribute/faker.rb +62 -13
  10. data/lib/spec_forge/attribute/global.rb +96 -0
  11. data/lib/spec_forge/attribute/literal.rb +15 -2
  12. data/lib/spec_forge/attribute/matcher.rb +186 -11
  13. data/lib/spec_forge/attribute/parameterized.rb +45 -12
  14. data/lib/spec_forge/attribute/regex.rb +55 -5
  15. data/lib/spec_forge/attribute/resolvable.rb +48 -5
  16. data/lib/spec_forge/attribute/resolvable_array.rb +62 -4
  17. data/lib/spec_forge/attribute/resolvable_hash.rb +62 -4
  18. data/lib/spec_forge/attribute/store.rb +65 -0
  19. data/lib/spec_forge/attribute/transform.rb +33 -5
  20. data/lib/spec_forge/attribute/variable.rb +37 -6
  21. data/lib/spec_forge/attribute.rb +166 -66
  22. data/lib/spec_forge/backtrace_formatter.rb +26 -3
  23. data/lib/spec_forge/callbacks.rb +88 -0
  24. data/lib/spec_forge/cli/actions.rb +27 -0
  25. data/lib/spec_forge/cli/command.rb +78 -24
  26. data/lib/spec_forge/cli/docs/generate.rb +72 -0
  27. data/lib/spec_forge/cli/docs.rb +92 -0
  28. data/lib/spec_forge/cli/init.rb +51 -9
  29. data/lib/spec_forge/cli/new.rb +67 -6
  30. data/lib/spec_forge/cli/run.rb +32 -4
  31. data/lib/spec_forge/cli/serve.rb +155 -0
  32. data/lib/spec_forge/cli.rb +26 -7
  33. data/lib/spec_forge/configuration.rb +96 -24
  34. data/lib/spec_forge/context/callbacks.rb +91 -0
  35. data/lib/spec_forge/context/global.rb +72 -0
  36. data/lib/spec_forge/context/store.rb +131 -0
  37. data/lib/spec_forge/context/variables.rb +91 -0
  38. data/lib/spec_forge/context.rb +36 -0
  39. data/lib/spec_forge/core_ext/array.rb +27 -0
  40. data/lib/spec_forge/core_ext/rspec.rb +22 -4
  41. data/lib/spec_forge/documentation/builder.rb +383 -0
  42. data/lib/spec_forge/documentation/document/operation.rb +47 -0
  43. data/lib/spec_forge/documentation/document/parameter.rb +22 -0
  44. data/lib/spec_forge/documentation/document/request_body.rb +24 -0
  45. data/lib/spec_forge/documentation/document/response.rb +39 -0
  46. data/lib/spec_forge/documentation/document/response_body.rb +27 -0
  47. data/lib/spec_forge/documentation/document.rb +48 -0
  48. data/lib/spec_forge/documentation/generators/base.rb +81 -0
  49. data/lib/spec_forge/documentation/generators/openapi/base.rb +100 -0
  50. data/lib/spec_forge/documentation/generators/openapi/error_formatter.rb +149 -0
  51. data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +65 -0
  52. data/lib/spec_forge/documentation/generators/openapi.rb +59 -0
  53. data/lib/spec_forge/documentation/generators.rb +17 -0
  54. data/lib/spec_forge/documentation/loader/cache.rb +138 -0
  55. data/lib/spec_forge/documentation/loader.rb +159 -0
  56. data/lib/spec_forge/documentation/openapi/base.rb +33 -0
  57. data/lib/spec_forge/documentation/openapi/v3_0/example.rb +44 -0
  58. data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +42 -0
  59. data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +175 -0
  60. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +65 -0
  61. data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +80 -0
  62. data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +71 -0
  63. data/lib/spec_forge/documentation/openapi.rb +23 -0
  64. data/lib/spec_forge/documentation.rb +27 -0
  65. data/lib/spec_forge/error.rb +284 -113
  66. data/lib/spec_forge/factory.rb +35 -16
  67. data/lib/spec_forge/filter.rb +86 -0
  68. data/lib/spec_forge/forge.rb +171 -0
  69. data/lib/spec_forge/http/backend.rb +101 -29
  70. data/lib/spec_forge/http/client.rb +23 -13
  71. data/lib/spec_forge/http/request.rb +85 -62
  72. data/lib/spec_forge/http/verb.rb +79 -0
  73. data/lib/spec_forge/http.rb +105 -0
  74. data/lib/spec_forge/loader.rb +244 -0
  75. data/lib/spec_forge/matchers.rb +130 -0
  76. data/lib/spec_forge/normalizer/default.rb +51 -0
  77. data/lib/spec_forge/normalizer/definition.rb +248 -0
  78. data/lib/spec_forge/normalizer/validators.rb +99 -0
  79. data/lib/spec_forge/normalizer.rb +486 -115
  80. data/lib/spec_forge/normalizers/_shared.yml +74 -0
  81. data/lib/spec_forge/normalizers/configuration.yml +23 -0
  82. data/lib/spec_forge/normalizers/constraint.yml +8 -0
  83. data/lib/spec_forge/normalizers/expectation.yml +47 -0
  84. data/lib/spec_forge/normalizers/factory.yml +12 -0
  85. data/lib/spec_forge/normalizers/factory_reference.yml +15 -0
  86. data/lib/spec_forge/normalizers/global_context.yml +28 -0
  87. data/lib/spec_forge/normalizers/spec.yml +50 -0
  88. data/lib/spec_forge/runner/adapter.rb +183 -0
  89. data/lib/spec_forge/runner/callbacks.rb +246 -0
  90. data/lib/spec_forge/runner/debug_proxy.rb +213 -0
  91. data/lib/spec_forge/runner/listener.rb +54 -0
  92. data/lib/spec_forge/runner/metadata.rb +58 -0
  93. data/lib/spec_forge/runner/state.rb +98 -0
  94. data/lib/spec_forge/runner.rb +50 -125
  95. data/lib/spec_forge/spec/expectation/constraint.rb +100 -21
  96. data/lib/spec_forge/spec/expectation.rb +47 -51
  97. data/lib/spec_forge/spec.rb +50 -108
  98. data/lib/spec_forge/type.rb +36 -4
  99. data/lib/spec_forge/version.rb +4 -1
  100. data/lib/spec_forge.rb +168 -76
  101. data/lib/templates/openapi.yml.tt +22 -0
  102. data/lib/templates/redoc.html.tt +28 -0
  103. data/lib/templates/swagger.html.tt +59 -0
  104. metadata +109 -16
  105. data/lib/spec_forge/normalizer/configuration.rb +0 -77
  106. data/lib/spec_forge/normalizer/constraint.rb +0 -47
  107. data/lib/spec_forge/normalizer/expectation.rb +0 -86
  108. data/lib/spec_forge/normalizer/factory.rb +0 -65
  109. data/lib/spec_forge/normalizer/factory_reference.rb +0 -71
  110. data/lib/spec_forge/normalizer/spec.rb +0 -74
  111. data/spec_forge/factories/user.yml +0 -4
  112. data/spec_forge/forge_helper.rb +0 -48
  113. data/spec_forge/specs/users.yml +0 -65
  114. /data/lib/templates/{forge_helper.tt → forge_helper.rb.tt} +0 -0
  115. /data/lib/templates/{new_factory.tt → new_factory.yml.tt} +0 -0
  116. /data/lib/templates/{new_spec.tt → new_spec.yml.tt} +0 -0
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpecForge
4
+ #
5
+ # Manages factory definitions and registration with FactoryBot
6
+ # Provides methods for loading factories from YAML files
7
+ #
4
8
  class Factory
5
9
  #
6
- # Loads the factories from their yml files and registers them with FactoryBot
10
+ # Loads factories from files and registers them with FactoryBot
11
+ # Sets up paths and loads definitions based on configuration
7
12
  #
8
13
  def self.load_and_register
9
14
  if SpecForge.configuration.factories.paths?
@@ -17,19 +22,18 @@ module SpecForge
17
22
  end
18
23
 
19
24
  #
20
- # Loads any factories defined in the factories. A single file can contain one or more factories
25
+ # Loads factory definitions from YAML files
26
+ # Creates Factory instances but doesn't register them with FactoryBot
21
27
  #
22
- # @return [Array<Factory>] An array of factories that were loaded.
23
- # Note: This factories have not been registered with FactoryBot.
24
- # See #register
28
+ # @return [Array<Factory>] Array of loaded factory instances
25
29
  #
26
30
  def self.load_from_files
27
- path = SpecForge.forge.join("factories", "**/*.yml")
31
+ path = SpecForge.forge_path.join("factories", "**/*.yml")
28
32
 
29
33
  factories = []
30
34
 
31
35
  Dir[path].each do |file_path|
32
- hash = YAML.load_file(file_path).deep_symbolize_keys
36
+ hash = YAML.load_file(file_path, symbolize_names: true)
33
37
 
34
38
  hash.each do |factory_name, factory_hash|
35
39
  factory_hash[:name] = factory_name
@@ -43,17 +47,32 @@ module SpecForge
43
47
 
44
48
  ############################################################################
45
49
 
46
- attr_reader :name, :input, :model_class, :variables, :attributes
50
+ # @return [Symbol, String] The name of the factory
51
+ attr_reader :name
47
52
 
53
+ # @return [Hash] The raw input that defined this factory
54
+ attr_reader :input
55
+
56
+ # @return [String, nil] The model class name this factory represents, if specified
57
+ attr_reader :model_class
58
+
59
+ # @return [Hash<Symbol, Attribute>] Variables defined for this factory
60
+ attr_reader :variables
61
+
62
+ # @return [Hash<Symbol, Attribute>] The attributes that define this factory
63
+ attr_reader :attributes
64
+
65
+ #
66
+ # Creates a new Factory instance
48
67
  #
49
- # Creates a new Factory
68
+ # @param name [String, Symbol] The name of the factory
69
+ # @param input [Hash] The attributes defining the factory
50
70
  #
51
- # @param name [String] The name of the factory
52
- # @param **input [Hash] Attributes to define the factory. See Normalizer::Factory
71
+ # @return [Factory] A new factory instance
53
72
  #
54
73
  def initialize(name:, **input)
55
74
  @name = name
56
- input = Normalizer.normalize_factory!(input)
75
+ input = Normalizer.normalize!(input, using: :factory)
57
76
 
58
77
  @input = input
59
78
  @model_class = input[:model_class]
@@ -63,10 +82,10 @@ module SpecForge
63
82
  end
64
83
 
65
84
  #
66
- # Registers this factory with FactoryBot.
67
- # Once registered, you can call FactoryBot.build and other methods
85
+ # Registers this factory with FactoryBot
86
+ # Makes the factory available for use in specs
68
87
  #
69
- # @return [Self]
88
+ # @return [self] Returns self for method chaining
70
89
  #
71
90
  def register
72
91
  dsl = FactoryBot::Syntax::Default::DSL.new
@@ -78,7 +97,7 @@ module SpecForge
78
97
  factory_forge = self
79
98
  dsl.factory(name, options) do
80
99
  factory_forge.attributes.each do |name, attribute|
81
- add_attribute(name) { attribute.resolve_value }
100
+ add_attribute(name) { attribute.resolve }
82
101
  end
83
102
  end
84
103
 
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ #
5
+ # Provides filtering capabilities for test suites based on different criteria
6
+ #
7
+ # The Filter class allows running specific tests by filtering forges, specs,
8
+ # and expectations based on file name, spec name, and expectation name.
9
+ #
10
+ # @example Filtering specs by name
11
+ # forges = Loader.load_from_files
12
+ # filtered = Filter.apply(forges, file_name: "users", spec_name: "create_user")
13
+ #
14
+ class Filter
15
+ class << self
16
+ #
17
+ # Prints out a message if any of the filters were used
18
+ #
19
+ # @param forges [Array<Forge>] The collection of forges that was filtered
20
+ # @param file_name [String, nil] Optional file name that was used by the filter
21
+ # @param spec_name [String, nil] Optional spec name that was used by the filter
22
+ # @param expectation_name [String, nil] Optional expectation name that was used by the filter
23
+ #
24
+ def announce(forges, file_name:, spec_name:, expectation_name:)
25
+ filters = {file_name:, spec_name:, expectation_name:}.compact_blank
26
+ return if filters.size == 0
27
+
28
+ filters_display = filters.join_map(", ") { |k, v| "#{k.in_quotes} => #{v.in_quotes}" }
29
+
30
+ expectation_count = forges.sum do |forge|
31
+ forge.specs.sum { |spec| spec.expectations.size }
32
+ end
33
+
34
+ puts "Applied filter #{filters_display}"
35
+ puts "Found #{expectation_count} #{"expectation".pluralize(expectation_count)}"
36
+ end
37
+
38
+ #
39
+ # Filters a collection of forges based on specified criteria
40
+ #
41
+ # This method allows running specific tests by filtering forges, specs,
42
+ # and expectations based on file name, spec name, and expectation name.
43
+ # It returns only the forges, specs, and expectations that match the criteria.
44
+ #
45
+ # @param forges [Array<Forge>] The collection of forges to filter
46
+ # @param file_name [String, nil] Optional file name to filter by
47
+ # @param spec_name [String, nil] Optional spec name to filter by
48
+ # @param expectation_name [String, nil] Optional expectation name to filter by
49
+ #
50
+ # @return [Array<Forge>] The filtered collection of forges
51
+ #
52
+ # @raise [ArgumentError] If filtering parameters are provided in an invalid combination
53
+ #
54
+ def apply(forges, file_name: nil, spec_name: nil, expectation_name: nil)
55
+ # Guard against invalid partial filters
56
+ if expectation_name && spec_name.blank?
57
+ raise ArgumentError, "The spec's name is required when filtering by an expectation's name"
58
+ end
59
+
60
+ if spec_name && file_name.blank?
61
+ raise ArgumentError, "The spec's filename is required when filtering by a spec's name"
62
+ end
63
+
64
+ forges.filter_map do |forge|
65
+ specs = forge.specs.filter_map do |spec|
66
+ next if file_name && spec.file_name != file_name # File filter
67
+ next if spec_name && spec.name != spec_name # Name filter
68
+
69
+ # Expectation filter
70
+ next spec unless expectation_name
71
+
72
+ spec.expectations.select! { |e| e.name == expectation_name }
73
+ next if spec.expectations.empty?
74
+
75
+ spec
76
+ end
77
+
78
+ next if specs.empty?
79
+
80
+ forge.specs = specs
81
+ forge
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ #
5
+ # Represents a collection of related specs loaded from a single YAML file
6
+ #
7
+ # A Forge contains multiple specs with their expectations, global variables,
8
+ # and request configuration. It acts as the container for all tests defined
9
+ # in a single file and manages their shared context.
10
+ #
11
+ # @example Creating a forge
12
+ # global = {variables: {api_key: "123"}}
13
+ # metadata = {file_name: "users", file_path: "/path/to/users.yml"}
14
+ # specs = [{name: "list_users", url: "/users", expectations: [...]}]
15
+ # forge = Forge.new(global, metadata, specs)
16
+ #
17
+ class Forge
18
+ #
19
+ # The name of this forge from the relative path
20
+ #
21
+ # @return [String] The name derived from the file path
22
+ #
23
+ attr_reader :name
24
+
25
+ #
26
+ # Global variables and configuration shared across all specs
27
+ #
28
+ # @return [Hash] The global variables and configuration
29
+ #
30
+ attr_reader :global
31
+
32
+ #
33
+ # Metadata about the spec file
34
+ #
35
+ # @return [Hash] File information such as path and name
36
+ #
37
+ attr_reader :metadata
38
+
39
+ #
40
+ # Variables defined at the spec and expectation levels
41
+ #
42
+ # @return [Hash] Variable definitions organized by spec
43
+ #
44
+ attr_reader :variables
45
+
46
+ #
47
+ # Request configuration for the specs
48
+ #
49
+ # @return [Hash] HTTP request configuration by spec
50
+ #
51
+ attr_reader :request
52
+
53
+ #
54
+ # Collection of specs contained in this forge
55
+ #
56
+ # @return [Array<Spec>] The specs defined in this file
57
+ #
58
+ attr_accessor :specs
59
+
60
+ #
61
+ # Creates a new Forge instance containing specs from a YAML file
62
+ #
63
+ # @param global [Hash] Global variables shared across all specs in the file
64
+ # @param metadata [Hash] Information about the spec file
65
+ # @param specs [Array<Hash>] Array of spec definitions from the file
66
+ #
67
+ # @return [Forge] A new forge instance with the processed specs
68
+ #
69
+ def initialize(global, metadata, specs)
70
+ @name = metadata[:relative_path]
71
+
72
+ @global = global
73
+ @metadata = metadata
74
+
75
+ @variables = extract_variables!(specs)
76
+ @request = extract_request!(specs)
77
+ @specs = specs.map { |spec| Spec.new(**spec) }
78
+ end
79
+
80
+ #
81
+ # Retrieves variables for a specific spec
82
+ #
83
+ # Returns the variables defined for a specific spec, including
84
+ # both base variables and any overlay variables for its expectations.
85
+ #
86
+ # @param spec [Spec] The spec to get variables for
87
+ #
88
+ # @return [Hash] The variables for the spec
89
+ #
90
+ def variables_for_spec(spec)
91
+ @variables[spec.id]
92
+ end
93
+
94
+ private
95
+
96
+ #
97
+ # Extracts variables from specs and organizes them into base and overlay variables
98
+ #
99
+ # @param specs [Array<Hash>] Array of spec definitions
100
+ #
101
+ # @return [Hash] A hash mapping spec IDs to their variables
102
+ #
103
+ # @private
104
+ #
105
+ def extract_variables!(specs)
106
+ #
107
+ # Creates a hash that looks like this:
108
+ #
109
+ # {
110
+ # spec_1: {
111
+ # base: {var_1: true, var_2: false},
112
+ # overlay: {
113
+ # expectation: {var_1: false}
114
+ # }
115
+ # },
116
+ # spec_2: ...
117
+ # }
118
+ #
119
+ specs.each_with_object({}) do |spec, hash|
120
+ overlay = spec[:expectations].to_h { |e| [e[:id], e.delete(:variables)] }.compact_blank
121
+
122
+ hash[spec[:id]] = {base: spec.delete(:variables), overlay:}
123
+ end
124
+ end
125
+
126
+ #
127
+ # Extracts request configuration from specs and organizes them into base and overlay configs
128
+ #
129
+ # @param specs [Array<Hash>] Array of spec definitions
130
+ #
131
+ # @return [Hash] A hash mapping spec IDs to their request configurations
132
+ #
133
+ # @private
134
+ #
135
+ def extract_request!(specs)
136
+ #
137
+ # Creates a hash that looks like this:
138
+ #
139
+ # {
140
+ # spec_1: {
141
+ # base: {base_url: "https://foo.bar", url: "", ...},
142
+ # overlay: {
143
+ # expectation: {base_url: "https://bar.baz", ...}
144
+ # }
145
+ # },
146
+ # spec_2: ...
147
+ # }
148
+ #
149
+ config = SpecForge.configuration.to_h.slice(:base_url, :headers, :query)
150
+
151
+ specs.each_with_object({}) do |spec, hash|
152
+ overlay = spec[:expectations].to_h do |expectation|
153
+ [
154
+ expectation[:id],
155
+ expectation.extract!(*HTTP::REQUEST_ATTRIBUTES).compact_blank
156
+ ]
157
+ end
158
+
159
+ overlay.compact_blank!
160
+
161
+ base = spec.extract!(*HTTP::REQUEST_ATTRIBUTES)
162
+ base.compact_blank!
163
+
164
+ base = config.deep_merge(base)
165
+ base[:http_verb] ||= "GET"
166
+
167
+ hash[spec[:id]] = {base:, overlay:}
168
+ end
169
+ end
170
+ end
171
+ end
@@ -2,16 +2,44 @@
2
2
 
3
3
  module SpecForge
4
4
  module HTTP
5
+ #
6
+ # Handles the low-level HTTP operations using Faraday
7
+ #
8
+ # This class is responsible for creating and configuring the Faraday connection,
9
+ # executing the actual HTTP requests, and handling URL path parameter substitution.
10
+ #
11
+ # @example Basic usage
12
+ # backend = Backend.new(request)
13
+ # response = backend.get("/users")
14
+ #
5
15
  class Backend
16
+ #
17
+ # Regular expression to match { placeholder } style URL parameters
18
+ #
19
+ # @return [Regexp]
20
+ #
6
21
  CURLY_PLACEHOLDER = /\{(\w+)\}/
22
+
23
+ #
24
+ # Regular expression to match :placeholder style URL parameters
25
+ #
26
+ # @return [Regexp]
27
+ #
7
28
  COLON_PLACEHOLDER = /:(\w+)/
8
29
 
30
+ #
31
+ # The configured Faraday connection
32
+ #
33
+ # @return [Faraday::Connection]
34
+ #
9
35
  attr_reader :connection
10
36
 
11
37
  #
12
- # Configures Faraday with the config values
38
+ # Configures a new Faraday connection based on the request configuration
39
+ #
40
+ # @param request [HTTP::Request] The request configuration to use
13
41
  #
14
- # @param request [HTTP::Request]
42
+ # @return [Backend] A new backend instance with a configured connection
15
43
  #
16
44
  def initialize(request)
17
45
  @connection =
@@ -23,7 +51,7 @@ module SpecForge
23
51
  end
24
52
 
25
53
  # Headers
26
- builder.headers.merge!(request.headers.resolve)
54
+ builder.headers.merge!(request.headers.resolved)
27
55
  end
28
56
  end
29
57
 
@@ -31,79 +59,112 @@ module SpecForge
31
59
  # Executes a DELETE request to <base_url>/<provided_url>
32
60
  #
33
61
  # @param url [String] The URL path to DELETE
34
- # @param query [Hash] Any query attributes to send
35
- # @param body [Hash] Any body data to send
62
+ # @param headers [Hash] HTTP headers to add
63
+ # @param query [Hash] Any query parameters to send
64
+ # @param body [Hash] Any body data to send
36
65
  #
37
- # @return [Hash] The response
66
+ # @return [Faraday::Response] The HTTP response
38
67
  #
39
- def delete(url, query: {}, body: {})
68
+ def delete(url, headers: {}, query: {}, body: {})
40
69
  url = normalize_url(url, query)
41
- connection.delete(url) { |request| update_request(request, query, body) }
70
+ connection.delete(url) { |request| update_request(request, headers, query, body) }
42
71
  end
43
72
 
44
73
  #
45
74
  # Executes a GET request to <base_url>/<provided_url>
46
75
  #
47
76
  # @param url [String] The URL path to GET
48
- # @param query [Hash] Any query attributes to send
49
- # @param body [Hash] Any body data to send
77
+ # @param headers [Hash] HTTP headers to add
78
+ # @param query [Hash] Any query parameters to send
79
+ # @param body [Hash] Any body data to send
50
80
  #
51
- # @return [Hash] The response
81
+ # @return [Faraday::Response] The HTTP response
52
82
  #
53
- def get(url, query: {}, body: {})
83
+ def get(url, headers: {}, query: {}, body: {})
54
84
  url = normalize_url(url, query)
55
- connection.get(url) { |request| update_request(request, query, body) }
85
+ connection.get(url) { |request| update_request(request, headers, query, body) }
56
86
  end
57
87
 
58
88
  #
59
89
  # Executes a PATCH request to <base_url>/<provided_url>
60
90
  #
61
91
  # @param url [String] The URL path to PATCH
62
- # @param query [Hash] Any query attributes to send
63
- # @param body [Hash] Any body data to send
92
+ # @param headers [Hash] HTTP headers to add
93
+ # @param query [Hash] Any query parameters to send
94
+ # @param body [Hash] Any body data to send
64
95
  #
65
- # @return [Hash] The response
96
+ # @return [Faraday::Response] The HTTP response
66
97
  #
67
- def patch(url, query: {}, body: {})
98
+ def patch(url, headers: {}, query: {}, body: {})
68
99
  url = normalize_url(url, query)
69
- connection.patch(url) { |request| update_request(request, query, body) }
100
+ connection.patch(url) { |request| update_request(request, headers, query, body) }
70
101
  end
71
102
 
72
103
  #
73
104
  # Executes a POST request to <base_url>/<provided_url>
74
105
  #
75
106
  # @param url [String] The URL path to POST
76
- # @param query [Hash] Any query attributes to send
77
- # @param body [Hash] Any body data to send
107
+ # @param headers [Hash] HTTP headers to add
108
+ # @param query [Hash] Any query parameters to send
109
+ # @param body [Hash] Any body data to send
78
110
  #
79
- # @return [Hash] The response
111
+ # @return [Faraday::Response] The HTTP response
80
112
  #
81
- def post(url, query: {}, body: {})
113
+ def post(url, headers: {}, query: {}, body: {})
82
114
  url = normalize_url(url, query)
83
- connection.post(url) { |request| update_request(request, query, body) }
115
+ connection.post(url) { |request| update_request(request, headers, query, body) }
84
116
  end
85
117
 
86
118
  #
87
119
  # Executes a PUT request to <base_url>/<provided_url>
88
120
  #
89
121
  # @param url [String] The URL path to PUT
90
- # @param query [Hash] Any query attributes to send
91
- # @param body [Hash] Any body data to send
122
+ # @param headers [Hash] HTTP headers to add
123
+ # @param query [Hash] Any query parameters to send
124
+ # @param body [Hash] Any body data to send
92
125
  #
93
- # @return [Hash] The response
126
+ # @return [Faraday::Response] The HTTP response
94
127
  #
95
- def put(url, query: {}, body: {})
128
+ def put(url, headers: {}, query: {}, body: {})
96
129
  url = normalize_url(url, query)
97
- connection.put(url) { |request| update_request(request, query, body) }
130
+ connection.put(url) { |request| update_request(request, headers, query, body) }
98
131
  end
99
132
 
100
133
  private
101
134
 
102
- def update_request(request, query, body)
135
+ #
136
+ # Updates the request with query parameters and body
137
+ #
138
+ # @param request [Faraday::Request] The request to update
139
+ # @param headers [Hash] HTTP headers to add
140
+ # @param query [Hash] Query parameters to add
141
+ # @param body [Hash] Body data to add
142
+ #
143
+ # @private
144
+ #
145
+ def update_request(request, headers, query, body)
146
+ request.headers.merge!(headers)
147
+ request.headers.transform_values!(&:to_s)
148
+
103
149
  request.params.merge!(query)
104
150
  request.body = body.to_json
105
151
  end
106
152
 
153
+ #
154
+ # Normalizes a URL by replacing path parameters with their values
155
+ #
156
+ # Handles both curly brace style {param} and colon style :param
157
+ # Parameters are extracted from the query hash and removed after substitution
158
+ #
159
+ # @param url [String] The URL pattern with potential placeholders
160
+ # @param query [Hash] Query parameters that may contain values for placeholders
161
+ #
162
+ # @return [String] The URL with placeholders replaced by actual values
163
+ #
164
+ # @raise [URI::InvalidURIError] If the resulting URL is invalid
165
+ #
166
+ # @private
167
+ #
107
168
  def normalize_url(url, query)
108
169
  # /users/<user_id>
109
170
  url = replace_url_placeholder(url, query, CURLY_PLACEHOLDER)
@@ -122,6 +183,17 @@ module SpecForge
122
183
  url
123
184
  end
124
185
 
186
+ #
187
+ # Replaces URL placeholders with values from the query hash
188
+ #
189
+ # @param url [String] The URL with placeholders
190
+ # @param query [Hash] The query parameters containing values
191
+ # @param regex [Regexp] The pattern to match (curly or colon style)
192
+ #
193
+ # @return [String] The URL with placeholders replaced
194
+ #
195
+ # @private
196
+ #
125
197
  def replace_url_placeholder(url, query, regex)
126
198
  match = url.match(regex)
127
199
  return url if match.nil?
@@ -2,30 +2,40 @@
2
2
 
3
3
  module SpecForge
4
4
  module HTTP
5
+ #
6
+ # HTTP client that executes requests and returns responses
7
+ #
8
+ # This class serves as a mediator between the test expectations
9
+ # and the actual HTTP backend implementation.
10
+ #
11
+ # @example Basic usage
12
+ # client = HTTP::Client.new(base_url: "https://api.example.com")
13
+ # response = client.call(request)
14
+ #
5
15
  class Client
6
- attr_reader :request
7
-
8
16
  #
9
- # Creates a new HTTP client to middleman between the tests and the backend
17
+ # Creates a new HTTP client with configured backend
10
18
  #
11
- # @param ** [Hash] Request attributes
19
+ # @return [Client] A new HTTP client instance
12
20
  #
13
21
  def initialize(**)
14
- @request = Request.new(**)
15
- @adapter = Backend.new(request)
22
+ @backend = Backend.new(HTTP::Request.new(**))
16
23
  end
17
24
 
18
25
  #
19
- # Triggers an HTTP request to the URL
26
+ # Executes an HTTP request and returns the response
27
+ #
28
+ # @param request [HTTP::Request] The request to execute
20
29
  #
21
- # @return [Hash] The response
30
+ # @return [Faraday::Response] The HTTP response
22
31
  #
23
- def call
24
- @adapter.public_send(
25
- request.http_verb,
32
+ def call(request)
33
+ @backend.public_send(
34
+ request.http_verb.to_s.downcase,
26
35
  request.url,
27
- query: request.query.resolve,
28
- body: request.body.resolve
36
+ headers: request.headers.resolved,
37
+ query: request.query.resolved,
38
+ body: request.body.resolved
29
39
  )
30
40
  end
31
41
  end