liminal-openapi 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d0b5af3f5911eaa775a9306325d79b09874d931c8b9c85f2d68df36a44344cd0
4
+ data.tar.gz: 9886a15c8ba31ab5b19cab9f6d389188594cfec9d7ba16a89554cc56ab0e64a2
5
+ SHA512:
6
+ metadata.gz: ce77ab37220564aa92fb2604902095de601e7fd2434ccfd8853cbec5eb181f0d2e9a58d061db3d6b3c8692aa7439e5e2ec141070480285b8d5a568210b771463
7
+ data.tar.gz: 559a90a3f3a6accbb987e7f38631f0e86c6e6688e9c0a12a92dd6cf68c1aa9705dacdca44cedf5f06d8e0216fb680d6a43d79c3993bd1d6e00187b35d4f3183a
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Liminal contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # Liminal OpenAPI
2
+
3
+ `liminal-openapi` compiles Liminal schema sources and dry-validation contracts
4
+ into OpenAPI 3.0 Schema Objects. It also registers reusable components and
5
+ validates generated OpenAPI documents without depending on Rswag.
6
+
7
+ ## Compile schemas and contracts
8
+
9
+ ```ruby
10
+ require "liminal/openapi"
11
+
12
+ schema = Liminal::Openapi.contract(
13
+ Contracts::Users::Create,
14
+ exclude: [[:current_user]],
15
+ examples: {[:name] => "Mario"}
16
+ )
17
+ ```
18
+
19
+ Both schema and contract compilation accept Liminal compiler options such as
20
+ `exclude`, `overrides`, and `predicates`. Contract names are used as diagnostic
21
+ context unless an explicit `source_name` is provided.
22
+
23
+ Passing a Hash treats it as a precompiled Schema Object. It is validated through
24
+ `Liminal::Schema.normalize`, copied, and deeply frozen; compiler-only options are
25
+ rejected because there is no dry-schema source to compile.
26
+
27
+ `examples` and `overrides` are path-keyed Hashes. Paths are canonicalized before
28
+ they are merged, duplicate aliases fail fast, and an explicit override takes
29
+ precedence over an example at the same path.
30
+
31
+ ## Register components
32
+
33
+ ```ruby
34
+ Liminal::Openapi.enrich(document) do |components|
35
+ components.register_contract(:create_user, Contracts::Users::Create)
36
+ end
37
+ ```
38
+
39
+ Registration validates the OpenAPI version and component container shape needed
40
+ for the mutation. It rejects invalid names, ambiguous string/symbol registries,
41
+ and component collisions, and returns immutable references. Use verification
42
+ for full-document validation.
43
+
44
+ ## Verify generated documents
45
+
46
+ ```ruby
47
+ require "liminal/openapi/verification"
48
+
49
+ Liminal::Openapi::Verification.call(
50
+ checked: "swagger/v1/actions.yaml",
51
+ generated: "v1/actions.yaml"
52
+ ) do |temporary_root|
53
+ generate_document(temporary_root)
54
+ end
55
+ ```
56
+
57
+ Verification validates both documents and compares their structure: object key
58
+ order is ignored, while array order and scalar types remain significant.
59
+ Documents must be bundled; external `$ref` targets are rejected because changes
60
+ outside either compared file would otherwise escape drift detection.
61
+
62
+ To ignore generated examples without masking unrelated changes:
63
+
64
+ ```ruby
65
+ Liminal::Openapi::Verification.call(
66
+ checked: "swagger/v1/actions.yaml",
67
+ generated: "v1/actions.yaml",
68
+ ignore: :operation_examples
69
+ ) { |temporary_root| generate_document(temporary_root) }
70
+ ```
71
+
72
+ This policy removes only inline OpenAPI-defined example fields beneath operations.
73
+ Property names, component names, defaults, extensions, and arbitrary
74
+ payload fields named `example` or `examples` remain part of the comparison.
75
+
76
+ ## Development
77
+
78
+ Run `bundle exec rake` for randomized RSpec and RuboCop checks,
79
+ `bundle exec rake coverage` for enforced line and branch coverage, and
80
+ `bundle exec rake build` to build the gem. The suite includes a real
81
+ `Dry::Validation::Contract` integration.
82
+
83
+ Made with ❤️ by [@olistik](https://olisti.co)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Openapi
5
+ module ComponentReference
6
+ module_function
7
+
8
+ def call(name)
9
+ Liminal::Components.reference(name)
10
+ rescue Liminal::InvalidComponentNameError => e
11
+ raise InvalidComponentName, e.message
12
+ end
13
+
14
+ def display_name(name)
15
+ Liminal::Components.normalize_name(name)
16
+ rescue Liminal::InvalidComponentNameError => e
17
+ raise InvalidComponentName, e.message
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Openapi
5
+ class ComponentsBuilder
6
+ def initialize(document)
7
+ @document = document
8
+ end
9
+
10
+ def register(name, schema, **compile_options)
11
+ Liminal::Openapi.register(
12
+ document: @document,
13
+ name: name,
14
+ schema: schema,
15
+ **compile_options
16
+ )
17
+ end
18
+
19
+ def register_contract(name, contract, **compile_options)
20
+ Liminal::Openapi.register_contract(
21
+ document: @document,
22
+ name: name,
23
+ contract: contract,
24
+ **compile_options
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Openapi
5
+ module ContractResolver
6
+ module_function
7
+
8
+ def call(contract, **compile_options)
9
+ validate_contract!(contract)
10
+ source = contract.schema
11
+ options = options_for(source, contract, compile_options)
12
+ Liminal::Openapi.schema(source, **options)
13
+ rescue InvalidPrecompiledSchema => e
14
+ raise InvalidPrecompiledSchema, contextual_message(contract, e)
15
+ end
16
+
17
+ def validate_contract!(contract)
18
+ return if contract.respond_to?(:schema)
19
+
20
+ raise InvalidContractSource,
21
+ "Contract source must respond to schema; received #{contract.class}."
22
+ end
23
+ private_class_method :validate_contract!
24
+
25
+ def options_for(source, contract, options)
26
+ return options unless source.respond_to?(:to_ast)
27
+ return options if options.key?(:source_name)
28
+ return options unless contract.respond_to?(:name)
29
+
30
+ options.merge(source_name: contract.name)
31
+ end
32
+ private_class_method :options_for
33
+
34
+ def contextual_message(contract, error)
35
+ name = contract.name if contract.respond_to?(:name)
36
+ label = name.to_s unless name.nil?
37
+ label && !label.empty? ? "#{label}: #{error.message}" : error.message
38
+ end
39
+ private_class_method :contextual_message
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Openapi
5
+ module DocumentValidator
6
+ OPENAPI_30_PATTERN = /\A3\.0\.(?:0|[1-9]\d*)\z/
7
+ VERSION_PATTERN = /\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\z/
8
+ MISSING = Object.new.freeze
9
+ private_constant :OPENAPI_30_PATTERN, :VERSION_PATTERN, :MISSING
10
+
11
+ module_function
12
+
13
+ def call(document, component_name:)
14
+ context = "Cannot register component #{component_name.inspect}"
15
+ unless document.is_a?(Hash)
16
+ raise InvalidDocument, "#{context}: document is #{typed_value(document)}, expected a Hash."
17
+ end
18
+
19
+ validate_version!(document, context)
20
+ validate_component_maps!(document, context)
21
+ document
22
+ end
23
+
24
+ def validate_version!(document, context)
25
+ version = single_logical_value(document, "openapi", context)
26
+ reject_swagger!(document, context)
27
+ return if version.equal?(MISSING)
28
+
29
+ validate_openapi_version!(version, context)
30
+ end
31
+ private_class_method :validate_version!
32
+
33
+ def reject_swagger!(document, context)
34
+ return unless document.key?("swagger") || document.key?(:swagger)
35
+
36
+ raise UnsupportedOpenapiVersion,
37
+ "#{context}: Swagger 2.0 documents are unsupported; only OpenAPI 3.0.x is supported."
38
+ end
39
+ private_class_method :reject_swagger!
40
+
41
+ def validate_openapi_version!(version, context)
42
+ return if version.is_a?(String) && OPENAPI_30_PATTERN.match?(version)
43
+
44
+ detail = if version.is_a?(String) && VERSION_PATTERN.match?(version)
45
+ "OpenAPI version #{version.inspect} is unsupported"
46
+ else
47
+ "OpenAPI version #{version.inspect} is malformed"
48
+ end
49
+ raise UnsupportedOpenapiVersion, "#{context}: #{detail}; only OpenAPI 3.0.x is supported."
50
+ end
51
+ private_class_method :validate_openapi_version!
52
+
53
+ def validate_component_maps!(document, context)
54
+ components = single_logical_value(document, "components", context)
55
+ return if components.equal?(MISSING)
56
+
57
+ unless components.is_a?(Hash)
58
+ raise InvalidDocument,
59
+ "#{context}: components is #{typed_value(components)}, expected a Hash."
60
+ end
61
+
62
+ schemas = single_logical_value(components, "schemas", context)
63
+ return if schemas.equal?(MISSING)
64
+ return if schemas.is_a?(Hash)
65
+
66
+ raise InvalidDocument,
67
+ "#{context}: components.schemas is #{typed_value(schemas)}, expected a Hash."
68
+ end
69
+ private_class_method :validate_component_maps!
70
+
71
+ def single_logical_value(container, name, context)
72
+ keys = [name, name.to_sym].select { |key| container.key?(key) }
73
+ if keys.length > 1
74
+ raise InvalidDocument,
75
+ "#{context}: #{name} cannot be present under both String and Symbol keys."
76
+ end
77
+
78
+ keys.empty? ? MISSING : container.fetch(keys.first)
79
+ end
80
+ private_class_method :single_logical_value
81
+
82
+ def typed_value(value)
83
+ article = value.class.to_s.match?(/\A[AEIOU]/i) ? "an" : "a"
84
+ "#{article} #{value.class}"
85
+ end
86
+ private_class_method :typed_value
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Openapi
5
+ class Error < Liminal::Error; end
6
+
7
+ class InvalidDocument < Error; end
8
+ class UnsupportedOpenapiVersion < Error; end
9
+ class InvalidComponentName < Error; end
10
+ class InvalidSchemaSource < Error; end
11
+ class InvalidPrecompiledSchema < InvalidSchemaSource; end
12
+ class InvalidContractSource < Error; end
13
+ class InvalidSchemaOptions < Error; end
14
+ class InvalidOverrideFragment < InvalidSchemaOptions; end
15
+ class CompileOptionsForPrecompiledSchema < Error; end
16
+ end
17
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Openapi
5
+ module ExampleOverrides
6
+ module_function
7
+
8
+ def call(examples:, overrides:)
9
+ validate_hash!(examples, :examples)
10
+ validate_hash!(overrides, :overrides)
11
+
12
+ normalized_examples = normalize_paths(examples, :examples)
13
+ normalized_overrides = normalize_paths(overrides, :overrides) do |path, fragment|
14
+ unless fragment.is_a?(Hash)
15
+ raise InvalidOverrideFragment,
16
+ "Override at #{path.inspect} must be a Hash; received #{fragment.class}."
17
+ end
18
+ end
19
+
20
+ merge(normalized_examples, normalized_overrides)
21
+ end
22
+
23
+ def merge(examples, overrides)
24
+ result = examples.to_h do |path, value|
25
+ [path, {"example" => value}]
26
+ end
27
+ overrides.each do |path, fragment|
28
+ example = result.fetch(path, {})
29
+ example = {} if fragment.key?("example") || fragment.key?(:example)
30
+ result[path] = example.merge(fragment)
31
+ end
32
+ result
33
+ end
34
+ private_class_method :merge
35
+
36
+ def validate_hash!(value, name)
37
+ return if value.is_a?(Hash)
38
+
39
+ raise InvalidSchemaOptions,
40
+ "#{name} must be a Hash of paths to values; received #{value.class}."
41
+ end
42
+ private_class_method :validate_hash!
43
+
44
+ def normalize_paths(values, name)
45
+ values.each_with_object({}) do |(path, value), normalized|
46
+ yield(path, value) if block_given?
47
+ canonical = Liminal::Path.normalize(path)
48
+ if normalized.key?(canonical)
49
+ raise InvalidSchemaOptions,
50
+ "#{name} contains more than one entry for path #{Liminal::Path.display(canonical)}."
51
+ end
52
+
53
+ normalized[canonical] = value
54
+ rescue ArgumentError => e
55
+ raise InvalidSchemaOptions, "Invalid #{name} path #{path.inspect}: #{e.message}."
56
+ end
57
+ end
58
+ private_class_method :normalize_paths
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Openapi
5
+ module SchemaResolver
6
+ module_function
7
+
8
+ def call(source, **compile_options)
9
+ schema = if source.is_a?(Hash)
10
+ resolve_precompiled(source, compile_options)
11
+ elsif source.respond_to?(:to_ast)
12
+ Liminal.compile(source, **compile_options)
13
+ else
14
+ raise InvalidSchemaSource,
15
+ "Schema source must be a Hash or a dry-schema source responding to to_ast; " \
16
+ "received #{source.class}."
17
+ end
18
+ deep_freeze(schema)
19
+ end
20
+
21
+ def resolve_precompiled(source, compile_options)
22
+ unless compile_options.empty?
23
+ options = compile_options.keys.map(&:inspect).sort.join(", ")
24
+ raise CompileOptionsForPrecompiledSchema,
25
+ "Compiler options are not valid for a precompiled OpenAPI schema Hash: #{options}."
26
+ end
27
+
28
+ Liminal::Schema.normalize(source)
29
+ rescue Liminal::InvalidSchemaError => e
30
+ raise InvalidPrecompiledSchema, e.message
31
+ end
32
+ private_class_method :resolve_precompiled
33
+
34
+ def deep_freeze(value)
35
+ case value
36
+ when Hash
37
+ value.each do |key, item|
38
+ deep_freeze(key)
39
+ deep_freeze(item)
40
+ end
41
+ when Array
42
+ value.each { |item| deep_freeze(item) }
43
+ end
44
+ value.freeze
45
+ end
46
+ private_class_method :deep_freeze
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Openapi
5
+ module Verification
6
+ module Document
7
+ module_function
8
+
9
+ # rubocop:disable Naming/PredicateMethod
10
+ def validate!(path)
11
+ normalized_path, document = parse(path)
12
+ validate_document!(normalized_path, document)
13
+ true
14
+ end
15
+
16
+ def validate_bundled!(path)
17
+ normalized_path, document = parse(path)
18
+ sources = document.reference_sources.to_a.map(&:relative_to_root).sort
19
+ raise ExternalReferenceError.new(normalized_path, sources) unless sources.empty?
20
+
21
+ validate_document!(normalized_path, document)
22
+ true
23
+ end
24
+ # rubocop:enable Naming/PredicateMethod
25
+
26
+ def load(path)
27
+ document = Psych.safe_load(
28
+ File.read(path.to_s),
29
+ permitted_classes: [Date, Time],
30
+ aliases: true
31
+ )
32
+ return document if document.is_a?(Hash)
33
+
34
+ raise InvalidDocumentError.new(path.to_s, "document root must be an object")
35
+ rescue InvalidDocumentError
36
+ raise
37
+ rescue StandardError => e
38
+ raise InvalidDocumentError.new(path.to_s, e.message)
39
+ end
40
+
41
+ def parse(path)
42
+ normalized_path = path.to_s
43
+ raise MissingDocumentError, normalized_path unless File.file?(normalized_path)
44
+
45
+ [normalized_path, Openapi3Parser.load_file(normalized_path)]
46
+ rescue MissingDocumentError, InvalidDocumentError
47
+ raise
48
+ rescue StandardError => e
49
+ raise InvalidDocumentError.new(normalized_path, e.message)
50
+ end
51
+ private_class_method :parse
52
+
53
+ def validate_document!(path, document)
54
+ return if document.valid?
55
+
56
+ raise InvalidDocumentError.new(path, document.errors)
57
+ rescue InvalidDocumentError
58
+ raise
59
+ rescue StandardError => e
60
+ raise InvalidDocumentError.new(path, e.message)
61
+ end
62
+ private_class_method :validate_document!
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Openapi
5
+ module Verification
6
+ class Error < Liminal::Openapi::Error; end
7
+ class InvalidConfigurationError < Error; end
8
+
9
+ class ExternalReferenceError < InvalidConfigurationError
10
+ attr_reader :path, :sources
11
+
12
+ def initialize(path, sources)
13
+ @path = path
14
+ @sources = sources.freeze
15
+ super("OpenAPI verification requires bundled documents without external $ref targets; " \
16
+ "#{path} references #{sources.join(', ')}")
17
+ end
18
+ end
19
+
20
+ class MissingDocumentError < Error
21
+ attr_reader :path
22
+
23
+ def initialize(path)
24
+ @path = path
25
+ super("OpenAPI document does not exist: #{path}")
26
+ end
27
+ end
28
+
29
+ class InvalidDocumentError < Error
30
+ attr_reader :path, :errors
31
+
32
+ def initialize(path, errors)
33
+ @path = path
34
+ @errors = errors
35
+ super("OpenAPI validation failed for #{path}:\n#{format_errors(errors)}")
36
+ end
37
+
38
+ private
39
+
40
+ def format_errors(errors)
41
+ return errors unless errors.respond_to?(:to_h)
42
+
43
+ errors.to_h.map do |location, messages|
44
+ "#{location}: #{messages.join(', ')}"
45
+ end.join("\n")
46
+ end
47
+ end
48
+
49
+ class DocumentDriftError < Error
50
+ attr_reader :difference
51
+
52
+ def initialize(difference)
53
+ @difference = difference
54
+ super("OpenAPI documents differ at #{difference.pointer}: " \
55
+ "checked=#{difference.checked_label}, generated=#{difference.generated_label}")
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Openapi
5
+ module Verification
6
+ module Normalizer
7
+ # Removes only fields that OpenAPI defines as examples beneath
8
+ # operations. Arbitrary payload and map keys named "example" remain
9
+ # significant during verification.
10
+ module OperationExampleStripper
11
+ HTTP_METHODS = %w[delete get head options patch post put trace].freeze
12
+ private_constant :HTTP_METHODS
13
+
14
+ module_function
15
+
16
+ def call(paths)
17
+ return unless paths.is_a?(Hash)
18
+
19
+ paths.each_value { |path_item| visit_path_item(path_item) }
20
+ end
21
+
22
+ def visit_path_item(path_item)
23
+ return unless path_item.is_a?(Hash)
24
+
25
+ visit_parameters(path_item["parameters"])
26
+ HTTP_METHODS.each { |method| visit_operation(path_item[method]) }
27
+ end
28
+ private_class_method :visit_path_item
29
+
30
+ def visit_operation(operation)
31
+ return unless operation.is_a?(Hash)
32
+
33
+ visit_parameters(operation["parameters"])
34
+ visit_request_body(operation["requestBody"])
35
+ visit_responses(operation["responses"])
36
+ visit_callbacks(operation["callbacks"])
37
+ end
38
+ private_class_method :visit_operation
39
+
40
+ def visit_parameters(parameters)
41
+ return unless parameters.is_a?(Array)
42
+
43
+ parameters.each { |parameter| visit_parameter(parameter) }
44
+ end
45
+ private_class_method :visit_parameters
46
+
47
+ def visit_parameter(parameter)
48
+ return unless parameter.is_a?(Hash)
49
+
50
+ remove_examples(parameter)
51
+ visit_schema(parameter["schema"])
52
+ visit_content(parameter["content"])
53
+ end
54
+ private_class_method :visit_parameter
55
+
56
+ def visit_request_body(request_body)
57
+ visit_content(request_body["content"]) if request_body.is_a?(Hash)
58
+ end
59
+ private_class_method :visit_request_body
60
+
61
+ def visit_responses(responses)
62
+ return unless responses.is_a?(Hash)
63
+
64
+ responses.each_value do |response|
65
+ next unless response.is_a?(Hash)
66
+
67
+ visit_headers(response["headers"])
68
+ visit_content(response["content"])
69
+ end
70
+ end
71
+ private_class_method :visit_responses
72
+
73
+ def visit_headers(headers)
74
+ return unless headers.is_a?(Hash)
75
+
76
+ headers.each_value { |header| visit_parameter(header) }
77
+ end
78
+ private_class_method :visit_headers
79
+
80
+ def visit_content(content)
81
+ return unless content.is_a?(Hash)
82
+
83
+ content.each_value { |media_type| visit_media_type(media_type) }
84
+ end
85
+ private_class_method :visit_content
86
+
87
+ def visit_media_type(media_type)
88
+ return unless media_type.is_a?(Hash)
89
+
90
+ remove_examples(media_type)
91
+ visit_schema(media_type["schema"])
92
+ visit_encodings(media_type["encoding"])
93
+ end
94
+ private_class_method :visit_media_type
95
+
96
+ def visit_encodings(encodings)
97
+ return unless encodings.is_a?(Hash)
98
+
99
+ encodings.each_value do |encoding|
100
+ visit_headers(encoding["headers"]) if encoding.is_a?(Hash)
101
+ end
102
+ end
103
+ private_class_method :visit_encodings
104
+
105
+ def visit_callbacks(callbacks)
106
+ return unless callbacks.is_a?(Hash)
107
+
108
+ callbacks.each_value do |callback|
109
+ next unless callback.is_a?(Hash)
110
+
111
+ callback.each_value { |path_item| visit_path_item(path_item) }
112
+ end
113
+ end
114
+ private_class_method :visit_callbacks
115
+
116
+ def visit_schema(schema)
117
+ return unless schema.is_a?(Hash)
118
+
119
+ schema.delete("example")
120
+ visit_schema_map(schema["properties"])
121
+ visit_schema(schema["items"])
122
+ visit_schema(schema["not"])
123
+ visit_schema(schema["additionalProperties"])
124
+ %w[allOf anyOf oneOf].each do |keyword|
125
+ children = schema[keyword]
126
+ children.each { |child| visit_schema(child) } if children.is_a?(Array)
127
+ end
128
+ end
129
+ private_class_method :visit_schema
130
+
131
+ def visit_schema_map(schemas)
132
+ schemas.each_value { |schema| visit_schema(schema) } if schemas.is_a?(Hash)
133
+ end
134
+ private_class_method :visit_schema_map
135
+
136
+ def remove_examples(value)
137
+ value.delete("example")
138
+ value.delete("examples")
139
+ end
140
+ private_class_method :remove_examples
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "liminal/openapi/verification/normalizer/operation_example_stripper"
4
+
5
+ module Liminal
6
+ module Openapi
7
+ module Verification
8
+ module Normalizer
9
+ OPERATION_EXAMPLES = "operation_examples"
10
+ private_constant :OPERATION_EXAMPLES, :OperationExampleStripper
11
+
12
+ module_function
13
+
14
+ def call(document, ignore:)
15
+ policies = normalize_policies(ignore)
16
+ OperationExampleStripper.call(document.fetch("paths", {})) if policies.include?(OPERATION_EXAMPLES)
17
+ document
18
+ end
19
+
20
+ def normalize_policies(ignore)
21
+ Array(ignore).map do |entry|
22
+ unless entry.is_a?(String) || entry.is_a?(Symbol)
23
+ raise InvalidConfigurationError,
24
+ "Verification ignore policies must be a String or Symbol; received #{entry.class}."
25
+ end
26
+
27
+ policy = entry.to_s
28
+ next policy if policy == OPERATION_EXAMPLES
29
+
30
+ raise InvalidConfigurationError, "Unsupported verification ignore policy: #{entry.inspect}"
31
+ end
32
+ end
33
+ private_class_method :normalize_policies
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Openapi
5
+ module Verification
6
+ class Runner
7
+ def initialize(checked:, generated:, ignore:)
8
+ @checked = checked.to_s
9
+ @generated = generated.to_s
10
+ @ignore = ignore
11
+ end
12
+
13
+ def call(&generator)
14
+ validate_configuration!(generator)
15
+ Document.validate_bundled!(checked)
16
+
17
+ Dir.mktmpdir("liminal-openapi-verification") do |temporary_root|
18
+ generator.call(temporary_root)
19
+ generated_path = File.join(temporary_root, generated)
20
+ Document.validate_bundled!(generated_path)
21
+ compare!(generated_path)
22
+ end
23
+
24
+ true
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :checked, :generated, :ignore
30
+
31
+ def validate_configuration!(generator)
32
+ raise InvalidConfigurationError, "A generation block is required" unless generator
33
+
34
+ path = Pathname.new(generated)
35
+ if generated.empty? || path.absolute? || path.each_filename.include?("..")
36
+ raise InvalidConfigurationError,
37
+ "Generated document path must be relative and remain below the temporary root: #{generated.inspect}"
38
+ end
39
+
40
+ Normalizer.call({}, ignore: ignore)
41
+ end
42
+
43
+ def compare!(generated_path)
44
+ checked_document = Normalizer.call(Document.load(checked), ignore: ignore)
45
+ generated_document = Normalizer.call(Document.load(generated_path), ignore: ignore)
46
+ difference = StructuralDifference.between(checked_document, generated_document)
47
+ raise DocumentDriftError, difference if difference
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Openapi
5
+ module Verification
6
+ class StructuralDifference
7
+ MISSING = Object.new.freeze
8
+ private_constant :MISSING
9
+
10
+ attr_reader :path, :checked, :generated
11
+
12
+ def self.between(checked, generated, path = [])
13
+ return if checked.eql?(generated)
14
+ return between_hashes(checked, generated, path) if checked.is_a?(Hash) && generated.is_a?(Hash)
15
+ return between_arrays(checked, generated, path) if checked.is_a?(Array) && generated.is_a?(Array)
16
+
17
+ new(path, checked, generated)
18
+ end
19
+
20
+ def self.between_hashes(checked, generated, path)
21
+ (checked.keys | generated.keys).sort_by(&:to_s).each do |key|
22
+ difference = between(
23
+ checked.fetch(key, MISSING),
24
+ generated.fetch(key, MISSING),
25
+ path + [key]
26
+ )
27
+ return difference if difference
28
+ end
29
+ nil
30
+ end
31
+
32
+ def self.between_arrays(checked, generated, path)
33
+ [checked.length, generated.length].max.times do |index|
34
+ difference = between(
35
+ checked.fetch(index, MISSING),
36
+ generated.fetch(index, MISSING),
37
+ path + [index]
38
+ )
39
+ return difference if difference
40
+ end
41
+ nil
42
+ end
43
+ private_class_method :between_hashes, :between_arrays
44
+
45
+ def initialize(path, checked, generated)
46
+ @path = path.freeze
47
+ @checked = checked
48
+ @generated = generated
49
+ end
50
+
51
+ def pointer
52
+ return "#" if path.empty?
53
+
54
+ "#/#{path.map { |part| escape_pointer(part) }.join('/')}"
55
+ end
56
+
57
+ def checked_label
58
+ display(checked)
59
+ end
60
+
61
+ def generated_label
62
+ display(generated)
63
+ end
64
+
65
+ private
66
+
67
+ def escape_pointer(part)
68
+ part.to_s.gsub("~", "~0").gsub("/", "~1")
69
+ end
70
+
71
+ def display(value)
72
+ return "<missing>" if value.equal?(MISSING)
73
+
74
+ text = value.inspect
75
+ text.length > 200 ? "#{text[0, 197]}..." : text
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "pathname"
5
+ require "psych"
6
+ require "tmpdir"
7
+ require "openapi3_parser"
8
+ require "liminal/openapi"
9
+ require "liminal/openapi/verification/error"
10
+ require "liminal/openapi/verification/document"
11
+ require "liminal/openapi/verification/normalizer"
12
+ require "liminal/openapi/verification/structural_difference"
13
+ require "liminal/openapi/verification/runner"
14
+
15
+ module Liminal
16
+ module Openapi
17
+ module Verification
18
+ module_function
19
+
20
+ def call(checked:, generated:, ignore: [], &generator)
21
+ Runner.new(checked: checked, generated: generated, ignore: ignore).call(&generator)
22
+ end
23
+
24
+ def validate!(path)
25
+ Document.validate!(path)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Openapi
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "liminal"
4
+ require "liminal/openapi/version"
5
+ require "liminal/openapi/error"
6
+ require "liminal/openapi/example_overrides"
7
+ require "liminal/openapi/schema_resolver"
8
+ require "liminal/openapi/contract_resolver"
9
+ require "liminal/openapi/component_reference"
10
+ require "liminal/openapi/document_validator"
11
+ require "liminal/openapi/components_builder"
12
+
13
+ module Liminal
14
+ module Openapi
15
+ module_function
16
+
17
+ def schema(source, examples: nil, **compile_options)
18
+ if !examples.nil? || compile_options.key?(:overrides)
19
+ overrides = compile_options.key?(:overrides) ? compile_options.delete(:overrides) : {}
20
+ compile_options[:overrides] = ExampleOverrides.call(
21
+ examples: examples.nil? ? {} : examples,
22
+ overrides: overrides
23
+ )
24
+ end
25
+ SchemaResolver.call(source, **compile_options)
26
+ end
27
+
28
+ def contract(contract, **compile_options)
29
+ ContractResolver.call(contract, **compile_options)
30
+ end
31
+
32
+ def ref(name)
33
+ ComponentReference.call(name)
34
+ end
35
+
36
+ def register(document:, name:, schema:, **compile_options)
37
+ component_name = ComponentReference.display_name(name)
38
+ DocumentValidator.call(document, component_name: component_name)
39
+ resolved_schema = self.schema(schema, **compile_options)
40
+ register_resolved(document, component_name, resolved_schema)
41
+ end
42
+
43
+ def register_contract(document:, name:, contract:, **compile_options)
44
+ component_name = ComponentReference.display_name(name)
45
+ DocumentValidator.call(document, component_name: component_name)
46
+ resolved_schema = self.contract(contract, **compile_options)
47
+ register_resolved(document, component_name, resolved_schema)
48
+ end
49
+
50
+ def enrich(document)
51
+ yield ComponentsBuilder.new(document)
52
+ document
53
+ end
54
+
55
+ def register_resolved(document, component_name, schema)
56
+ Liminal::Components.register(
57
+ document: document,
58
+ name: component_name,
59
+ schema: schema
60
+ )
61
+ end
62
+ private_class_method :register_resolved
63
+ end
64
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "liminal/openapi"
metadata ADDED
@@ -0,0 +1,172 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: liminal-openapi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Maurizio De Magnis
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: liminal
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '0.2'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '0.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '0.2'
32
+ - !ruby/object:Gem::Dependency
33
+ name: openapi3_parser
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: 0.10.1
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: 0.10.1
46
+ - !ruby/object:Gem::Dependency
47
+ name: dry-validation
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '1.10'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '1.10'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rake
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '13.0'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '13.0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: rspec
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '3.13'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '3.13'
88
+ - !ruby/object:Gem::Dependency
89
+ name: rubocop
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '1.64'
95
+ - - "<"
96
+ - !ruby/object:Gem::Version
97
+ version: '2'
98
+ type: :development
99
+ prerelease: false
100
+ version_requirements: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '1.64'
105
+ - - "<"
106
+ - !ruby/object:Gem::Version
107
+ version: '2'
108
+ - !ruby/object:Gem::Dependency
109
+ name: simplecov
110
+ requirement: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '0.22'
115
+ type: :development
116
+ prerelease: false
117
+ version_requirements: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - "~>"
120
+ - !ruby/object:Gem::Version
121
+ version: '0.22'
122
+ description: |
123
+ liminal-openapi compiles dry-schema and dry-validation contracts into
124
+ OpenAPI 3.0 schemas, manages schema components, and verifies generated
125
+ OpenAPI documents without depending on a request-spec framework.
126
+ email: root@olisti.co
127
+ executables: []
128
+ extensions: []
129
+ extra_rdoc_files: []
130
+ files:
131
+ - LICENSE.txt
132
+ - README.md
133
+ - lib/liminal-openapi.rb
134
+ - lib/liminal/openapi.rb
135
+ - lib/liminal/openapi/component_reference.rb
136
+ - lib/liminal/openapi/components_builder.rb
137
+ - lib/liminal/openapi/contract_resolver.rb
138
+ - lib/liminal/openapi/document_validator.rb
139
+ - lib/liminal/openapi/error.rb
140
+ - lib/liminal/openapi/example_overrides.rb
141
+ - lib/liminal/openapi/schema_resolver.rb
142
+ - lib/liminal/openapi/verification.rb
143
+ - lib/liminal/openapi/verification/document.rb
144
+ - lib/liminal/openapi/verification/error.rb
145
+ - lib/liminal/openapi/verification/normalizer.rb
146
+ - lib/liminal/openapi/verification/normalizer/operation_example_stripper.rb
147
+ - lib/liminal/openapi/verification/runner.rb
148
+ - lib/liminal/openapi/verification/structural_difference.rb
149
+ - lib/liminal/openapi/version.rb
150
+ homepage: https://source.olisti.co/olisti.co/liminal-openapi
151
+ licenses:
152
+ - MIT
153
+ metadata:
154
+ rubygems_mfa_required: 'true'
155
+ rdoc_options: []
156
+ require_paths:
157
+ - lib
158
+ required_ruby_version: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - ">="
161
+ - !ruby/object:Gem::Version
162
+ version: '3.2'
163
+ required_rubygems_version: !ruby/object:Gem::Requirement
164
+ requirements:
165
+ - - ">="
166
+ - !ruby/object:Gem::Version
167
+ version: '0'
168
+ requirements: []
169
+ rubygems_version: 4.0.15
170
+ specification_version: 4
171
+ summary: Compile Liminal schemas and verify OpenAPI documents
172
+ test_files: []