versponse 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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -6
  3. data/CHANGELOG.md +59 -1
  4. data/CODE_OF_CONDUCT.md +1 -1
  5. data/LICENSE.txt +1 -1
  6. data/README.md +674 -20
  7. data/Rakefile +26 -3
  8. data/lib/versponse/base/class_methods.rb +180 -0
  9. data/lib/versponse/base/dsl.rb +231 -0
  10. data/lib/versponse/base.rb +35 -0
  11. data/lib/versponse/builder.rb +423 -0
  12. data/lib/versponse/cache.rb +47 -0
  13. data/lib/versponse/callable.rb +74 -0
  14. data/lib/versponse/class_policies.rb +15 -0
  15. data/lib/versponse/collapse_if_plan.rb +26 -0
  16. data/lib/versponse/collections.rb +49 -0
  17. data/lib/versponse/components.rb +37 -0
  18. data/lib/versponse/condition.rb +33 -0
  19. data/lib/versponse/configuration.rb +261 -0
  20. data/lib/versponse/const_as_enum.rb +39 -0
  21. data/lib/versponse/context_override/call.rb +27 -0
  22. data/lib/versponse/context_override/merge.rb +23 -0
  23. data/lib/versponse/context_override.rb +25 -0
  24. data/lib/versponse/context_plan.rb +24 -0
  25. data/lib/versponse/deep_copy.rb +27 -0
  26. data/lib/versponse/deep_freeze.rb +20 -0
  27. data/lib/versponse/discriminated_plan.rb +26 -0
  28. data/lib/versponse/error_policy/recovery.rb +68 -0
  29. data/lib/versponse/error_policy.rb +97 -0
  30. data/lib/versponse/expansion.rb +46 -0
  31. data/lib/versponse/extractors/block.rb +24 -0
  32. data/lib/versponse/extractors/fallback.rb +27 -0
  33. data/lib/versponse/extractors/itself.rb +21 -0
  34. data/lib/versponse/extractors/key.rb +28 -0
  35. data/lib/versponse/extractors/path.rb +28 -0
  36. data/lib/versponse/extractors.rb +23 -0
  37. data/lib/versponse/field_order.rb +23 -0
  38. data/lib/versponse/field_type.rb +88 -0
  39. data/lib/versponse/formatters.rb +38 -0
  40. data/lib/versponse/gem_version.rb +6 -0
  41. data/lib/versponse/inline_validation/builder.rb +129 -0
  42. data/lib/versponse/inline_validation/state.rb +16 -0
  43. data/lib/versponse/inline_validation.rb +86 -0
  44. data/lib/versponse/internals.rb +553 -0
  45. data/lib/versponse/json_value.rb +22 -0
  46. data/lib/versponse/lazy_plan.rb +30 -0
  47. data/lib/versponse/naming.rb +68 -0
  48. data/lib/versponse/object_schema.rb +25 -0
  49. data/lib/versponse/open_api/builder.rb +159 -0
  50. data/lib/versponse/open_api/component_set.rb +33 -0
  51. data/lib/versponse/open_api/declarations.rb +90 -0
  52. data/lib/versponse/open_api/document.rb +187 -0
  53. data/lib/versponse/open_api/ref_check.rb +89 -0
  54. data/lib/versponse/open_api/rswag_specs.rb +98 -0
  55. data/lib/versponse/open_api/sources.rb +72 -0
  56. data/lib/versponse/open_api/values.rb +29 -0
  57. data/lib/versponse/open_api.rb +12 -0
  58. data/lib/versponse/output_validator.rb +82 -0
  59. data/lib/versponse/pattern.rb +38 -0
  60. data/lib/versponse/plan.rb +104 -0
  61. data/lib/versponse/properties/array.rb +56 -0
  62. data/lib/versponse/properties/base.rb +325 -0
  63. data/lib/versponse/properties/collection.rb +135 -0
  64. data/lib/versponse/properties/const.rb +46 -0
  65. data/lib/versponse/properties/container.rb +51 -0
  66. data/lib/versponse/properties/enum.rb +93 -0
  67. data/lib/versponse/properties/field.rb +43 -0
  68. data/lib/versponse/properties/item_one_of.rb +19 -0
  69. data/lib/versponse/properties/object.rb +32 -0
  70. data/lib/versponse/properties/one_of.rb +176 -0
  71. data/lib/versponse/properties/reference.rb +133 -0
  72. data/lib/versponse/references.rb +162 -0
  73. data/lib/versponse/registry.rb +124 -0
  74. data/lib/versponse/schema_context.rb +69 -0
  75. data/lib/versponse/schema_merge.rb +24 -0
  76. data/lib/versponse/selection/scope.rb +36 -0
  77. data/lib/versponse/selection.rb +92 -0
  78. data/lib/versponse/selection_schema.rb +101 -0
  79. data/lib/versponse/steps/checked.rb +43 -0
  80. data/lib/versponse/steps/checked_read.rb +46 -0
  81. data/lib/versponse/steps/checked_read_formatted.rb +32 -0
  82. data/lib/versponse/steps/checked_value.rb +48 -0
  83. data/lib/versponse/steps/const.rb +44 -0
  84. data/lib/versponse/steps/nested.rb +98 -0
  85. data/lib/versponse/steps/one_of.rb +62 -0
  86. data/lib/versponse/steps/read.rb +48 -0
  87. data/lib/versponse/steps/read_formatted.rb +28 -0
  88. data/lib/versponse/steps/value.rb +52 -0
  89. data/lib/versponse/supported_versions.rb +61 -0
  90. data/lib/versponse/validator/all.rb +32 -0
  91. data/lib/versponse/validator/array_check.rb +48 -0
  92. data/lib/versponse/validator/boolean_check.rb +16 -0
  93. data/lib/versponse/validator/both.rb +24 -0
  94. data/lib/versponse/validator/class_check.rb +25 -0
  95. data/lib/versponse/validator/closed_object_check.rb +78 -0
  96. data/lib/versponse/validator/combinator.rb +40 -0
  97. data/lib/versponse/validator/compiler.rb +146 -0
  98. data/lib/versponse/validator/constant.rb +33 -0
  99. data/lib/versponse/validator/date_time_check.rb +21 -0
  100. data/lib/versponse/validator/format_check.rb +49 -0
  101. data/lib/versponse/validator/integer_check.rb +16 -0
  102. data/lib/versponse/validator/nullable_check.rb +22 -0
  103. data/lib/versponse/validator/number_check.rb +58 -0
  104. data/lib/versponse/validator/object_check.rb +90 -0
  105. data/lib/versponse/validator/ref.rb +38 -0
  106. data/lib/versponse/validator/root.rb +31 -0
  107. data/lib/versponse/validator/string_check.rb +48 -0
  108. data/lib/versponse/validator/string_keys.rb +21 -0
  109. data/lib/versponse/validator/type_check.rb +84 -0
  110. data/lib/versponse/validator/value_check.rb +36 -0
  111. data/lib/versponse/validator.rb +10 -0
  112. data/lib/versponse/variant_dispatcher.rb +35 -0
  113. data/lib/versponse/version.rb +179 -1
  114. data/lib/versponse/version_resolver.rb +27 -0
  115. data/lib/versponse/versions.rb +116 -0
  116. data/lib/versponse/view.rb +126 -0
  117. data/lib/versponse/wrapper.rb +53 -0
  118. data/lib/versponse.rb +165 -3
  119. metadata +151 -12
  120. data/sig/versponse.rbs +0 -4
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ # Builds an object schema from properties; every unconditional property is required (no required keyword when none is)
5
+ #
6
+ # @api private
7
+ module ObjectSchema
8
+ # @param properties [Array<Versponse::Properties::Base>] properties in output order
9
+ # @param context [Versponse::SchemaContext] schema generation context
10
+ # @param description [String, nil] schema description
11
+ # @param schema_options [Hash] keywords merged last
12
+ # @param additional_properties [Boolean] additionalProperties (config.default_additional_properties, false for collapsed shapes)
13
+ # @return [Hash] object schema
14
+ def self.build(properties, context, description: nil, schema_options: {}, additional_properties: Versponse.configuration.default_additional_properties)
15
+ schema = {"type" => "object"}
16
+ schema["description"] = description if description
17
+ required = properties.reject(&:conditional?).map(&:key)
18
+ # OpenAPI 3.0 and draft 4 need at least one item in required
19
+ schema["required"] = required unless required.empty?
20
+ schema["properties"] = properties.to_h { |property| [property.key, property.schema(context)] }
21
+ schema["additionalProperties"] = additional_properties
22
+ schema.merge(schema_options)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module OpenAPI
5
+ # Assembles one version of a document into a String-keyed Hash
6
+ #
7
+ # @api private
8
+ class Builder
9
+ # @param declarations [Versponse::OpenAPI::Declarations] the document's declarations
10
+ # @param version [String] version to build
11
+ def initialize(declarations, version)
12
+ @declarations = declarations
13
+ @version = version
14
+ end
15
+
16
+ # @return [Hash] the document, a copy the caller can change
17
+ # @raise [ArgumentError] for a version the document doesn't declare
18
+ # @raise [Versponse::DefinitionError] for colliding component names, an openapi version config.json_schema_version
19
+ # doesn't write, or webhooks in an OpenAPI 3.0 document
20
+ def call
21
+ check_version!
22
+ # Components are the classes' frozen, cached schemas, so the caller and the hooks get a copy
23
+ document = DeepCopy.call(base.merge("components" => components))
24
+ # A hook that takes one argument gets only the document
25
+ @declarations.finalizers.each { |hook| (hook.arity == 1) ? hook.call(document) : hook.call(document, @version) }
26
+ document
27
+ end
28
+
29
+ private
30
+
31
+ def check_version!
32
+ versions = @declarations.versions
33
+ return if versions.empty? || versions.include?(@version)
34
+
35
+ raise ArgumentError, "#{@version} is not a version of this document (#{versions.join(", ")})"
36
+ end
37
+
38
+ def base
39
+ openapi = openapi_version
40
+ webhooks = resolve(@declarations.webhooks)
41
+ if webhooks && openapi.start_with?("3.0.")
42
+ raise DefinitionError, "webhooks need OpenAPI 3.1 (config.json_schema_version is :#{Versponse.configuration.json_schema_version})"
43
+ end
44
+
45
+ {
46
+ "openapi" => openapi,
47
+ "info" => info,
48
+ "externalDocs" => resolve(@declarations.external_docs),
49
+ "servers" => presence(resolve(@declarations.servers)),
50
+ "tags" => presence(resolve(@declarations.tags)),
51
+ "security" => resolve(@declarations.security),
52
+ "paths" => {},
53
+ "webhooks" => webhooks
54
+ }.compact
55
+ end
56
+
57
+ # Each layer is resolved for the version and merged over the ones before it
58
+ def info
59
+ @declarations.info.reduce({"version" => @version}) { |merged, layer| merged.merge(resolve(layer) || {}) }
60
+ end
61
+
62
+ def components
63
+ schemes = @declarations.security_schemes.transform_values { |scheme| resolve(scheme) }
64
+ {"securitySchemes" => presence(schemes), "schemas" => schemas}.compact
65
+ end
66
+
67
+ def schemas
68
+ set = ComponentSet.new
69
+ add_responses(set)
70
+ add_requests(set)
71
+ add_wrappers(set)
72
+ add_schemas(set)
73
+ set.to_h
74
+ end
75
+
76
+ def add_responses(set)
77
+ response_classes.each do |klass|
78
+ Versponse.components(version: @version, classes: [klass]).each { |name, schema| set.add(name, schema, klass.name) }
79
+ end
80
+ end
81
+
82
+ def add_requests(set)
83
+ request_classes.each do |klass|
84
+ References.check_dialect!(klass)
85
+ schema = request_schema(klass)
86
+ set.add(klass.component_name, schema, klass.name) if schema
87
+ end
88
+ end
89
+
90
+ # Wrapper schemas only $ref the class, so skip classes that don't cover the version (supported_versions)
91
+ def add_wrappers(set)
92
+ @declarations.wrappers.each_value do |wrapper|
93
+ next unless Components.resolved(wrapper.klass, @version)
94
+
95
+ schema = wrapper.klass.to_schema(version: @version, view: wrapper.view, root: wrapper.root, meta: wrapper.meta, collection: wrapper.collection)
96
+ set.add(wrapper.name, schema, "wrapper #{wrapper.name}")
97
+ end
98
+ end
99
+
100
+ # Hand-written components are schemas, so they're camelized like every other schema; other document values aren't
101
+ def add_schemas(set)
102
+ @declarations.schemas.each do |name, value|
103
+ value = resolve(value)
104
+ set.add(name, value.is_a?(Hash) ? Naming.camelize_keys(value) : value, "schema #{name}")
105
+ end
106
+ end
107
+
108
+ def response_classes
109
+ select(@declarations.response_sources, :responses, "response classes") { Registry.classes }
110
+ end
111
+
112
+ def request_classes
113
+ select(@declarations.request_sources, :requests, "body requests") { verquest_classes.select { |klass| Sources.body?(klass) } }
114
+ end
115
+
116
+ def select(sources, keyword, kind, &candidates)
117
+ sources.flat_map { |source| source.classes || Sources.in_namespace(keyword, source.namespace, kind, &candidates) }.uniq
118
+ end
119
+
120
+ def verquest_classes
121
+ raise DefinitionError, "requests namespace: needs verquest to be loaded" unless defined?(::Verquest::Base)
122
+
123
+ descendants(::Verquest::Base)
124
+ end
125
+
126
+ def descendants(klass)
127
+ klass.subclasses.flat_map { |subclass| [subclass, *descendants(subclass)] }
128
+ end
129
+
130
+ def request_schema(klass)
131
+ klass.to_schema(version: @version)
132
+ rescue *version_errors
133
+ nil
134
+ end
135
+
136
+ def version_errors
137
+ [VersionNotFoundError, (::Verquest::VersionNotFoundError if defined?(::Verquest::VersionNotFoundError))].compact
138
+ end
139
+
140
+ def presence(collection)
141
+ collection unless collection.empty?
142
+ end
143
+
144
+ # Schemas are written for config.json_schema_version, so the document declares the matching version
145
+ def openapi_version
146
+ openapi30 = Versponse.configuration.json_schema_version == :openapi30
147
+ declared = @declarations.openapi
148
+ return openapi30 ? "3.0.3" : "3.1.1" if declared.nil?
149
+ return declared if declared.start_with?("3.0.") == openapi30
150
+
151
+ raise DefinitionError, "openapi #{declared} doesn't match config.json_schema_version :#{Versponse.configuration.json_schema_version} (use #{openapi30 ? "3.0.x" : "3.1.x"})"
152
+ end
153
+
154
+ def resolve(value)
155
+ Values.resolve(value, @version)
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module OpenAPI
5
+ # components.schemas of one build, remembering where each name came from
6
+ #
7
+ # @api private
8
+ class ComponentSet
9
+ # @return [ComponentSet] an empty set
10
+ def initialize
11
+ @schemas = {}
12
+ @sources = {}
13
+ end
14
+
15
+ # @param name [String] component name
16
+ # @param schema [Hash] component schema
17
+ # @param source [String] where it came from, for the collision message
18
+ # @return [void]
19
+ # @raise [Versponse::DefinitionError] when the name is already taken
20
+ def add(name, schema, source)
21
+ raise DefinitionError, "Component #{name} from #{source} collides with #{@sources[name]}" if @schemas.key?(name)
22
+
23
+ @schemas[name] = schema
24
+ @sources[name] = source
25
+ end
26
+
27
+ # @return [Hash{String => Hash}] the schemas
28
+ def to_h
29
+ @schemas
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module OpenAPI
5
+ # Everything a document class declares. Subclasses get a copy, so they inherit and extend it.
6
+ #
7
+ # @api private
8
+ class Declarations
9
+ # One response or request source: an explicit list, or a namespace (Module or String) resolved at build
10
+ Source = Data.define(:classes, :namespace)
11
+ # One named wrapper component
12
+ Wrapper = Data.define(:name, :klass, :root, :collection, :meta, :view)
13
+
14
+ # @return [String, nil] rswag key prefix
15
+ attr_accessor :document_name
16
+ # @return [String, nil] openapi field, nil for the version config.json_schema_version writes
17
+ attr_accessor :openapi
18
+ # @return [Hash, Proc, nil] externalDocs
19
+ attr_accessor :external_docs
20
+ # @return [Array, Proc, nil] top-level security
21
+ attr_accessor :security
22
+ # @return [Hash, Proc, nil] top-level webhooks
23
+ attr_accessor :webhooks
24
+ # @return [Array<String>] versions exposed to rswag (empty = any version)
25
+ attr_accessor :versions
26
+ # @return [Array<Hash, Proc>] info layers, a subclass's after its parent's, merged in order at build
27
+ attr_reader :info
28
+ # @return [Array<Hash>] servers
29
+ attr_reader :servers
30
+ # @return [Array<Hash>] tags
31
+ attr_reader :tags
32
+ # @return [Hash{String => Hash, Proc}] securitySchemes by name
33
+ attr_reader :security_schemes
34
+ # @return [Array<Source>] response sources
35
+ attr_reader :response_sources
36
+ # @return [Array<Source>] request sources
37
+ attr_reader :request_sources
38
+ # @return [Hash{String => Wrapper}] wrapper components by name
39
+ attr_reader :wrappers
40
+ # @return [Hash{String => Hash, Proc}] hand-written components by name
41
+ attr_reader :schemas
42
+ # @return [Array<Proc>] finalize hooks
43
+ attr_reader :finalizers
44
+
45
+ # @return [Declarations] empty declarations
46
+ def initialize
47
+ @openapi = nil
48
+ @versions = []
49
+ @info = []
50
+ @servers = []
51
+ @tags = []
52
+ @security_schemes = {}
53
+ @response_sources = []
54
+ @request_sources = []
55
+ @wrappers = {}
56
+ @schemas = {}
57
+ @finalizers = []
58
+ @claimed = Set.new
59
+ end
60
+
61
+ # @param other [Declarations] declarations being copied
62
+ # @return [void]
63
+ def initialize_copy(other)
64
+ super
65
+ @versions = other.versions.dup
66
+ @info = other.info.dup
67
+ @servers = other.servers.dup
68
+ @tags = other.tags.dup
69
+ @security_schemes = other.security_schemes.dup
70
+ @response_sources = other.response_sources.dup
71
+ @request_sources = other.request_sources.dup
72
+ @wrappers = other.wrappers.dup
73
+ @schemas = other.schemas.dup
74
+ @finalizers = other.finalizers.dup
75
+ # A subclass may reuse its parent's names, which replaces the parent's entries in place
76
+ @claimed = Set.new
77
+ end
78
+
79
+ # Records a name this class declares
80
+ #
81
+ # @param keyword [Symbol] declaring method (:schema, :wrapper or :security_scheme)
82
+ # @param name [String] declared name
83
+ # @return [void]
84
+ # @raise [Versponse::DefinitionError] when this class already declared the name
85
+ def claim!(keyword, name)
86
+ raise DefinitionError, "#{keyword} #{name} is declared twice" unless @claimed.add?([keyword, name])
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module OpenAPI
5
+ # Parent of OpenAPI document classes. Declare the base, components and hooks in the class body,
6
+ # then build one version with {.build}.
7
+ #
8
+ # @example
9
+ # class PublicApiDocument < Versponse::OpenAPI::Document
10
+ # document_name "public_api"
11
+ # versions "2025-09", "preview"
12
+ # info "title" => "Public API"
13
+ # responses namespace: PublicApi
14
+ # end
15
+ #
16
+ # PublicApiDocument.build(version: "2025-09")
17
+ class Document
18
+ @declarations = Declarations.new
19
+
20
+ class << self
21
+ # @return [Versponse::OpenAPI::Declarations] this class's declarations
22
+ # @api private
23
+ attr_reader :declarations
24
+
25
+ # Gives the subclass a copy of the declarations so far
26
+ #
27
+ # @param subclass [Class] new document class
28
+ # @return [void]
29
+ def inherited(subclass)
30
+ super
31
+ subclass.instance_variable_set(:@declarations, declarations.dup)
32
+ end
33
+
34
+ # @param version [String] version to build
35
+ # @return [Hash] String-keyed OpenAPI document
36
+ # @raise [ArgumentError] for a version the document doesn't declare
37
+ # @raise [Versponse::DefinitionError] for colliding component names
38
+ def build(version:)
39
+ Builder.new(declarations, version.to_s).call
40
+ end
41
+
42
+ # Validates against the OpenAPI meta-schema, and checks that every local "$ref" ("#/...") resolves,
43
+ # which the meta-schema can't (a referenced class left out of responses, say)
44
+ #
45
+ # @param version [String] version to build and validate
46
+ # @return [Array<Hash>] errors with :pointer, :type, :message and :details (empty when valid)
47
+ def validate(version:)
48
+ require "json_schemer"
49
+
50
+ document = build(version:)
51
+ errors = JSONSchemer.openapi(document).validate.map do |error|
52
+ {pointer: error["data_pointer"], type: error["type"], message: error["error"], details: error["details"]}
53
+ end
54
+ errors + RefCheck.call(document)
55
+ end
56
+
57
+ # @param value [String, Symbol, nil] the prefix to declare, nil to read it
58
+ # @return [String, nil] the rswag key prefix
59
+ def document_name(value = nil)
60
+ return declarations.document_name if value.nil?
61
+
62
+ declarations.document_name = value.to_s
63
+ end
64
+
65
+ # @param names [Array<String, Symbol>] versions to declare, none to read them
66
+ # @return [Array<String>] declared versions
67
+ def versions(*names)
68
+ return declarations.versions.dup if names.empty?
69
+
70
+ declarations.versions = names.map(&:to_s)
71
+ end
72
+
73
+ # Alias of {versions} without arguments, kept for callers that read it under this name
74
+ #
75
+ # @return [Array<String>] declared versions
76
+ def document_versions = versions
77
+
78
+ # Component name of a declared wrapper or hand-written schema, inherited declarations included
79
+ #
80
+ # @param name [Symbol, String] a Symbol is turned into PascalCase as {wrapper} does (:contact_list is
81
+ # "ContactList"), a String is used as given
82
+ # @return [String] component name
83
+ # @raise [Versponse::DefinitionError] when the document declares no such wrapper or schema
84
+ def component_name(name)
85
+ component = declared_name(name)
86
+ return component if declarations.wrappers.key?(component) || declarations.schemas.key?(component)
87
+
88
+ shown = name.is_a?(Symbol) ? "#{name.inspect} (#{component})" : name.inspect
89
+ raise DefinitionError, "#{self} declares no wrapper or schema #{shown}"
90
+ end
91
+
92
+ # @param name [Symbol, String] wrapper or schema name, as {component_name} takes it
93
+ # @return [String] "#/components/schemas/<name>" reference
94
+ # @raise [Versponse::DefinitionError] when the document declares no such wrapper or schema
95
+ def ref(name)
96
+ "#/components/schemas/#{component_name(name)}"
97
+ end
98
+
99
+ private
100
+
101
+ def declared_name(name)
102
+ name.is_a?(Symbol) ? Naming.pascalize(name) : name.to_s
103
+ end
104
+
105
+ def openapi(value)
106
+ declarations.openapi = value.to_s
107
+ end
108
+
109
+ def info(value = nil, &block)
110
+ declarations.info << required_value(:info, value, block)
111
+ end
112
+
113
+ def external_docs(value = nil, &block)
114
+ declarations.external_docs = required_value(:external_docs, value, block)
115
+ end
116
+
117
+ def security(value = nil, &block)
118
+ declarations.security = required_value(:security, value, block)
119
+ end
120
+
121
+ def server(url, description: nil, **extra)
122
+ declarations.servers << {"url" => url, "description" => description}.compact.merge(extensions(extra))
123
+ end
124
+
125
+ def tag(name, description: nil, **extra)
126
+ declarations.tags << {"name" => name, "description" => description}.compact.merge(extensions(extra))
127
+ end
128
+
129
+ def security_scheme(name, value = nil, &block)
130
+ value = required_value(:security_scheme, value, block)
131
+ declarations.claim!(:security_scheme, name.to_s)
132
+ declarations.security_schemes[name.to_s] = value
133
+ end
134
+
135
+ def responses(*classes, namespace: nil)
136
+ declarations.response_sources << source(:responses, classes.flatten, namespace)
137
+ end
138
+
139
+ def requests(*classes, namespace: nil)
140
+ declarations.request_sources << source(:requests, classes.flatten, namespace)
141
+ end
142
+
143
+ def wrapper(name, klass, root:, collection: false, meta: nil, view: :default)
144
+ Versponse::Wrapper.check_meta!(meta)
145
+ name = declared_name(name)
146
+ declarations.claim!(:wrapper, name)
147
+ declarations.wrappers[name] = Declarations::Wrapper.new(name:, klass:, root:, collection:, meta:, view:)
148
+ end
149
+
150
+ def schema(name, value = nil, &block)
151
+ value = required_value(:schema, value, block)
152
+ declarations.claim!(:schema, name.to_s)
153
+ declarations.schemas[name.to_s] = value
154
+ end
155
+
156
+ def webhooks(value = nil, &block)
157
+ declarations.webhooks = required_value(:webhooks, value, block)
158
+ end
159
+
160
+ def source(keyword, classes, namespace)
161
+ raise DefinitionError, "#{keyword} needs a list of classes or namespace:" if classes.empty? == namespace.nil?
162
+ return Declarations::Source.new(classes: nil, namespace: namespace.is_a?(Module) ? namespace : namespace.to_s) if namespace
163
+
164
+ Sources.check!(keyword, classes)
165
+ Declarations::Source.new(classes:, namespace: nil)
166
+ end
167
+
168
+ # Keyword keys that start with x_ are OpenAPI extensions: x_internal becomes "x-internal"
169
+ def extensions(extra)
170
+ extra.to_h { |key, value| [key.start_with?("x_") ? key.to_s.tr("_", "-") : key.to_s, value] }
171
+ end
172
+
173
+ def finalize(&block)
174
+ raise DefinitionError, "finalize needs a block" unless block
175
+
176
+ declarations.finalizers << block
177
+ end
178
+
179
+ def required_value(keyword, value, block)
180
+ raise DefinitionError, "#{keyword} needs a value or a block" if value.nil? && block.nil?
181
+
182
+ block || value
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module OpenAPI
5
+ # Finds local "$ref"s ("#/...") that point at nothing in a built document. Anchor references
6
+ # ("#Name"), external references and example values are left alone.
7
+ #
8
+ # @api private
9
+ module RefCheck
10
+ # Keywords whose values are data inside a Schema Object, so a "$ref" key in them isn't a reference ("examples"
11
+ # too when it's a schema's Array). Outside schemas only "example" is data: "default" is a response there.
12
+ DATA_KEYWORDS = %w[example default const enum].freeze
13
+
14
+ # Keywords whose keys are names (a property may be called "default"), not keywords
15
+ NAME_MAPS = %w[properties patternProperties $defs definitions dependentSchemas].freeze
16
+
17
+ # @param document [Hash] built OpenAPI document
18
+ # @return [Array<Hash>] errors with :pointer, :type, :message and :details
19
+ def self.call(document)
20
+ errors = []
21
+ walk(document, "", document, errors)
22
+ errors
23
+ end
24
+
25
+ # schema: true inside a Schema Object (below a "schema" key or a components/schemas entry)
26
+ def self.walk(value, pointer, document, errors, names: false, schema: false)
27
+ case value
28
+ when Hash then names ? walk_names(value, pointer, document, errors, schema:) : walk_object(value, pointer, document, errors, schema:)
29
+ when Array then value.each_with_index { |item, index| walk(item, "#{pointer}/#{index}", document, errors, schema:) }
30
+ end
31
+ end
32
+
33
+ def self.walk_names(map, pointer, document, errors, schema:)
34
+ map.each { |name, item| walk(item, "#{pointer}/#{escape(name)}", document, errors, schema:) }
35
+ end
36
+
37
+ def self.walk_object(object, pointer, document, errors, schema:)
38
+ check(object["$ref"], pointer, document, errors) if object["$ref"].is_a?(String)
39
+ check_mapping(object["discriminator"], "#{pointer}/discriminator", document, errors)
40
+ object.each do |key, item|
41
+ next if data?(key, item, schema:)
42
+
43
+ components = key == "schemas" && pointer == "/components"
44
+ walk(item, "#{pointer}/#{escape(key)}", document, errors,
45
+ names: components || (schema && NAME_MAPS.include?(key)), schema: schema || components || key == "schema")
46
+ end
47
+ end
48
+
49
+ def self.data?(key, item, schema:)
50
+ return key == "example" unless schema
51
+
52
+ DATA_KEYWORDS.include?(key) || (key == "examples" && !item.is_a?(Hash))
53
+ end
54
+
55
+ def self.check_mapping(discriminator, pointer, document, errors)
56
+ return unless discriminator.is_a?(Hash) && discriminator["mapping"].is_a?(Hash)
57
+
58
+ discriminator["mapping"].each do |value, ref|
59
+ check(ref, "#{pointer}/mapping/#{escape(value)}", document, errors) if ref.is_a?(String)
60
+ end
61
+ end
62
+
63
+ def self.check(ref, pointer, document, errors)
64
+ return unless ref.start_with?("#/")
65
+ return if resolves?(document, ref)
66
+
67
+ errors << {pointer:, type: "ref", message: "$ref #{ref} does not resolve in the document", details: {"ref" => ref}}
68
+ end
69
+
70
+ def self.resolves?(document, ref)
71
+ ref.delete_prefix("#/").split("/").reduce(document) do |node, token|
72
+ token = token.gsub("~1", "/").gsub("~0", "~")
73
+ case node
74
+ when Hash then node.fetch(token) { return false }
75
+ when Array then (/\A\d+\z/.match?(token) && token.to_i < node.size) ? node[token.to_i] : (return false)
76
+ else return false
77
+ end
78
+ end
79
+ true
80
+ end
81
+
82
+ def self.escape(key)
83
+ key.to_s.gsub("~", "~0").gsub("/", "~1")
84
+ end
85
+
86
+ private_class_method :walk, :walk_names, :walk_object, :data?, :check_mapping, :check, :resolves?, :escape
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Versponse
4
+ module OpenAPI
5
+ # rswag's config.openapi_specs: maps "name/version.json" keys to lazily built, memoized,
6
+ # symbol-keyed documents (rswag merges paths with symbol keys). Security scheme "type" and "in"
7
+ # values are symbols too: rswag compares them with :apiKey and :header to send API key headers.
8
+ class RswagSpecs
9
+ include Enumerable
10
+
11
+ # @param documents [Array<Class>] Versponse::OpenAPI::Document subclasses with document_name and versions
12
+ # @raise [Versponse::DefinitionError] when a document has no document_name
13
+ # @raise [Versponse::DefinitionError] when a document has no versions
14
+ def initialize(documents)
15
+ @entries = documents.each_with_object({}) do |document, entries|
16
+ raise DefinitionError, "#{document.name} needs document_name for rswag" unless document.document_name
17
+ raise DefinitionError, "#{document.name} needs versions for rswag" if document.document_versions.empty?
18
+
19
+ document.document_versions.each { |version| entries["#{document.document_name}/#{version}.json"] = [document, version] }
20
+ end
21
+ @built = {}
22
+ end
23
+
24
+ # @param key [String] e.g. "public_api/2025-09.json" (.yaml and .yml are accepted too)
25
+ # @return [Hash, nil] the symbol-keyed document, nil for an unknown key (rswag checks for nil); built on
26
+ # first access and kept, because rswag merges its paths into it
27
+ def [](key)
28
+ key = normalize(key)
29
+ return unless @entries.key?(key)
30
+
31
+ @built[key] ||= symbolize_security_schemes(symbolize(build(key)))
32
+ end
33
+
34
+ # @param key [String] document key
35
+ # @return [Hash] the symbol-keyed document
36
+ # @raise [KeyError] for an unknown key
37
+ def fetch(key)
38
+ self[key] || raise(KeyError, "Unknown OpenAPI document #{key}")
39
+ end
40
+
41
+ # @yieldparam key [String] document key
42
+ # @yieldparam document [Hash] symbol-keyed document
43
+ # @return [Enumerator, void] an enumerator without a block
44
+ def each
45
+ return enum_for(:each) unless block_given?
46
+
47
+ keys.each { |key| yield key, self[key] }
48
+ end
49
+
50
+ # @return [Array<String>] every document key
51
+ def keys
52
+ @entries.keys
53
+ end
54
+
55
+ # @return [Array<Hash>] every document
56
+ def values
57
+ keys.map { |key| self[key] }
58
+ end
59
+
60
+ # @param key [String] document key
61
+ # @return [Boolean] whether the key is known
62
+ def key?(key)
63
+ @entries.key?(normalize(key))
64
+ end
65
+
66
+ # @return [Boolean] true when no document is declared
67
+ def empty?
68
+ @entries.empty?
69
+ end
70
+
71
+ private
72
+
73
+ def normalize(key)
74
+ key.to_s.sub(/\.ya?ml\z/, ".json")
75
+ end
76
+
77
+ def build(key)
78
+ document, version = @entries.fetch(key)
79
+ document.build(version:)
80
+ end
81
+
82
+ def symbolize_security_schemes(document)
83
+ document.dig(:components, :securitySchemes)&.each_value do |scheme|
84
+ scheme.slice(:type, :in).each { |name, value| scheme[name] = value.to_sym }
85
+ end
86
+ document
87
+ end
88
+
89
+ def symbolize(value)
90
+ case value
91
+ when Hash then value.to_h { |key, item| [key.to_sym, symbolize(item)] }
92
+ when Array then value.map { |item| symbolize(item) }
93
+ else value
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end