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.
- checksums.yaml +4 -4
- data/.rubocop.yml +15 -6
- data/CHANGELOG.md +59 -1
- data/CODE_OF_CONDUCT.md +1 -1
- data/LICENSE.txt +1 -1
- data/README.md +674 -20
- data/Rakefile +26 -3
- data/lib/versponse/base/class_methods.rb +180 -0
- data/lib/versponse/base/dsl.rb +231 -0
- data/lib/versponse/base.rb +35 -0
- data/lib/versponse/builder.rb +423 -0
- data/lib/versponse/cache.rb +47 -0
- data/lib/versponse/callable.rb +74 -0
- data/lib/versponse/class_policies.rb +15 -0
- data/lib/versponse/collapse_if_plan.rb +26 -0
- data/lib/versponse/collections.rb +49 -0
- data/lib/versponse/components.rb +37 -0
- data/lib/versponse/condition.rb +33 -0
- data/lib/versponse/configuration.rb +261 -0
- data/lib/versponse/const_as_enum.rb +39 -0
- data/lib/versponse/context_override/call.rb +27 -0
- data/lib/versponse/context_override/merge.rb +23 -0
- data/lib/versponse/context_override.rb +25 -0
- data/lib/versponse/context_plan.rb +24 -0
- data/lib/versponse/deep_copy.rb +27 -0
- data/lib/versponse/deep_freeze.rb +20 -0
- data/lib/versponse/discriminated_plan.rb +26 -0
- data/lib/versponse/error_policy/recovery.rb +68 -0
- data/lib/versponse/error_policy.rb +97 -0
- data/lib/versponse/expansion.rb +46 -0
- data/lib/versponse/extractors/block.rb +24 -0
- data/lib/versponse/extractors/fallback.rb +27 -0
- data/lib/versponse/extractors/itself.rb +21 -0
- data/lib/versponse/extractors/key.rb +28 -0
- data/lib/versponse/extractors/path.rb +28 -0
- data/lib/versponse/extractors.rb +23 -0
- data/lib/versponse/field_order.rb +23 -0
- data/lib/versponse/field_type.rb +88 -0
- data/lib/versponse/formatters.rb +38 -0
- data/lib/versponse/gem_version.rb +6 -0
- data/lib/versponse/inline_validation/builder.rb +129 -0
- data/lib/versponse/inline_validation/state.rb +16 -0
- data/lib/versponse/inline_validation.rb +86 -0
- data/lib/versponse/internals.rb +553 -0
- data/lib/versponse/json_value.rb +22 -0
- data/lib/versponse/lazy_plan.rb +30 -0
- data/lib/versponse/naming.rb +68 -0
- data/lib/versponse/object_schema.rb +25 -0
- data/lib/versponse/open_api/builder.rb +159 -0
- data/lib/versponse/open_api/component_set.rb +33 -0
- data/lib/versponse/open_api/declarations.rb +90 -0
- data/lib/versponse/open_api/document.rb +187 -0
- data/lib/versponse/open_api/ref_check.rb +89 -0
- data/lib/versponse/open_api/rswag_specs.rb +98 -0
- data/lib/versponse/open_api/sources.rb +72 -0
- data/lib/versponse/open_api/values.rb +29 -0
- data/lib/versponse/open_api.rb +12 -0
- data/lib/versponse/output_validator.rb +82 -0
- data/lib/versponse/pattern.rb +38 -0
- data/lib/versponse/plan.rb +104 -0
- data/lib/versponse/properties/array.rb +56 -0
- data/lib/versponse/properties/base.rb +325 -0
- data/lib/versponse/properties/collection.rb +135 -0
- data/lib/versponse/properties/const.rb +46 -0
- data/lib/versponse/properties/container.rb +51 -0
- data/lib/versponse/properties/enum.rb +93 -0
- data/lib/versponse/properties/field.rb +43 -0
- data/lib/versponse/properties/item_one_of.rb +19 -0
- data/lib/versponse/properties/object.rb +32 -0
- data/lib/versponse/properties/one_of.rb +176 -0
- data/lib/versponse/properties/reference.rb +133 -0
- data/lib/versponse/references.rb +162 -0
- data/lib/versponse/registry.rb +124 -0
- data/lib/versponse/schema_context.rb +69 -0
- data/lib/versponse/schema_merge.rb +24 -0
- data/lib/versponse/selection/scope.rb +36 -0
- data/lib/versponse/selection.rb +92 -0
- data/lib/versponse/selection_schema.rb +101 -0
- data/lib/versponse/steps/checked.rb +43 -0
- data/lib/versponse/steps/checked_read.rb +46 -0
- data/lib/versponse/steps/checked_read_formatted.rb +32 -0
- data/lib/versponse/steps/checked_value.rb +48 -0
- data/lib/versponse/steps/const.rb +44 -0
- data/lib/versponse/steps/nested.rb +98 -0
- data/lib/versponse/steps/one_of.rb +62 -0
- data/lib/versponse/steps/read.rb +48 -0
- data/lib/versponse/steps/read_formatted.rb +28 -0
- data/lib/versponse/steps/value.rb +52 -0
- data/lib/versponse/supported_versions.rb +61 -0
- data/lib/versponse/validator/all.rb +32 -0
- data/lib/versponse/validator/array_check.rb +48 -0
- data/lib/versponse/validator/boolean_check.rb +16 -0
- data/lib/versponse/validator/both.rb +24 -0
- data/lib/versponse/validator/class_check.rb +25 -0
- data/lib/versponse/validator/closed_object_check.rb +78 -0
- data/lib/versponse/validator/combinator.rb +40 -0
- data/lib/versponse/validator/compiler.rb +146 -0
- data/lib/versponse/validator/constant.rb +33 -0
- data/lib/versponse/validator/date_time_check.rb +21 -0
- data/lib/versponse/validator/format_check.rb +49 -0
- data/lib/versponse/validator/integer_check.rb +16 -0
- data/lib/versponse/validator/nullable_check.rb +22 -0
- data/lib/versponse/validator/number_check.rb +58 -0
- data/lib/versponse/validator/object_check.rb +90 -0
- data/lib/versponse/validator/ref.rb +38 -0
- data/lib/versponse/validator/root.rb +31 -0
- data/lib/versponse/validator/string_check.rb +48 -0
- data/lib/versponse/validator/string_keys.rb +21 -0
- data/lib/versponse/validator/type_check.rb +84 -0
- data/lib/versponse/validator/value_check.rb +36 -0
- data/lib/versponse/validator.rb +10 -0
- data/lib/versponse/variant_dispatcher.rb +35 -0
- data/lib/versponse/version.rb +179 -1
- data/lib/versponse/version_resolver.rb +27 -0
- data/lib/versponse/versions.rb +116 -0
- data/lib/versponse/view.rb +126 -0
- data/lib/versponse/wrapper.rb +53 -0
- data/lib/versponse.rb +165 -3
- metadata +151 -12
- 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
|