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
@@ -2,170 +2,244 @@
2
2
 
3
3
  module SpecForge
4
4
  #
5
- # Represents a collection of related specs loaded from a single YAML file
5
+ # The main execution engine for running blueprints
6
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)
7
+ # Forge orchestrates the execution of blueprints by managing the execution
8
+ # context, HTTP client, variable storage, and display output. It processes
9
+ # each step sequentially and tracks statistics across the run.
16
10
  #
17
11
  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
12
+ class << self
13
+ #
14
+ # Initializes SpecForge by loading the forge_helper and factories
15
+ #
16
+ # @return [Class] self for chaining
17
+ #
18
+ def ignite
19
+ load_forge_helper
20
+ Factory.load_and_register
24
21
 
25
- #
26
- # Global variables and configuration shared across all specs
27
- #
28
- # @return [Hash] The global variables and configuration
29
- #
30
- attr_reader :global
22
+ # Return for chaining
23
+ self
24
+ end
31
25
 
32
- #
33
- # Metadata about the spec file
34
- #
35
- # @return [Hash] File information such as path and name
36
- #
37
- attr_reader :metadata
26
+ #
27
+ # Creates and runs a new Forge instance with the given blueprints
28
+ #
29
+ # @param blueprints [Array<Blueprint>] The blueprints to execute
30
+ # @option verbosity_level [Integer] Output verbosity (0-3)
31
+ # @option hooks [Hash] Forge-level event hooks
32
+ #
33
+ # @return [void]
34
+ #
35
+ def run(blueprints, **)
36
+ new(blueprints, **).run
37
+ end
38
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
39
+ #
40
+ # Returns the current execution context for the current thread
41
+ #
42
+ # @return [Context, nil] The current context or nil if not executing
43
+ #
44
+ def context
45
+ Thread.current[:spec_forge_context]
46
+ end
45
47
 
46
- #
47
- # Request configuration for the specs
48
- #
49
- # @return [Hash] HTTP request configuration by spec
50
- #
51
- attr_reader :request
48
+ #
49
+ # Executes a block with a given context
50
+ #
51
+ # @param context [Context] The context to use during execution
52
+ #
53
+ # @yield Block to execute with the context
54
+ #
55
+ # @return [Object] The result of the block
56
+ #
57
+ def with_context(context)
58
+ old_context = Thread.current[:spec_forge_context]
59
+ Thread.current[:spec_forge_context] = context
60
+ yield
61
+ ensure
62
+ Thread.current[:spec_forge_context] = old_context
63
+ end
52
64
 
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
65
+ private
59
66
 
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]
67
+ def load_forge_helper
68
+ forge_helper = SpecForge.forge_path.join("forge_helper.rb")
69
+ return unless File.exist?(forge_helper)
71
70
 
72
- @global = global
73
- @metadata = metadata
71
+ require_relative forge_helper
74
72
 
75
- @variables = extract_variables!(specs)
76
- @request = extract_request!(specs)
77
- @specs = specs.map { |spec| Spec.new(**spec) }
73
+ # Revalidate in case anything was changed
74
+ SpecForge.configuration.validate
75
+ end
78
76
  end
79
77
 
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
78
+ # @return [Array<Blueprint>] The blueprints being executed
79
+ attr_reader :blueprints
93
80
 
94
- private
81
+ # @return [Callbacks] Callback registry for this forge run
82
+ attr_reader :callbacks
83
+
84
+ # @return [Display] Display handler for output formatting
85
+ attr_reader :display
86
+
87
+ # @return [Array<Hash>] List of failed expectations
88
+ attr_reader :failures
89
+
90
+ # @return [Hash{Symbol => Array<Step::Call>}] Forge-level before and after hooks
91
+ attr_reader :hooks
92
+
93
+ # @return [HTTP::Client] HTTP client for making requests
94
+ attr_reader :http_client
95
+
96
+ # @return [Runner] RSpec runner for executing expectations
97
+ attr_reader :runner
98
+
99
+ # @return [Hash] Statistics about the current run
100
+ attr_reader :stats
101
+
102
+ # @return [Timer] Timer for tracking execution duration
103
+ attr_reader :timer
104
+
105
+ # @return [Variables] Variable storage for the current run
106
+ attr_reader :variables
95
107
 
96
108
  #
97
- # Extracts variables from specs and organizes them into base and overlay variables
98
- #
99
- # @param specs [Array<Hash>] Array of spec definitions
109
+ # Creates a new Forge instance with the specified blueprints
100
110
  #
101
- # @return [Hash] A hash mapping spec IDs to their variables
111
+ # @param blueprints [Array<Blueprint>] The blueprints to execute
112
+ # @param verbosity_level [Integer] Output verbosity (0-3)
113
+ # @param hooks [Hash] Forge-level event hooks
102
114
  #
103
- # @private
115
+ # @return [Forge] A new forge instance
104
116
  #
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
117
+ def initialize(blueprints, verbosity_level: 0, hooks: {})
118
+ @blueprints = blueprints
119
+ @callbacks = Callbacks.new
120
+ @display = Display.new(verbosity_level:)
121
+ @failures = []
122
+ @hooks = Step::Call.wrap_hooks(hooks)
123
+ @http_client = HTTP::Client.new
124
+ @runner = Runner.new
125
+ @stats = {}
126
+ @timer = Timer.new
127
+ @variables = Variables.new(static: SpecForge.configuration.global_variables)
121
128
 
122
- hash[spec[:id]] = {base: spec.delete(:variables), overlay:}
123
- end
129
+ reset_stats
124
130
  end
125
131
 
126
132
  #
127
- # Extracts request configuration from specs and organizes them into base and overlay configs
133
+ # Executes all blueprints and their steps
128
134
  #
129
- # @param specs [Array<Hash>] Array of spec definitions
135
+ # @return [void]
130
136
  #
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
- ]
137
+ def run
138
+ context = Context.new(variables:)
139
+
140
+ Forge.with_context(context) do
141
+ forge_start
142
+
143
+ @blueprints.each do |blueprint|
144
+ blueprint_start(blueprint)
145
+
146
+ blueprint.steps.each do |step|
147
+ step_start(blueprint, step)
148
+ step_action(blueprint, step)
149
+ step_end(blueprint, step)
150
+ rescue => e
151
+ step_end(blueprint, step, error: e)
152
+ break
153
+ end
154
+
155
+ blueprint_end(blueprint)
157
156
  end
157
+ ensure
158
+ forge_end
159
+ end
160
+ end
158
161
 
159
- overlay.compact_blank!
162
+ private
160
163
 
161
- base = spec.extract!(*HTTP::REQUEST_ATTRIBUTES)
162
- base.compact_blank!
164
+ def reset_stats
165
+ @stats = {
166
+ blueprints: 0,
167
+ steps: 0,
168
+ passed: 0,
169
+ failed: 0
170
+ }
171
+ end
163
172
 
164
- base = config.deep_merge(base)
165
- base[:http_verb] ||= "GET"
173
+ def forge_start
174
+ reset_stats
166
175
 
167
- hash[spec[:id]] = {base:, overlay:}
176
+ # Load the callbacks from the configuration
177
+ SpecForge.configuration.callbacks.each { |name, block| @callbacks.register(name, &block) }
178
+
179
+ @display.forge_start(self)
180
+ @timer.start
181
+
182
+ Hooks.before_forge(self)
183
+ end
184
+
185
+ def blueprint_start(blueprint)
186
+ @variables.clear
187
+ @failures.clear
188
+
189
+ @display.blueprint_start(blueprint)
190
+
191
+ Hooks.before_blueprint(self, blueprint)
192
+ end
193
+
194
+ def step_start(blueprint, step)
195
+ @display.step_start(step)
196
+
197
+ Hooks.before_step(self, blueprint, step)
198
+ end
199
+
200
+ def step_action(blueprint, step)
201
+ # HEY! LISTEN: These read and write to the forge's state
202
+ Call.new(step).run(self) if step.calls?
203
+ Request.new(step).run(self) if step.request?
204
+ Debug.new(step).run(self, blueprint) if step.debug?
205
+ Expect.new(step).run(self) if step.expects?
206
+ Store.new(step).run(self) if step.store?
207
+ end
208
+
209
+ def step_end(blueprint, step, error: nil)
210
+ @stats[:steps] += 1
211
+
212
+ if error.is_a?(Error::ExpectationFailure)
213
+ @failures += error.failed_examples.map { |example| {step:, example:} }
168
214
  end
215
+
216
+ Hooks.after_step(self, blueprint, step, error:)
217
+
218
+ @display.step_end(self, step, error:)
219
+
220
+ # Bubble up only AFTER display has been updated
221
+ raise error if error && !error.is_a?(Error::ExpectationFailure)
222
+ ensure
223
+ # Drop the request/response data from scope
224
+ # Do this after everything is done so variables can be printed out if needed
225
+ @variables.except!(:request, :response)
226
+ end
227
+
228
+ def blueprint_end(blueprint)
229
+ @stats[:blueprints] += 1
230
+
231
+ @display.blueprint_end(blueprint, success: @failures.empty?)
232
+
233
+ Hooks.after_blueprint(self, blueprint)
234
+ end
235
+
236
+ def forge_end
237
+ @timer.stop
238
+
239
+ @display.forge_end(self)
240
+ Hooks.after_forge(self)
241
+
242
+ @display.stats(self)
169
243
  end
170
244
  end
171
245
  end
@@ -3,30 +3,12 @@
3
3
  module SpecForge
4
4
  module HTTP
5
5
  #
6
- # Handles the low-level HTTP operations using Faraday
6
+ # Low-level HTTP client wrapper around Faraday
7
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")
8
+ # Backend provides methods for each HTTP verb and handles the actual
9
+ # communication with the server. It's used internally by HTTP::Client.
14
10
  #
15
11
  class Backend
16
- #
17
- # Regular expression to match { placeholder } style URL parameters
18
- #
19
- # @return [Regexp]
20
- #
21
- CURLY_PLACEHOLDER = /\{(\w+)\}/
22
-
23
- #
24
- # Regular expression to match :placeholder style URL parameters
25
- #
26
- # @return [Regexp]
27
- #
28
- COLON_PLACEHOLDER = /:(\w+)/
29
-
30
12
  #
31
13
  # The configured Faraday connection
32
14
  #
@@ -35,180 +17,101 @@ module SpecForge
35
17
  attr_reader :connection
36
18
 
37
19
  #
38
- # Configures a new Faraday connection based on the request configuration
20
+ # Creates a new HTTP backend with a Faraday connection
39
21
  #
40
- # @param request [HTTP::Request] The request configuration to use
22
+ # @return [Backend] A new backend instance
41
23
  #
42
- # @return [Backend] A new backend instance with a configured connection
43
- #
44
- def initialize(request)
45
- @connection =
46
- Faraday.new(url: request.base_url) do |builder|
47
- # Content-Type
48
- if !request.headers.key?("Content-Type")
49
- builder.request :json
50
- builder.response :json
51
- end
52
-
53
- # Headers
54
- builder.headers.merge!(request.headers.resolved)
55
- end
24
+ def initialize
25
+ @connection = Faraday.new
56
26
  end
57
27
 
58
28
  #
59
29
  # Executes a DELETE request to <base_url>/<provided_url>
60
30
  #
61
- # @param url [String] The URL path to DELETE
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
31
+ # @option options [String] :url The URL path to DELETE
32
+ # @option options [String] :base_url The base URL to use for the request
33
+ # @option options [Hash] :headers HTTP headers to add
34
+ # @option options [Hash] :query Any query parameters to send
35
+ # @option options [Hash] :body Any body data to send
65
36
  #
66
37
  # @return [Faraday::Response] The HTTP response
67
38
  #
68
- def delete(url, headers: {}, query: {}, body: {})
69
- url = normalize_url(url, query)
70
- connection.delete(url) { |request| update_request(request, headers, query, body) }
39
+ def delete(**)
40
+ run_http_method(:delete, **)
71
41
  end
72
42
 
73
43
  #
74
44
  # Executes a GET request to <base_url>/<provided_url>
75
45
  #
76
- # @param url [String] The URL path to GET
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
46
+ # @option options [String] :url The URL path to GET
47
+ # @option options [String] :base_url The base URL to use for the request
48
+ # @option options [Hash] :headers HTTP headers to add
49
+ # @option options [Hash] :query Any query parameters to send
50
+ # @option options [Hash] :body Any body data to send
80
51
  #
81
52
  # @return [Faraday::Response] The HTTP response
82
53
  #
83
- def get(url, headers: {}, query: {}, body: {})
84
- url = normalize_url(url, query)
85
- connection.get(url) { |request| update_request(request, headers, query, body) }
54
+ def get(**)
55
+ run_http_method(:get, **)
86
56
  end
87
57
 
88
58
  #
89
59
  # Executes a PATCH request to <base_url>/<provided_url>
90
60
  #
91
- # @param url [String] The URL path to PATCH
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
61
+ # @option options [String] :url The URL path to PATCH
62
+ # @option options [String] :base_url The base URL to use for the request
63
+ # @option options [Hash] :headers HTTP headers to add
64
+ # @option options [Hash] :query Any query parameters to send
65
+ # @option options [Hash] :body Any body data to send
95
66
  #
96
67
  # @return [Faraday::Response] The HTTP response
97
68
  #
98
- def patch(url, headers: {}, query: {}, body: {})
99
- url = normalize_url(url, query)
100
- connection.patch(url) { |request| update_request(request, headers, query, body) }
69
+ def patch(**)
70
+ run_http_method(:patch, **)
101
71
  end
102
72
 
103
73
  #
104
74
  # Executes a POST request to <base_url>/<provided_url>
105
75
  #
106
- # @param url [String] The URL path to POST
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
76
+ # @option options [String] :url The URL path to POST
77
+ # @option options [String] :base_url The base URL to use for the request
78
+ # @option options [Hash] :headers HTTP headers to add
79
+ # @option options [Hash] :query Any query parameters to send
80
+ # @option options [Hash] :body Any body data to send
110
81
  #
111
82
  # @return [Faraday::Response] The HTTP response
112
83
  #
113
- def post(url, headers: {}, query: {}, body: {})
114
- url = normalize_url(url, query)
115
- connection.post(url) { |request| update_request(request, headers, query, body) }
84
+ def post(**)
85
+ run_http_method(:post, **)
116
86
  end
117
87
 
118
88
  #
119
89
  # Executes a PUT request to <base_url>/<provided_url>
120
90
  #
121
- # @param url [String] The URL path to PUT
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
91
+ # @option options [String] :url The URL path to PUT
92
+ # @option options [String] :base_url The base URL to use for the request
93
+ # @option options [Hash] :headers HTTP headers to add
94
+ # @option options [Hash] :query Any query parameters to send
95
+ # @option options [Hash] :body Any body data to send
125
96
  #
126
97
  # @return [Faraday::Response] The HTTP response
127
98
  #
128
- def put(url, headers: {}, query: {}, body: {})
129
- url = normalize_url(url, query)
130
- connection.put(url) { |request| update_request(request, headers, query, body) }
99
+ def put(**)
100
+ run_http_method(:put, **)
131
101
  end
132
102
 
133
103
  private
134
104
 
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
-
149
- request.params.merge!(query)
150
- request.body = body.to_json
151
- end
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
- #
168
- def normalize_url(url, query)
169
- # Strip leading slash so paths properly append to base URL
170
- url = url.delete_prefix("/")
105
+ def run_http_method(method, url:, base_url:, headers: {}, query: {}, body: {})
106
+ connection.url_prefix = base_url
171
107
 
172
- # /users/<user_id>
173
- url = replace_url_placeholder(url, query, CURLY_PLACEHOLDER)
108
+ connection.public_send(method, url) do |request|
109
+ request.headers.merge!(headers)
110
+ request.headers.transform_values!(&:to_s)
174
111
 
175
- # /users/:user_id
176
- url = replace_url_placeholder(url, query, COLON_PLACEHOLDER)
177
-
178
- # Attempt to validate (the colon style is considered valid apparently)
179
- begin
180
- URI.parse(url)
181
- rescue URI::InvalidURIError
182
- raise URI::InvalidURIError,
183
- "#{url.inspect} is not a valid URI. If you're using path parameters (like ':id' or '{id}'), ensure they are defined in the 'query' section."
112
+ request.params.merge!(query)
113
+ request.body = body
184
114
  end
185
-
186
- url
187
- end
188
-
189
- #
190
- # Replaces URL placeholders with values from the query hash
191
- #
192
- # @param url [String] The URL with placeholders
193
- # @param query [Hash] The query parameters containing values
194
- # @param regex [Regexp] The pattern to match (curly or colon style)
195
- #
196
- # @return [String] The URL with placeholders replaced
197
- #
198
- # @private
199
- #
200
- def replace_url_placeholder(url, query, regex)
201
- match = url.match(regex)
202
- return url if match.nil?
203
-
204
- key = match[1].to_sym
205
- return url unless query.key?(key)
206
-
207
- value = query.delete(key)
208
- url.gsub(
209
- match[0],
210
- URI.encode_uri_component(value.to_s)
211
- )
212
115
  end
213
116
  end
214
117
  end