spec_forge 0.1.0 → 0.2.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -2
  3. data/README.md +366 -143
  4. data/lib/spec_forge/attribute/chainable.rb +19 -16
  5. data/lib/spec_forge/attribute/factory.rb +6 -18
  6. data/lib/spec_forge/attribute/faker.rb +56 -20
  7. data/lib/spec_forge/attribute/literal.rb +4 -0
  8. data/lib/spec_forge/attribute/resolvable.rb +4 -6
  9. data/lib/spec_forge/attribute/resolvable_array.rb +4 -0
  10. data/lib/spec_forge/attribute/resolvable_hash.rb +4 -0
  11. data/lib/spec_forge/attribute/variable.rb +6 -13
  12. data/lib/spec_forge/attribute.rb +3 -12
  13. data/lib/spec_forge/cli/init.rb +2 -9
  14. data/lib/spec_forge/configuration.rb +58 -0
  15. data/lib/spec_forge/factory.rb +4 -4
  16. data/lib/spec_forge/http/backend.rb +42 -8
  17. data/lib/spec_forge/http/client.rb +2 -2
  18. data/lib/spec_forge/http/request.rb +11 -14
  19. data/lib/spec_forge/normalizer/configuration.rb +77 -0
  20. data/lib/spec_forge/normalizer/expectation.rb +1 -0
  21. data/lib/spec_forge/normalizer/spec.rb +1 -0
  22. data/lib/spec_forge/normalizer.rb +14 -11
  23. data/lib/spec_forge/runner.rb +98 -72
  24. data/lib/spec_forge/spec/expectation/constraint.rb +2 -5
  25. data/lib/spec_forge/spec/expectation.rb +32 -13
  26. data/lib/spec_forge/spec.rb +20 -14
  27. data/lib/spec_forge/version.rb +1 -1
  28. data/lib/spec_forge.rb +20 -11
  29. data/lib/templates/forge_helper.tt +48 -0
  30. data/spec_forge/forge_helper.rb +37 -0
  31. data/spec_forge/specs/users.yml +6 -4
  32. metadata +6 -7
  33. data/lib/spec_forge/config.rb +0 -84
  34. data/lib/spec_forge/environment.rb +0 -71
  35. data/lib/spec_forge/normalizer/config.rb +0 -104
  36. data/lib/templates/config.tt +0 -19
  37. data/spec_forge/config.yml +0 -19
@@ -5,7 +5,7 @@ module SpecForge
5
5
  module Chainable
6
6
  NUMBER_REGEX = /^\d+$/i
7
7
 
8
- attr_reader :invocation_chain, :base_object
8
+ attr_reader :header, :invocation_chain, :base_object
9
9
 
10
10
  #
11
11
  # Represents any attribute that is a series of chained invocations:
@@ -21,37 +21,40 @@ module SpecForge
21
21
  # Drop the keyword
22
22
  sections = input.split(".")[1..]
23
23
 
24
- # The "header" is the first element in this array
25
- @invocation_chain = sections || []
24
+ @header = sections.first&.to_sym
25
+ @invocation_chain = sections[1..] || []
26
26
  end
27
27
 
28
28
  def value
29
29
  invoke_chain
30
30
  end
31
31
 
32
- #
33
- # Custom implementation to ensure the underlying values are resolved
34
- # without breaking #value's functionality
35
- #
36
32
  def resolve
37
- @resolved ||= __resolve(invoke_chain(resolve: true))
33
+ @resolved ||= resolve_chain
38
34
  end
39
35
 
40
36
  private
41
37
 
42
- def invoke_chain(resolve: false)
43
- current_value = @base_object
38
+ def invoke_chain
39
+ traverse_chain(resolve: false)
40
+ end
41
+
42
+ def resolve_chain
43
+ __resolve(traverse_chain(resolve: true))
44
+ end
45
+
46
+ def traverse_chain(resolve:)
47
+ result = invocation_chain.reduce(base_object) do |current_value, step|
48
+ next_value = retrieve_value(current_value, resolve:)
44
49
 
45
- invocation_chain.each do |step|
46
- object = retrieve_value(current_value, resolve:)
47
- current_value = invoke(step, object)
50
+ invoke(step, next_value)
48
51
  end
49
52
 
50
- retrieve_value(current_value, resolve:)
53
+ retrieve_value(result, resolve:)
51
54
  end
52
55
 
53
- def retrieve_value(object, resolve: false)
54
- return object if !object.is_a?(Attribute)
56
+ def retrieve_value(object, resolve:)
57
+ return object unless object.is_a?(Attribute)
55
58
 
56
59
  resolve ? object.resolve : object.value
57
60
  end
@@ -14,7 +14,7 @@ module SpecForge
14
14
  build_stubbed
15
15
  ].freeze
16
16
 
17
- attr_reader :factory_name
17
+ alias_method :factory_name, :header
18
18
 
19
19
  #
20
20
  # Represents any attribute that is a factory reference
@@ -24,39 +24,27 @@ module SpecForge
24
24
  def initialize(...)
25
25
  super
26
26
 
27
- @factory_name = invocation_chain.shift&.to_sym
28
-
29
27
  # Check the arguments before preparing them
30
28
  arguments[:keyword] = Normalizer.normalize_factory_reference!(arguments[:keyword])
31
29
 
32
30
  prepare_arguments!
33
31
  end
34
32
 
35
- def value
36
- @base_object = create_factory_object
37
- super
38
- end
39
-
40
- def resolve
41
- @base_object = create_factory_object
42
- super
43
- end
44
-
45
33
  private
46
34
 
47
- def create_factory_object
35
+ def base_object
48
36
  attributes = arguments[:keyword]
49
- return FactoryBot.create(@factory_name) if attributes.blank?
37
+ return FactoryBot.create(factory_name) if attributes.blank?
50
38
 
51
39
  # Determine build strat
52
- build_strategy = attributes[:build_strategy].resolve
40
+ build_strategy = attributes[:build_strategy].resolve_value
53
41
 
54
42
  # stubbed => build_stubbed
55
43
  build_strategy.prepend("build_") if build_strategy == "stubbed"
56
44
  raise InvalidBuildStrategy, build_strategy unless BUILD_STRATEGIES.include?(build_strategy)
57
45
 
58
- attributes = attributes[:attributes].resolve
59
- FactoryBot.public_send(build_strategy, @factory_name, **attributes)
46
+ attributes = attributes[:attributes].resolve_value
47
+ FactoryBot.public_send(build_strategy, factory_name, **attributes)
60
48
  end
61
49
  end
62
50
  end
@@ -3,6 +3,8 @@
3
3
  module SpecForge
4
4
  class Attribute
5
5
  class Faker < Parameterized
6
+ include Chainable
7
+
6
8
  KEYWORD_REGEX = /^faker\./i
7
9
 
8
10
  attr_reader :faker_class, :faker_method
@@ -15,39 +17,73 @@ module SpecForge
15
17
  def initialize(...)
16
18
  super
17
19
 
18
- # As of right now, Faker only goes 2 sub classes deep. I've added +2 padding just in case
19
- # faker.class.method
20
- # faker.class.subclass.method
21
- sections = input.split(".")[0..5]
20
+ @faker_class, @faker_method = extract_faker_call
21
+
22
+ prepare_arguments!
23
+ end
24
+
25
+ private
26
+
27
+ def base_object
28
+ if uses_positional_arguments?(faker_method)
29
+ faker_method.call(*arguments[:positional].resolve)
30
+ elsif uses_keyword_arguments?(faker_method)
31
+ faker_method.call(**arguments[:keyword].resolve)
32
+ else
33
+ faker_method.call
34
+ end
35
+ end
36
+
37
+ def extract_faker_call
38
+ class_name = header.downcase.to_s
39
+
40
+ # Simple case: faker.<header>.<method>
41
+ if invocation_chain.size == 1
42
+ return resolve_faker_class_and_method(class_name, invocation_chain.shift)
43
+ end
44
+
45
+ # Try each part of the chain as a potential class name
46
+ # Example: faker.games.zelda.game.underscore
47
+ namespace = []
48
+
49
+ while invocation_chain.any?
50
+ part = invocation_chain.first.downcase
51
+ test_class_name = ([class_name] + namespace + [part]).map(&:camelize).join("::")
52
+
53
+ begin
54
+ "::Faker::#{test_class_name}".constantize
22
55
 
23
- class_name = sections[0..-2].join("::").underscore.classify
24
- method_name = sections.last
56
+ namespace << invocation_chain.shift
57
+ rescue NameError
58
+ # This part isn't a valid class, so it must be our method
59
+ method_name = invocation_chain.shift
60
+ class_name = ([class_name] + namespace).map(&:camelize).join("::")
61
+
62
+ return resolve_faker_class_and_method(class_name, method_name)
63
+ end
64
+ end
25
65
 
66
+ # If we get here, we consumed all parts as classes but found no method
67
+ class_name = ([class_name] + namespace).map(&:camelize).join("::")
68
+ raise InvalidFakerMethodError.new(nil, "::#{class_name}".constantize)
69
+ end
70
+
71
+ def resolve_faker_class_and_method(class_name, method_name)
26
72
  # Load the class
27
- @faker_class = begin
28
- "::#{class_name}".constantize
73
+ faker_class = begin
74
+ "::Faker::#{class_name.camelize}".constantize
29
75
  rescue NameError
30
76
  raise InvalidFakerClassError, class_name
31
77
  end
32
78
 
33
79
  # Load the method
34
- @faker_method = begin
80
+ faker_method = begin
35
81
  faker_class.method(method_name)
36
82
  rescue NameError
37
83
  raise InvalidFakerMethodError.new(method_name, faker_class)
38
84
  end
39
85
 
40
- prepare_arguments!
41
- end
42
-
43
- def value
44
- if uses_positional_arguments?(faker_method)
45
- faker_method.call(*arguments[:positional].resolve)
46
- elsif uses_keyword_arguments?(faker_method)
47
- faker_method.call(**arguments[:keyword].resolve)
48
- else
49
- faker_method.call
50
- end
86
+ [faker_class, faker_method]
51
87
  end
52
88
  end
53
89
  end
@@ -22,6 +22,10 @@ module SpecForge
22
22
  input
23
23
  end
24
24
  end
25
+
26
+ def resolve
27
+ @value
28
+ end
25
29
  end
26
30
  end
27
31
  end
@@ -6,16 +6,14 @@ module SpecForge
6
6
  # Helpers for ResolvableHash and ResolvableArray
7
7
  #
8
8
  module Resolvable
9
- # @private
10
- def to_proc
11
- this = self
12
- -> { this.resolve }
13
- end
14
-
15
9
  # @private
16
10
  def resolvable_proc
17
11
  ->(v) { v.respond_to?(:resolve) ? v.resolve : v }
18
12
  end
13
+
14
+ def resolvable_value_proc
15
+ ->(v) { v.respond_to?(:resolve_value) ? v.resolve_value : v }
16
+ end
19
17
  end
20
18
  end
21
19
  end
@@ -16,6 +16,10 @@ module SpecForge
16
16
  value.map(&resolvable_proc)
17
17
  end
18
18
 
19
+ def resolve_value
20
+ value.map(&resolvable_value_proc)
21
+ end
22
+
19
23
  def bind_variables(variables)
20
24
  value.each { |v| Attribute.bind_variables(v, variables) }
21
25
  end
@@ -16,6 +16,10 @@ module SpecForge
16
16
  value.transform_values(&resolvable_proc)
17
17
  end
18
18
 
19
+ def resolve_value
20
+ value.transform_values(&resolvable_value_proc)
21
+ end
22
+
19
23
  def bind_variables(variables)
20
24
  value.each_value { |v| Attribute.bind_variables(v, variables) }
21
25
  end
@@ -2,24 +2,17 @@
2
2
 
3
3
  module SpecForge
4
4
  class Attribute
5
+ #
6
+ # Represents any attribute that is a variable reference
7
+ #
8
+ # variables.<variable_name>
9
+ #
5
10
  class Variable < Attribute
6
11
  include Chainable
7
12
 
8
13
  KEYWORD_REGEX = /^variables\./i
9
14
 
10
- attr_reader :variable_name
11
-
12
- #
13
- # Represents any attribute that is a variable reference
14
- #
15
- # variables.<variable_name>
16
- #
17
- def initialize(...)
18
- super
19
-
20
- # Remove the variable name from the chain
21
- @variable_name = invocation_chain.shift&.to_sym
22
- end
15
+ alias_method :variable_name, :header
23
16
 
24
17
  def bind_variables(variables)
25
18
  raise InvalidTypeError.new(variables, Hash, for: "'variables'") unless Type.hash?(variables)
@@ -138,8 +138,6 @@ module SpecForge
138
138
 
139
139
  #
140
140
  # Returns the fully evaluated result, recursively resolving any nested attributes
141
- # Note: This method can only be called once to ensure data is correct across the board
142
- # You can still call #value if you need a new value
143
141
  #
144
142
  # @return [Object] The resolved value
145
143
  #
@@ -152,18 +150,11 @@ module SpecForge
152
150
  # attr.resolve # => [42, ["Jane"]]
153
151
  #
154
152
  def resolve
155
- # Past test for the variable
156
- @resolved ||= __resolve(value)
153
+ @resolved ||= resolve_value
157
154
  end
158
155
 
159
- #
160
- # Wraps the call to #resolve in a proc. Used with FactoryBot
161
- #
162
- # @return [Proc]
163
- #
164
- def to_proc
165
- this = self # kek - what are we, javascript?
166
- -> { this.resolve }
156
+ def resolve_value
157
+ __resolve(value)
167
158
  end
168
159
 
169
160
  #
@@ -13,17 +13,10 @@ module SpecForge
13
13
  actions.empty_directory "#{base_path}/specs"
14
14
 
15
15
  actions.template(
16
- "config.tt",
17
- SpecForge.root.join(base_path, "config.yml"),
18
- context: binding
16
+ "forge_helper.tt",
17
+ SpecForge.root.join(base_path, "forge_helper.rb")
19
18
  )
20
19
  end
21
-
22
- def default_authorization_value
23
- <<~STRING.chomp
24
- Bearer <%= ENV.fetch("API_TOKEN", "") %>
25
- STRING
26
- end
27
20
  end
28
21
  end
29
22
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Configuration < Struct.new(:base_url, :headers, :query, :factories, :specs, :on_debug)
5
+ ############################################################################
6
+
7
+ class Factories < Struct.new(:auto_discover, :paths)
8
+ attr_predicate :auto_discover, :paths
9
+
10
+ def initialize(auto_discover: true, paths: []) = super
11
+ end
12
+
13
+ ############################################################################
14
+
15
+ def self.overlay_options(source, overlay)
16
+ source.deep_merge(overlay) do |key, source_value, overlay_value|
17
+ # If overlay has a populated value, use it
18
+ if overlay_value.present? || overlay_value == false
19
+ overlay_value
20
+ # If source is nil and overlay exists (but wasn't "present"), use overlay
21
+ elsif source_value.nil? && !overlay_value.nil?
22
+ overlay_value
23
+ # Otherwise keep source value
24
+ else
25
+ source_value
26
+ end
27
+ end
28
+ end
29
+
30
+ def initialize
31
+ config = Normalizer.default_configuration
32
+
33
+ config[:base_url] = "http://localhost:3000"
34
+ config[:factories] = Factories.new
35
+ config[:specs] = RSpec.configuration
36
+ config[:on_debug] = Runner::DebugProxy.default
37
+
38
+ super(**config)
39
+ end
40
+
41
+ def validate
42
+ output = Normalizer.normalize_configuration!(to_h)
43
+
44
+ # In case any value was set to `nil`
45
+ self.base_url = output[:base_url] if base_url.blank?
46
+ self.query = output[:query] if query.blank?
47
+ self.headers = output[:headers] if headers.blank?
48
+
49
+ self
50
+ end
51
+
52
+ def to_h
53
+ hash = super.except(:specs)
54
+ hash[:factories] = hash[:factories].to_h
55
+ hash
56
+ end
57
+ end
58
+ end
@@ -8,11 +8,11 @@ module SpecForge
8
8
  # @param path [String, Path] The base path where the factories directory are located
9
9
  #
10
10
  def self.load_and_register(base_path)
11
- if (paths = SpecForge.config.factories.paths) && paths.size > 0
12
- FactoryBot.definition_file_paths = paths
11
+ if SpecForge.configuration.factories.paths?
12
+ FactoryBot.definition_file_paths = SpecForge.configuration.factories.paths
13
13
  end
14
14
 
15
- FactoryBot.find_definitions if SpecForge.config.factories.auto_discover?
15
+ FactoryBot.find_definitions if SpecForge.configuration.factories.auto_discover?
16
16
 
17
17
  factories = load_from_path(base_path.join("factories", "**/*.yml"))
18
18
  factories.each(&:register)
@@ -80,7 +80,7 @@ module SpecForge
80
80
  factory_forge = self
81
81
  dsl.factory(name, options) do
82
82
  factory_forge.attributes.each do |name, attribute|
83
- add_attribute(name, &attribute.to_proc)
83
+ add_attribute(name) { attribute.resolve_value }
84
84
  end
85
85
  end
86
86
 
@@ -3,6 +3,9 @@
3
3
  module SpecForge
4
4
  module HTTP
5
5
  class Backend
6
+ CURLY_PLACEHOLDER = /\{(\w+)\}/
7
+ COLON_PLACEHOLDER = /:(\w+)/
8
+
6
9
  attr_reader :connection
7
10
 
8
11
  #
@@ -13,20 +16,14 @@ module SpecForge
13
16
  def initialize(request)
14
17
  @connection =
15
18
  Faraday.new(url: request.base_url) do |builder|
16
- # Authorization
17
- builder.headers[request.authorization.header] = request.authorization.value
18
-
19
19
  # Content-Type
20
20
  if !request.headers.key?("Content-Type")
21
21
  builder.request :json
22
22
  builder.response :json
23
23
  end
24
24
 
25
- # Headers / Content Type
26
- builder.headers.merge!(request.headers)
27
-
28
- # Params
29
- builder.params.merge!(request.query.resolve)
25
+ # Headers
26
+ builder.headers.merge!(request.headers.resolve)
30
27
  end
31
28
  end
32
29
 
@@ -40,6 +37,7 @@ module SpecForge
40
37
  # @return [Hash] The response
41
38
  #
42
39
  def delete(url, query: {}, body: {})
40
+ url = normalize_url(url, query)
43
41
  connection.delete(url) { |request| update_request(request, query, body) }
44
42
  end
45
43
 
@@ -53,6 +51,7 @@ module SpecForge
53
51
  # @return [Hash] The response
54
52
  #
55
53
  def get(url, query: {}, body: {})
54
+ url = normalize_url(url, query)
56
55
  connection.get(url) { |request| update_request(request, query, body) }
57
56
  end
58
57
 
@@ -66,6 +65,7 @@ module SpecForge
66
65
  # @return [Hash] The response
67
66
  #
68
67
  def patch(url, query: {}, body: {})
68
+ url = normalize_url(url, query)
69
69
  connection.patch(url) { |request| update_request(request, query, body) }
70
70
  end
71
71
 
@@ -79,6 +79,7 @@ module SpecForge
79
79
  # @return [Hash] The response
80
80
  #
81
81
  def post(url, query: {}, body: {})
82
+ url = normalize_url(url, query)
82
83
  connection.post(url) { |request| update_request(request, query, body) }
83
84
  end
84
85
 
@@ -92,6 +93,7 @@ module SpecForge
92
93
  # @return [Hash] The response
93
94
  #
94
95
  def put(url, query: {}, body: {})
96
+ url = normalize_url(url, query)
95
97
  connection.put(url) { |request| update_request(request, query, body) }
96
98
  end
97
99
 
@@ -101,6 +103,38 @@ module SpecForge
101
103
  request.params.merge!(query)
102
104
  request.body = body.to_json
103
105
  end
106
+
107
+ def normalize_url(url, query)
108
+ # /users/<user_id>
109
+ url = replace_url_placeholder(url, query, CURLY_PLACEHOLDER)
110
+
111
+ # /users/:user_id
112
+ url = replace_url_placeholder(url, query, COLON_PLACEHOLDER)
113
+
114
+ # Attempt to validate (the colon style is considered valid apparently)
115
+ begin
116
+ URI.parse(url)
117
+ rescue URI::InvalidURIError
118
+ raise URI::InvalidURIError,
119
+ "#{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."
120
+ end
121
+
122
+ url
123
+ end
124
+
125
+ def replace_url_placeholder(url, query, regex)
126
+ match = url.match(regex)
127
+ return url if match.nil?
128
+
129
+ key = match[1].to_sym
130
+ return url unless query.key?(key)
131
+
132
+ value = query.delete(key)
133
+ url.gsub(
134
+ match[0],
135
+ URI.encode_uri_component(value.to_s)
136
+ )
137
+ end
104
138
  end
105
139
  end
106
140
  end
@@ -24,8 +24,8 @@ module SpecForge
24
24
  @adapter.public_send(
25
25
  request.http_verb,
26
26
  request.url,
27
- query: request.query.transform_values(&:resolve),
28
- body: request.body.transform_values(&:resolve)
27
+ query: request.query.resolve,
28
+ body: request.body.resolve
29
29
  )
30
30
  end
31
31
  end
@@ -2,9 +2,7 @@
2
2
 
3
3
  module SpecForge
4
4
  module HTTP
5
- attributes = [:base_url, :url, :http_method, :headers, :query, :body, :authorization]
6
-
7
- class Request < Data.define(*attributes)
5
+ class Request < Data.define(:base_url, :url, :http_method, :headers, :query, :body)
8
6
  HEADER = /^[A-Z][A-Za-z0-9!-]*$/
9
7
 
10
8
  #
@@ -23,33 +21,36 @@ module SpecForge
23
21
  # @option options [Hash] :body The request body (defaults to {})
24
22
  #
25
23
  def initialize(**options)
26
- base_url = extract_base_url(options)
27
24
  url = extract_url(options)
28
- headers = normalize_headers(options)
25
+ base_url = extract_base_url(options)
29
26
  http_method = normalize_http_method(options)
27
+ headers = normalize_headers(options)
30
28
  query = normalize_query(options)
31
29
  body = normalize_body(options)
32
- authorization = extract_authorization(options)
33
30
 
34
- super(base_url:, url:, http_method:, headers:, query:, body:, authorization:)
31
+ super(base_url:, url:, http_method:, headers:, query:, body:)
35
32
  end
36
33
 
37
34
  def http_verb
38
35
  http_method.name.downcase
39
36
  end
40
37
 
38
+ def to_h
39
+ super.transform_values { |v| v.respond_to?(:resolve) ? v.resolve : v }
40
+ end
41
+
41
42
  private
42
43
 
43
44
  def extract_base_url(options)
44
- options[:base_url]&.value&.presence || SpecForge.config.base_url
45
+ options[:base_url].resolve
45
46
  end
46
47
 
47
48
  def extract_url(options)
48
- options[:url].value
49
+ options[:url].resolve
49
50
  end
50
51
 
51
52
  def normalize_http_method(options)
52
- method = options[:http_method].value.presence || "GET"
53
+ method = options[:http_method].resolve.presence || "GET"
53
54
 
54
55
  if method.is_a?(String)
55
56
  Verb.from(method)
@@ -84,10 +85,6 @@ module SpecForge
84
85
  body = Attribute.bind_variables(options[:body], options[:variables])
85
86
  Attribute::ResolvableHash.new(body)
86
87
  end
87
-
88
- def extract_authorization(options)
89
- SpecForge.config.authorization.default
90
- end
91
88
  end
92
89
  end
93
90
  end