spec_forge 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.rspec +1 -0
  4. data/.standard.yml +3 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +426 -0
  9. data/Rakefile +6 -0
  10. data/bin/spec_forge +5 -0
  11. data/flake.lock +61 -0
  12. data/flake.nix +41 -0
  13. data/lib/spec_forge/attribute/chainable.rb +86 -0
  14. data/lib/spec_forge/attribute/factory.rb +63 -0
  15. data/lib/spec_forge/attribute/faker.rb +54 -0
  16. data/lib/spec_forge/attribute/literal.rb +27 -0
  17. data/lib/spec_forge/attribute/matcher.rb +118 -0
  18. data/lib/spec_forge/attribute/parameterized.rb +76 -0
  19. data/lib/spec_forge/attribute/resolvable.rb +21 -0
  20. data/lib/spec_forge/attribute/resolvable_array.rb +24 -0
  21. data/lib/spec_forge/attribute/resolvable_hash.rb +24 -0
  22. data/lib/spec_forge/attribute/transform.rb +39 -0
  23. data/lib/spec_forge/attribute/variable.rb +36 -0
  24. data/lib/spec_forge/attribute.rb +208 -0
  25. data/lib/spec_forge/cli/actions.rb +23 -0
  26. data/lib/spec_forge/cli/command.rb +127 -0
  27. data/lib/spec_forge/cli/init.rb +29 -0
  28. data/lib/spec_forge/cli/new.rb +161 -0
  29. data/lib/spec_forge/cli/run.rb +17 -0
  30. data/lib/spec_forge/cli.rb +43 -0
  31. data/lib/spec_forge/config.rb +84 -0
  32. data/lib/spec_forge/environment.rb +71 -0
  33. data/lib/spec_forge/error.rb +150 -0
  34. data/lib/spec_forge/factory.rb +104 -0
  35. data/lib/spec_forge/http/backend.rb +106 -0
  36. data/lib/spec_forge/http/client.rb +33 -0
  37. data/lib/spec_forge/http/request.rb +93 -0
  38. data/lib/spec_forge/http/verb.rb +118 -0
  39. data/lib/spec_forge/http.rb +6 -0
  40. data/lib/spec_forge/normalizer/config.rb +104 -0
  41. data/lib/spec_forge/normalizer/constraint.rb +47 -0
  42. data/lib/spec_forge/normalizer/expectation.rb +85 -0
  43. data/lib/spec_forge/normalizer/factory.rb +65 -0
  44. data/lib/spec_forge/normalizer/factory_reference.rb +66 -0
  45. data/lib/spec_forge/normalizer/spec.rb +73 -0
  46. data/lib/spec_forge/normalizer.rb +183 -0
  47. data/lib/spec_forge/runner.rb +91 -0
  48. data/lib/spec_forge/spec/expectation/constraint.rb +52 -0
  49. data/lib/spec_forge/spec/expectation.rb +53 -0
  50. data/lib/spec_forge/spec.rb +77 -0
  51. data/lib/spec_forge/type.rb +45 -0
  52. data/lib/spec_forge/version.rb +5 -0
  53. data/lib/spec_forge.rb +90 -0
  54. data/lib/templates/config.tt +19 -0
  55. data/spec_forge/config.yml +19 -0
  56. data/spec_forge/factories/user.yml +4 -0
  57. data/spec_forge/specs/users.yml +63 -0
  58. metadata +234 -0
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Need to be first
4
+ require_relative "attribute/parameterized"
5
+ require_relative "attribute/chainable"
6
+ require_relative "attribute/resolvable"
7
+
8
+ # Doesn't matter
9
+ require_relative "attribute/factory"
10
+ require_relative "attribute/faker"
11
+ require_relative "attribute/literal"
12
+ require_relative "attribute/matcher"
13
+ require_relative "attribute/resolvable_array"
14
+ require_relative "attribute/resolvable_hash"
15
+ require_relative "attribute/transform"
16
+ require_relative "attribute/variable"
17
+
18
+ module SpecForge
19
+ class Attribute
20
+ #
21
+ # Binds variables to Attribute objects
22
+ #
23
+ # @param input [Array, Hash, Attribute] The input to loop through or bind to
24
+ # @param variables [Hash] Any variables to available to assign
25
+ #
26
+ # @return [Array, Hash, Attribute] The input with bounded variables
27
+ #
28
+ def self.bind_variables(input, variables = {})
29
+ case input
30
+ when ArrayLike
31
+ input.each { |v| v.bind_variables(variables) }
32
+ when HashLike
33
+ input.each_value { |v| v.bind_variables(variables) }
34
+ when Attribute
35
+ input.bind_variables(variables)
36
+ end
37
+
38
+ input
39
+ end
40
+
41
+ #
42
+ # Creates an Attribute instance based on the input value's type and content.
43
+ # Recursively converts Array and Hash
44
+ #
45
+ # @param value [Object] The input value to convert into an Attribute
46
+ #
47
+ # @return [Attribute] A new Attribute instance of the appropriate subclass
48
+ #
49
+ def self.from(value)
50
+ case value
51
+ when String
52
+ from_string(value)
53
+ when HashLike
54
+ from_hash(value)
55
+ when Attribute
56
+ value
57
+ when ArrayLike
58
+ array = value.map { |v| Attribute.from(v) }
59
+ Attribute::ResolvableArray.new(array)
60
+ else
61
+ Literal.new(value)
62
+ end
63
+ end
64
+
65
+ #
66
+ # Creates an Attribute instance from a string, handling any macros
67
+ #
68
+ # @param string [String] The input string
69
+ #
70
+ # @return [Attribute]
71
+ #
72
+ # @private
73
+ #
74
+ def self.from_string(string)
75
+ case string
76
+ when Faker::KEYWORD_REGEX
77
+ Faker.new(string)
78
+ when Variable::KEYWORD_REGEX
79
+ Variable.new(string)
80
+ when Matcher::KEYWORD_REGEX
81
+ Matcher.new(string)
82
+ when Factory::KEYWORD_REGEX
83
+ Factory.new(string)
84
+ else
85
+ Literal.new(string)
86
+ end
87
+ end
88
+
89
+ #
90
+ # Creates an Attribute instance from a hash, handling any macros
91
+ #
92
+ # @param hash [Hash] The input hash
93
+ #
94
+ # @return [Attribute]
95
+ #
96
+ # @private
97
+ #
98
+ def self.from_hash(hash)
99
+ # Determine if the hash is an expanded macro call
100
+ has_macro = ->(h, regex) { h.any? { |k, _| k.match?(regex) } }
101
+
102
+ if has_macro.call(hash, Transform::KEYWORD_REGEX)
103
+ Transform.from_hash(hash)
104
+ elsif has_macro.call(hash, Faker::KEYWORD_REGEX)
105
+ Faker.from_hash(hash)
106
+ elsif has_macro.call(hash, Matcher::KEYWORD_REGEX)
107
+ Matcher.from_hash(hash)
108
+ elsif has_macro.call(hash, Factory::KEYWORD_REGEX)
109
+ Factory.from_hash(hash)
110
+ else
111
+ hash = hash.transform_values { |v| Attribute.from(v) }
112
+ Attribute::ResolvableHash.new(hash)
113
+ end
114
+ end
115
+
116
+ attr_reader :input
117
+
118
+ #
119
+ # @param input [Object] Anything
120
+ #
121
+ def initialize(input)
122
+ @input = input
123
+ end
124
+
125
+ #
126
+ # Returns the processed value of the input
127
+ #
128
+ # For literals, this is the input itself.
129
+ # For generated values (Faker, Transform), this is the result of their operations.
130
+ #
131
+ # @return [Object] The processed value of this attribute
132
+ #
133
+ # @raise [RuntimeError] if not implemented by subclass
134
+ #
135
+ def value
136
+ raise "not implemented"
137
+ end
138
+
139
+ #
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
+ #
144
+ # @return [Object] The resolved value
145
+ #
146
+ # @example Simple literal
147
+ # attr = Attribute::Literal.new("hello")
148
+ # attr.resolve # => "hello"
149
+ #
150
+ # @example Nested array with faker
151
+ # attr = Attribute::Literal.new(["faker.number.positive", ["faker.name.first_name"]])
152
+ # attr.resolve # => [42, ["Jane"]]
153
+ #
154
+ def resolve
155
+ # Past test for the variable
156
+ @resolved ||= __resolve(value)
157
+ end
158
+
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 }
167
+ end
168
+
169
+ #
170
+ # Compares this attributes input to other
171
+ #
172
+ # @param other [Object, Attribute] If another Attribute, the input will be compared
173
+ #
174
+ # @return [Boolean]
175
+ #
176
+ def ==(other)
177
+ other =
178
+ if other.is_a?(Attribute)
179
+ other.input
180
+ else
181
+ other
182
+ end
183
+
184
+ input == other
185
+ end
186
+
187
+ #
188
+ # Used to bind variables to self or any sub attributes
189
+ #
190
+ # @param variables [Hash] A hash of variable attributes
191
+ #
192
+ def bind_variables(_variables)
193
+ end
194
+
195
+ protected
196
+
197
+ def __resolve(value)
198
+ case value
199
+ when ArrayLike
200
+ value.map(&:resolve)
201
+ when HashLike
202
+ value.transform_values(&:resolve)
203
+ else
204
+ value
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class CLI
5
+ module Actions
6
+ def self.included(base)
7
+ base.define_method(:actions) do
8
+ @actions ||= ActionContext.new
9
+ end
10
+ end
11
+ end
12
+
13
+ class ActionContext < Thor
14
+ include Thor::Actions
15
+
16
+ def initialize(...)
17
+ self.class.source_root(File.expand_path("../../templates", __dir__))
18
+ self.destination_root = SpecForge.root
19
+ self.options = {}
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class CLI
5
+ class Command
6
+ include CLI::Actions
7
+
8
+ class << self
9
+ attr_writer(*%i[
10
+ command_name
11
+ syntax
12
+ description
13
+ summary
14
+ options
15
+ ])
16
+
17
+ #
18
+ # The command's name
19
+ #
20
+ # @param name [String] The name of the command
21
+ #
22
+ def command_name(name)
23
+ self.command_name = name
24
+ end
25
+
26
+ #
27
+ # The command's syntax
28
+ #
29
+ # @param syntax [String]
30
+ #
31
+ def syntax(syntax)
32
+ self.syntax = syntax
33
+ end
34
+
35
+ #
36
+ # The command's description, long form
37
+ #
38
+ # @param description [String]
39
+ #
40
+ def description(description)
41
+ self.description = description
42
+ end
43
+
44
+ #
45
+ # The command's summary, short form
46
+ #
47
+ # @param summary [String]
48
+ #
49
+ def summary(summary)
50
+ self.summary = summary
51
+ end
52
+
53
+ #
54
+ # Defines an example on how to use the command
55
+ #
56
+ # @param command [String] The example
57
+ # @param description [String] Description of the example
58
+ #
59
+ def example(command, description)
60
+ @examples ||= []
61
+
62
+ # Commander wants it backwards
63
+ @examples << [description, command]
64
+ end
65
+
66
+ #
67
+ # Defines a command flag (-f, --force)
68
+ #
69
+ def option(*args, &block)
70
+ @options ||= []
71
+
72
+ @options << [args, block]
73
+ end
74
+
75
+ #
76
+ # Defines any aliases for this command
77
+ #
78
+ # @param *aliases [Array<String>]
79
+ #
80
+ def aliases(*aliases)
81
+ @aliases ||= []
82
+
83
+ @aliases += aliases
84
+ end
85
+
86
+ #
87
+ # Registers the command with Commander
88
+ #
89
+ # @param context [Commander::Command]
90
+ #
91
+ # @private
92
+ #
93
+ def register(context)
94
+ raise "Missing command name" if @command_name.nil?
95
+
96
+ context.command(@command_name) do |c|
97
+ c.syntax = @syntax
98
+ c.summary = @summary
99
+ c.description = @description
100
+ c.examples = @examples if @examples
101
+
102
+ @options&.each do |opts, block|
103
+ c.option(*opts, &block)
104
+ end
105
+
106
+ c.action { |args, opts| new(args, opts).call }
107
+ end
108
+
109
+ @aliases&.each do |alii|
110
+ context.alias_command(alii, @command_name)
111
+ end
112
+ end
113
+ end
114
+
115
+ attr_reader :arguments, :options
116
+
117
+ #
118
+ # @param arguments [Array] Any positional arguments
119
+ # @param options [Hash] Any flag arguments
120
+ #
121
+ def initialize(arguments, options)
122
+ @arguments = arguments
123
+ @options = options
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class CLI
5
+ class Init < Command
6
+ command_name "init"
7
+ syntax "init"
8
+ summary "Initializes directory structure and configuration files"
9
+
10
+ def call
11
+ base_path = SpecForge.forge
12
+ actions.empty_directory "#{base_path}/factories"
13
+ actions.empty_directory "#{base_path}/specs"
14
+
15
+ actions.template(
16
+ "config.tt",
17
+ SpecForge.root.join(base_path, "config.yml"),
18
+ context: binding
19
+ )
20
+ end
21
+
22
+ def default_authorization_value
23
+ <<~STRING.chomp
24
+ Bearer <%= ENV.fetch("API_TOKEN", "") %>
25
+ STRING
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class CLI
5
+ class New < Command
6
+ command_name "new"
7
+ summary "Create a new spec or factory"
8
+
9
+ syntax "new <type> <name>"
10
+
11
+ example "new spec users",
12
+ "Creates a new spec located at 'spec_forge/specs/users.yml'"
13
+
14
+ example "new factory user",
15
+ "Creates a new factory located at 'spec_forge/factories/user.yml'"
16
+
17
+ example "generate spec accounts",
18
+ "Uses the generate alias (shorthand 'g') instead of 'new'"
19
+
20
+ aliases :generate, :g
21
+
22
+ def call
23
+ type = arguments.first.downcase
24
+ name = arguments.second
25
+
26
+ # Cleanup
27
+ name.delete_suffix!(".yml") if name.end_with?(".yml")
28
+ name.delete_suffix!(".yaml") if name.end_with?(".yaml")
29
+
30
+ case type
31
+ when "spec"
32
+ create_new_spec(name)
33
+ when "factory"
34
+ create_new_factory(name)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def create_new_spec(name)
41
+ actions.create_file(
42
+ SpecForge.forge.join("specs", "#{name}.yml"),
43
+ generate_spec(name)
44
+ )
45
+ end
46
+
47
+ def create_new_factory(name)
48
+ actions.create_file(
49
+ SpecForge.forge.join("factories", "#{name}.yml"),
50
+ generate_factory(name)
51
+ )
52
+ end
53
+
54
+ def generate_spec(name)
55
+ plural_name = name.pluralize
56
+ singular_name = name.singularize
57
+
58
+ base_spec = {url: ""}
59
+ base_constraint = {expect: {status: 200}}
60
+
61
+ hash = {
62
+ ##################################################
63
+ "index_#{plural_name}" => base_spec.merge(
64
+ url: "/#{plural_name}",
65
+ expectations: [base_constraint]
66
+ ),
67
+ ##################################################
68
+ "show_#{singular_name}" => base_spec.merge(
69
+ url: "/#{plural_name}/{id}",
70
+ expectations: [
71
+ base_constraint.merge(expect: {status: 404}),
72
+ base_constraint.deep_merge(
73
+ query: {id: 1},
74
+ expect: {
75
+ json: {
76
+ name: "kind_of.string",
77
+ email: /\w+@example\.com/i
78
+ }
79
+ }
80
+ )
81
+ ]
82
+ ),
83
+ ##################################################
84
+ "create_#{singular_name}" => base_spec.merge(
85
+ url: "/#{plural_name}",
86
+ method: "post",
87
+ expectations: [
88
+ base_constraint.merge(expect: {status: 400}),
89
+ base_constraint.deep_merge(
90
+ variables: {
91
+ name: "faker.name.name",
92
+ role: "user"
93
+ },
94
+ body: {name: "variables.name"},
95
+ expect: {
96
+ json: {name: "variables.name", role: "variables.role"}
97
+ }
98
+ )
99
+ ]
100
+ ),
101
+ ##################################################
102
+ "update_#{singular_name}" => base_spec.merge(
103
+ url: "/#{plural_name}/{id}",
104
+ method: "patch",
105
+ query: {id: 1},
106
+ variables: {
107
+ number: {
108
+ "faker.number.between" => {from: 100_000, to: 999_999}
109
+ }
110
+ },
111
+ expectations: [
112
+ base_constraint.deep_merge(
113
+ body: {number: "variables.number"},
114
+ expect: {
115
+ json: {name: "kind_of.string", number: "kind_of.integer"}
116
+ }
117
+ )
118
+ ]
119
+ ),
120
+ ##################################################
121
+ "destroy_#{singular_name}" => base_spec.merge(
122
+ url: "/#{plural_name}/{id}",
123
+ method: "delete",
124
+ query: {id: 1},
125
+ expectations: [
126
+ base_constraint
127
+ ]
128
+ )
129
+ }
130
+
131
+ generate_yaml(hash)
132
+ end
133
+
134
+ def generate_factory(name)
135
+ singular_name = name.singularize
136
+
137
+ hash = {
138
+ singular_name => {
139
+ class: singular_name.titleize,
140
+ attributes: {
141
+ attribute: "value"
142
+ }
143
+ }
144
+ }
145
+
146
+ generate_yaml(hash)
147
+ end
148
+
149
+ def generate_yaml(hash)
150
+ result = hash.deep_stringify_keys.join_map("\n") do |key, value|
151
+ {key => value}.to_yaml
152
+ .sub!("---\n", "")
153
+ .gsub("!ruby/regexp ", "")
154
+ end
155
+
156
+ result.delete!("\"")
157
+ result
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class CLI
5
+ class Run < Command
6
+ command_name "run"
7
+ syntax "run"
8
+ summary "Runs all specs"
9
+
10
+ # option "-n", "--no-docs", "Do not generate OpenAPI documentation on completion"
11
+
12
+ def call
13
+ SpecForge.run
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli/actions"
4
+ require_relative "cli/command"
5
+ require_relative "cli/init"
6
+ require_relative "cli/new"
7
+ require_relative "cli/run"
8
+
9
+ module SpecForge
10
+ class CLI
11
+ include Commander::Methods
12
+
13
+ COMMANDS = [Init, New, Run]
14
+
15
+ #
16
+ # Runs the CLI
17
+ #
18
+ # @private
19
+ #
20
+ def run
21
+ program :name, "SpecForge"
22
+ program :version, SpecForge::VERSION
23
+ program :description, "Write expressive API tests in YAML with the power of RSpec matchers"
24
+
25
+ register_commands
26
+
27
+ default_command :run
28
+
29
+ run!
30
+ end
31
+
32
+ #
33
+ # Registers the commands with Commander
34
+ #
35
+ # @private
36
+ #
37
+ def register_commands
38
+ COMMANDS.each do |command_class|
39
+ command_class.register(self)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ #
5
+ # Represents `config.yml`
6
+ #
7
+ class Config < Struct.new(:base_url, :authorization, :factories, :environment)
8
+ #
9
+ # authorization: {}
10
+ #
11
+ class Authorization < Struct.new(:header, :value)
12
+ attr_predicate :header, :value
13
+ end
14
+
15
+ #
16
+ # factories: {}
17
+ #
18
+ class Factories < Struct.new(:paths, :auto_discover)
19
+ attr_predicate :paths, :auto_discover
20
+ end
21
+
22
+ #
23
+ # environment: {}
24
+ #
25
+ class Environment < Struct.new(:use, :preload, :models_path)
26
+ attr_predicate :use, :preload, :models_path
27
+
28
+ def initialize(string_or_hash)
29
+ use, preload, models_path = "", "", ""
30
+
31
+ # "rails" or other preset
32
+ if string_or_hash.is_a?(String)
33
+ use = string_or_hash
34
+ else
35
+ string_or_hash => {use:, preload:, models_path:}
36
+ end
37
+
38
+ super(use:, preload:, models_path:)
39
+ end
40
+ end
41
+
42
+ ############################################################################
43
+
44
+ #
45
+ # Creates a config with the user's config overlaid on the default
46
+ #
47
+ def initialize
48
+ config = Normalizer.default_config.deep_merge(load_from_file)
49
+ normalized = Normalizer.normalize_config!(config)
50
+
51
+ super(
52
+ base_url: normalized.delete(:base_url),
53
+ authorization: transform_authorization(normalized),
54
+ factories: transform_factories(normalized),
55
+ environment: transform_environment(normalized)
56
+ )
57
+ end
58
+
59
+ private
60
+
61
+ def load_from_file
62
+ path = SpecForge.forge.join("config.yml")
63
+ return unless File.exist?(path)
64
+
65
+ erb = ERB.new(File.read(path)).result
66
+ YAML.safe_load(erb, aliases: true, symbolize_names: true)
67
+ end
68
+
69
+ def transform_authorization(hash)
70
+ # The intention for authorization hash is to support different authorization schemes
71
+ # authorization: {default: {}, admin: {}, dev: {}}
72
+ # But I won't know exactly what will be defined - `to_struct` will handle that.
73
+ hash[:authorization].transform_values { |v| Authorization.new(**v) }.to_struct
74
+ end
75
+
76
+ def transform_factories(hash)
77
+ Factories.new(**hash[:factories])
78
+ end
79
+
80
+ def transform_environment(hash)
81
+ Environment.new(hash[:environment])
82
+ end
83
+ end
84
+ end