spec_forge 0.7.0 → 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 +139 -9
  3. data/README.md +125 -203
  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 +6 -0
  29. data/lib/spec_forge/cli.rb +6 -14
  30. data/lib/spec_forge/configuration.rb +212 -78
  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 +22 -6
  42. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +29 -7
  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 -143
  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 -74
  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 -183
  124. data/lib/spec_forge/runner/callbacks.rb +0 -246
  125. data/lib/spec_forge/runner/debug_proxy.rb +0 -213
  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
@@ -1,65 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SpecForge
4
- module Documentation
5
- module Generators
6
- module OpenAPI
7
- # https://spec.openapis.org/oas/v3.0.4.html
8
- class V3_0 < Base # standard:disable Naming/ClassAndModuleCamelCase
9
- #
10
- # Current OpenAPI 3.0 version supported by this generator
11
- #
12
- # @api private
13
- #
14
- CURRENT_VERSION = "3.0.4"
15
-
16
- #
17
- # Alias for OpenAPI V3.0 classes for cleaner code
18
- #
19
- # @api private
20
- #
21
- OAS = Documentation::OpenAPI::V3_0
22
-
23
- #
24
- # Generates an OpenAPI 3.0 specification from the input document
25
- #
26
- # Creates a complete OpenAPI specification by combining the document's
27
- # endpoint data with configuration files and ensuring compliance with
28
- # OpenAPI 3.0.4 standards.
29
- #
30
- # @return [Hash] Complete OpenAPI 3.0 specification
31
- #
32
- def generate
33
- output = {
34
- openapi: CURRENT_VERSION,
35
- paths:
36
- }
37
-
38
- output.deep_stringify_keys!
39
- output.deep_merge!(config)
40
-
41
- output
42
- end
43
-
44
- #
45
- # Transforms document endpoints into OpenAPI paths structure
46
- #
47
- # Converts the internal endpoint representation into the OpenAPI paths
48
- # format, with each path containing operations organized by HTTP method.
49
- #
50
- # @return [Hash] OpenAPI paths object with operations
51
- #
52
- def paths
53
- paths = input.endpoints.deep_dup
54
-
55
- paths.each do |path, operations|
56
- operations.transform_values! do |document|
57
- OAS::Operation.new(document).to_h
58
- end
59
- end
60
- end
61
- end
62
- end
63
- end
64
- end
65
- end
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "openapi/base"
4
- require_relative "openapi/error_formatter"
5
- require_relative "openapi/v3_0"
6
-
7
- module SpecForge
8
- module Documentation
9
- module Generators
10
- #
11
- # Namespace for OpenAPI generators
12
- #
13
- # Contains version-specific OpenAPI generators and helper methods
14
- # for selecting the appropriate generator.
15
- #
16
- module OpenAPI
17
- #
18
- # Current OpenAPI version used as default
19
- #
20
- # Points to the latest supported OpenAPI version for new specifications.
21
- #
22
- # @api private
23
- #
24
- CURRENT_VERSION = V3_0::CURRENT_VERSION
25
-
26
- #
27
- # Mapping of OpenAPI versions to their generator classes
28
- #
29
- # Used for version selection when generating OpenAPI documentation.
30
- # Keys are SemVersion objects, values are generator classes.
31
- #
32
- # @api private
33
- #
34
- VERSIONS = {
35
- V3_0.to_sem_version => V3_0
36
- }.freeze
37
-
38
- #
39
- # Selects an OpenAPI generator by version
40
- #
41
- # @param version [String] OpenAPI version (e.g., "3.0")
42
- #
43
- # @return [Class] The appropriate generator class
44
- # @raise [ArgumentError] If the version is not supported
45
- #
46
- def self.[](version)
47
- version = SemVersion.from_loose_version(version)
48
- generator = VERSIONS.value_where { |k, _v| k.satisfies?("~> #{version}") }
49
-
50
- if generator.nil?
51
- raise ArgumentError, "Invalid OpenAPI version provided: #{version.to_s.in_quotes}"
52
- end
53
-
54
- generator
55
- end
56
- end
57
- end
58
- end
59
- end
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SpecForge
4
- module Documentation
5
- #
6
- # Documentation rendering functionality
7
- #
8
- # Contains generator classes for transforming SpecForge documents
9
- # into various output formats like OpenAPI specifications.
10
- #
11
- module Generators
12
- end
13
- end
14
- end
15
-
16
- require_relative "generators/base"
17
- require_relative "generators/openapi"
@@ -1,159 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "loader/cache"
4
-
5
- module SpecForge
6
- module Documentation
7
- #
8
- # Extracts API documentation data from SpecForge tests
9
- #
10
- # This class runs all tests and captures successful test contexts
11
- # to extract endpoint information, including request/response data.
12
- #
13
- # @example Extracting documentation data
14
- # endpoints = Loader.extract_from_tests
15
- #
16
- class Loader
17
- #
18
- # Loads a documentation document with optional caching
19
- #
20
- # Extracts endpoint data from SpecForge tests, either from cache (if valid
21
- # and requested) or by running fresh tests. Returns a structured document
22
- # ready for generator consumption.
23
- #
24
- # @param use_cache [Boolean] Whether to use cached data if available
25
- #
26
- # @return [Document] Structured document containing endpoint data
27
- #
28
- def self.load_document(use_cache: false)
29
- cache = Cache.new
30
-
31
- endpoints =
32
- if use_cache && cache.valid?
33
- puts "Loading from cache..."
34
- cache.read
35
- else
36
- puts "Cache invalid - Regenerating..." if use_cache
37
-
38
- endpoints = extract_from_tests
39
- cache.create(endpoints)
40
-
41
- endpoints
42
- end
43
-
44
- Builder.document_from_endpoints(endpoints)
45
- end
46
-
47
- #
48
- # Runs tests and extracts endpoint data
49
- #
50
- # @return [Array<Hash>] Extracted endpoint data from successful tests
51
- #
52
- def self.extract_from_tests
53
- new
54
- .run_tests
55
- .extract_and_normalize_data
56
- end
57
-
58
- #
59
- # Initializes a new loader
60
- #
61
- # Sets up a unique callback and prepares storage for successful test results
62
- #
63
- # @return [Loader] A new loader instance
64
- #
65
- def initialize
66
- @callback_name = "__sf_docs_#{SpecForge.generate_id(self)}"
67
- @successes = []
68
- end
69
-
70
- #
71
- # Runs all tests and captures successful test results
72
- #
73
- # Registers a callback to capture test context and runs all tests
74
- #
75
- # @return [self] Returns self for method chaining
76
- #
77
- def run_tests
78
- @successes.clear
79
-
80
- Callbacks.register(@callback_name) do |context|
81
- next if context.expectation.documentation == false || context.spec.documentation == false
82
-
83
- @successes << context if context.example.execution_result.status == :passed
84
- end
85
-
86
- forges = prepare_forges
87
-
88
- Runner.run(forges, exit_on_finish: false, exit_on_failure: true)
89
-
90
- self
91
- ensure
92
- Callbacks.deregister(@callback_name)
93
- end
94
-
95
- #
96
- # Prepares forge objects for test execution
97
- #
98
- # Adds the documentation callback to each forge
99
- #
100
- # @return [Array<Forge>] Array of prepared forge objects
101
- #
102
- def prepare_forges
103
- forges = Runner.prepare
104
-
105
- forges.each do |forge|
106
- forge.global[:callbacks] << {after_each: @callback_name}
107
- end
108
-
109
- forges
110
- end
111
-
112
- #
113
- # Extracts and normalizes endpoint data from test results
114
- #
115
- # @return [Array<Hash>] Normalized endpoint data
116
- #
117
- def extract_and_normalize_data
118
- @successes.map { |d| extract_endpoint(d) }
119
- end
120
-
121
- private
122
-
123
- def extract_endpoint(context)
124
- request_hash = context.request.to_h
125
- response_hash = context.response.to_hash
126
-
127
- # Only pull the headers that the user explicitly checked for.
128
- # This keeps the extra unrelated headers from being included
129
- response_headers = context.expectation
130
- .constraints
131
- .headers
132
- .keys
133
- .map { |h| h.to_s.downcase }
134
-
135
- response_headers = response_hash[:response_headers].slice(*response_headers)
136
-
137
- {
138
- # Metadata
139
- spec_name: context.spec.name,
140
- expectation_name: context.expectation.name,
141
-
142
- # Request data
143
- base_url: request_hash[:base_url],
144
- url: request_hash[:url],
145
- http_verb: request_hash[:http_verb],
146
- content_type: request_hash[:content_type],
147
- request_body: request_hash[:body],
148
- request_headers: request_hash[:headers],
149
- request_query: request_hash[:query],
150
-
151
- # Response data
152
- response_status: response_hash[:status],
153
- response_body: response_hash[:body],
154
- response_headers:
155
- }
156
- end
157
- end
158
- end
159
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SpecForge
4
- module Documentation
5
- module OpenAPI
6
- #
7
- # Base class for OpenAPI documentation objects
8
- #
9
- # Provides common functionality for OpenAPI specification objects
10
- # like operations, responses, and schemas.
11
- #
12
- class Base
13
- #
14
- # The document object containing structured API data
15
- #
16
- # @return [Object] The document with endpoint information
17
- #
18
- attr_reader :document
19
-
20
- #
21
- # Creates a new OpenAPI base object
22
- #
23
- # @param document [Object] The document object containing API data
24
- #
25
- # @return [Base] A new base instance
26
- #
27
- def initialize(document)
28
- @document = document
29
- end
30
- end
31
- end
32
- end
33
- end
@@ -1,86 +0,0 @@
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
@@ -1,248 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SpecForge
4
- class Normalizer
5
- #
6
- # Manages structure definitions for the Normalizer
7
- #
8
- # Handles loading structure definitions from YAML files, processing references
9
- # between structures, and normalizing structure formats for consistent validation.
10
- #
11
- # @example Loading all structure definitions
12
- # structures = SpecForge::Normalizer::Definition.from_files
13
- #
14
- class Definition
15
- #
16
- # Mapping of structure names to their human-readable labels
17
- #
18
- # @return [Hash<Symbol, String>]
19
- #
20
- LABELS = {
21
- factory_reference: "factory reference",
22
- global_context: "global context"
23
- }.freeze
24
-
25
- #
26
- # Core structure definition used to validate other structures
27
- #
28
- # Defines the valid attributes and types for structure definitions,
29
- # creating a meta-structure that validates other structure definitions.
30
- #
31
- # @return [Hash]
32
- #
33
- STRUCTURE = {
34
- type: {
35
- type: [String, Array, Class],
36
- default: nil,
37
- validator: :present?
38
- },
39
- default: {
40
- type: [String, NilClass, Numeric, Array, Hash, TrueClass, FalseClass],
41
- required: false
42
- },
43
- required: {
44
- type: [TrueClass, FalseClass],
45
- required: false
46
- },
47
- aliases: {
48
- type: Array,
49
- required: false,
50
- structure: {type: String}
51
- },
52
- structure: {
53
- type: Hash,
54
- required: false
55
- },
56
- validator: {
57
- type: String,
58
- required: false
59
- }
60
- }.freeze
61
-
62
- #
63
- # Loads normalizer definitions from YAML files
64
- #
65
- # Reads all YAML files in the normalizers directory, processes shared
66
- # references, and prepares them for use by the Normalizer.
67
- #
68
- # @return [Hash] A hash mapping structure names to their definitions
69
- #
70
- def self.from_files
71
- base_path = Pathname.new(File.expand_path("../normalizers", __dir__))
72
- paths = Dir[base_path.join("**/*.yml")].sort
73
-
74
- normalizers =
75
- paths.each_with_object({}) do |path, hash|
76
- path = Pathname.new(path)
77
-
78
- # Include the directory name in the path to include normalizers in directories
79
- name = path.relative_path_from(base_path).to_s.delete_suffix(".yml").to_sym
80
-
81
- input = YAML.safe_load_file(path, symbolize_names: true)
82
- raise Error, "Normalizer defined at #{path.to_s.in_quotes} is empty" if input.blank?
83
-
84
- hash[name] = new(input, label: LABELS[name] || name.to_s.humanize.downcase)
85
- end
86
-
87
- # Pull the shared structures and prepare it
88
- structures = normalizers.delete(:_shared).normalize
89
-
90
- # Merge in the normalizers to allow referencing other normalizers
91
- structures.merge!(normalizers.transform_values(&:input))
92
-
93
- # Now prepare all of the other definitions with access to references
94
- normalizers.transform_values!(with_key: true) do |definition, name|
95
- structure = definition.normalize(structures)
96
-
97
- {
98
- label: definition.label,
99
- structure:
100
- }
101
- end
102
-
103
- normalizers
104
- end
105
-
106
- ##########################################################################
107
-
108
- attr_reader :input, :label
109
-
110
- def initialize(input, label: "")
111
- @input = input
112
- @label = label
113
- end
114
-
115
- #
116
- # Normalizes a structure definition
117
- #
118
- # Processes references, resolves types, and ensures all attributes
119
- # have a consistent format for validation.
120
- #
121
- # @param shared_structures [Hash] Optional shared structures for resolving references
122
- #
123
- # @return [Hash] The normalized structure definition
124
- #
125
- def normalize(shared_structures = {})
126
- hash = @input.deep_dup
127
-
128
- # First, we'll deeply replace any references
129
- replace_references(hash, shared_structures)
130
-
131
- # Second, normalize the root level keys
132
- hash.transform_values!(with_key: true) do |attribute, name|
133
- next if STRUCTURE.key?(name)
134
-
135
- normalize_attribute(name, attribute)
136
- end
137
-
138
- # Third, normalize the underlying structures
139
- hash.each do |name, attribute|
140
- next unless attribute.is_a?(Hash)
141
-
142
- structure = attribute[:structure]
143
- next if structure.blank?
144
-
145
- attribute[:structure] = normalize_structure(name, attribute)
146
- end
147
-
148
- hash
149
- end
150
-
151
- private
152
-
153
- def replace_references(attributes, shared_structures)
154
- return if shared_structures.blank?
155
-
156
- # The goal is to walk down the hash and recursively replace any references
157
- attributes.each do |attribute_name, attribute|
158
- # Replace the top level reference
159
- replace_with_reference(attribute_name, attribute, shared_structures:)
160
- next unless attribute.is_a?(Hash) && attribute[:structure].present?
161
-
162
- # Allow structures to reference other structures
163
- if attribute.dig(:structure, :reference)
164
- replace_with_reference(
165
- "#{attribute_name}'s structure",
166
- attribute[:structure],
167
- shared_structures:
168
- )
169
- end
170
-
171
- # Recursively replace any structures that have references
172
- if [Array, "array"].include?(attribute[:type])
173
- result = replace_references(attribute.slice(:structure), shared_structures)
174
- attribute.merge!(result)
175
- elsif [Hash, "hash"].include?(attribute[:type])
176
- replace_references(attribute[:structure], shared_structures)
177
- end
178
- end
179
- end
180
-
181
- def replace_with_reference(attribute_name, attribute, shared_structures: {})
182
- return unless attribute.is_a?(Hash) && attribute.key?(:reference)
183
-
184
- reference_name = attribute.delete(:reference)
185
- reference = shared_structures[reference_name.to_sym]
186
-
187
- if reference.nil?
188
- structures_names = shared_structures.keys.map(&:in_quotes).to_or_sentence
189
-
190
- raise Error, "Attribute #{attribute_name.in_quotes}: Invalid reference name. Got #{reference_name&.in_quotes}, expected one of #{structures_names} in #{@label}"
191
- end
192
-
193
- # Allows overwriting data on the reference
194
- attribute.reverse_merge!(reference)
195
- end
196
-
197
- def normalize_attribute(attribute_name, attribute)
198
- case attribute
199
- when String, Array # Array is multiple types
200
- hash = {type: resolve_type(attribute)}
201
-
202
- default = Normalizer.default(structure: STRUCTURE)
203
- hash.merge!(default)
204
- when Hash
205
- hash = Normalizer.raise_errors! do
206
- Normalizer.new(
207
- "#{attribute_name.in_quotes} in #{@label}",
208
- attribute,
209
- structure: STRUCTURE
210
- ).normalize
211
- end
212
-
213
- hash[:type] = resolve_type(attribute[:type])
214
-
215
- if hash[:structure].present?
216
- hash[:structure] = normalize_structure(attribute_name, hash) || {}
217
- end
218
-
219
- hash
220
- else
221
- raise ArgumentError, "Attribute #{attribute_name.in_quotes}: Expected String or Hash, got #{attribute.inspect}"
222
- end
223
- end
224
-
225
- def normalize_structure(name, hash)
226
- if hash[:type] == Array
227
- normalize_attribute(name, hash[:structure])
228
- elsif hash[:type] == Hash
229
- hash[:structure].transform_values(with_key: true) { |v, k| normalize_attribute(k, v) }
230
- end
231
- end
232
-
233
- def resolve_type(type)
234
- if type == "boolean"
235
- [TrueClass, FalseClass]
236
- elsif type.instance_of?(Array)
237
- type.map { |t| resolve_type(t) }
238
- elsif type.is_a?(String)
239
- type.classify.constantize
240
- else
241
- type
242
- end
243
- rescue NameError => e
244
- raise Error, "#{e}. #{type.inspect} is not a valid type found in #{@label}"
245
- end
246
- end
247
- end
248
- end