fly_io 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 +7 -0
- data/CHANGELOG.md +15 -0
- data/CONTRIBUTING.md +14 -0
- data/LICENSE +21 -0
- data/README.md +239 -0
- data/Rakefile +30 -0
- data/contracts/additional_rest/network_policies.json +94 -0
- data/contracts/additional_rest/prometheus.json +103 -0
- data/contracts/graphql/fly_go_operations.json +948 -0
- data/contracts/graphql/flyctl_named_operations.graphql +316 -0
- data/contracts/graphql/flyctl_operations.json +336 -0
- data/contracts/graphql/official_examples.json +75 -0
- data/contracts/graphql/schema.graphql +10995 -0
- data/contracts/graphql/source.json +29 -0
- data/contracts/machines/openapi.headers +9 -0
- data/contracts/machines/openapi.json +1 -0
- data/contracts/machines/source.json +20 -0
- data/contracts/public_surface_inventory.json +164 -0
- data/contracts/research_metadata.json +17 -0
- data/contracts/sources/metrics.html.md +474 -0
- data/contracts/sources/network-policies.html.markerb +142 -0
- data/docs/API.md +149 -0
- data/docs/SURFACES.md +60 -0
- data/lib/fly_io/client.rb +52 -0
- data/lib/fly_io/configuration.rb +115 -0
- data/lib/fly_io/errors.rb +45 -0
- data/lib/fly_io/generated/additional_operations.json +123 -0
- data/lib/fly_io/generated/graphql_operations.json +1658 -0
- data/lib/fly_io/generated/operations.json +6130 -0
- data/lib/fly_io/generated/prometheus_operations.json +503 -0
- data/lib/fly_io/generated/schemas.json +4237 -0
- data/lib/fly_io/graphql_client.rb +103 -0
- data/lib/fly_io/model.rb +65 -0
- data/lib/fly_io/operation_registry.rb +79 -0
- data/lib/fly_io/redactor.rb +34 -0
- data/lib/fly_io/resources/base.rb +68 -0
- data/lib/fly_io/resources.rb +30 -0
- data/lib/fly_io/response.rb +45 -0
- data/lib/fly_io/schema_registry.rb +89 -0
- data/lib/fly_io/schema_validator.rb +56 -0
- data/lib/fly_io/transport.rb +282 -0
- data/lib/fly_io/version.rb +5 -0
- data/lib/fly_io.rb +23 -0
- data/script/check_api_coverage +60 -0
- data/script/fetch_openapi +18 -0
- data/script/generate_api +302 -0
- metadata +197 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FlyIO
|
|
6
|
+
# Fly documents this control-plane GraphQL endpoint as internal and unstable.
|
|
7
|
+
# Raw documents are first class; generated flyctl-observed operations live
|
|
8
|
+
# behind #experimental and carry no compatibility guarantee.
|
|
9
|
+
class GraphQLClient
|
|
10
|
+
INTROSPECTION_PATTERN = /\b__(?:schema|type)\b/
|
|
11
|
+
|
|
12
|
+
attr_reader :configuration, :transport
|
|
13
|
+
|
|
14
|
+
def initialize(configuration = nil, **)
|
|
15
|
+
@configuration = configuration || Configuration.new(**)
|
|
16
|
+
graphql_configuration = @configuration.with(base_url: graphql_origin)
|
|
17
|
+
@transport = Transport.new(graphql_configuration)
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def query(document:, variables: {}, operation_name: nil, headers: {}, timeout: nil,
|
|
22
|
+
retry_unsafe: false, introspection: false)
|
|
23
|
+
validate_document!(document, introspection)
|
|
24
|
+
body = {"query" => String(document), "variables" => variables}
|
|
25
|
+
body["operationName"] = operation_name if operation_name
|
|
26
|
+
response = transport.request(method: :post, path: graphql_path, headers: headers, body: body,
|
|
27
|
+
retry_unsafe: retry_unsafe, timeout: timeout)
|
|
28
|
+
errors = response.body.is_a?(Hash) ? response.body["errors"] : nil
|
|
29
|
+
if errors&.any?
|
|
30
|
+
raise GraphQLError.new(graphql_errors: errors, status: response.status, headers: response.headers,
|
|
31
|
+
request_id: response.request_id, request: response.request)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
response
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
alias execute query
|
|
38
|
+
|
|
39
|
+
def introspect(document:, **)
|
|
40
|
+
query(document: document, introspection: true, **)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def experimental
|
|
44
|
+
ExperimentalGraphQL.new(self)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def validate_document!(document, introspection)
|
|
50
|
+
value = String(document)
|
|
51
|
+
raise FlyIO::ArgumentError, "GraphQL document must not be empty" if value.strip.empty?
|
|
52
|
+
return unless value.match?(INTROSPECTION_PATTERN)
|
|
53
|
+
return if introspection && configuration.introspection
|
|
54
|
+
|
|
55
|
+
raise FlyIO::ArgumentError,
|
|
56
|
+
"GraphQL introspection requires Configuration(introspection: true) and introspection: true"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def graphql_path
|
|
60
|
+
require "uri"
|
|
61
|
+
uri = URI(configuration.graphql_url)
|
|
62
|
+
if uri.query
|
|
63
|
+
"#{uri.path}?#{uri.query}"
|
|
64
|
+
else
|
|
65
|
+
(uri.path.empty? ? "/" : uri.path)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def graphql_origin
|
|
70
|
+
uri = URI(configuration.graphql_url)
|
|
71
|
+
uri.path = ""
|
|
72
|
+
uri.query = nil
|
|
73
|
+
uri.to_s
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class ExperimentalGraphQL
|
|
78
|
+
MANIFEST_PATH = File.expand_path("generated/graphql_operations.json", __dir__)
|
|
79
|
+
|
|
80
|
+
attr_reader :client
|
|
81
|
+
|
|
82
|
+
def initialize(client)
|
|
83
|
+
@client = client
|
|
84
|
+
freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def operations
|
|
88
|
+
self.class.operations
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def execute(name, variables: {}, **)
|
|
92
|
+
operation = operations.find { |entry| entry.fetch("name") == name.to_s }
|
|
93
|
+
raise FlyIO::ArgumentError, "unknown experimental GraphQL operation: #{name}" unless operation
|
|
94
|
+
|
|
95
|
+
client.query(document: operation.fetch("document"), operation_name: operation["operation_name"],
|
|
96
|
+
variables: variables, **)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.operations
|
|
100
|
+
@operations ||= JSON.parse(File.read(MANIFEST_PATH)).fetch("operations").freeze
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/fly_io/model.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlyIO
|
|
4
|
+
module Models; end
|
|
5
|
+
|
|
6
|
+
class Model
|
|
7
|
+
attr_reader :attributes, :schema_name
|
|
8
|
+
|
|
9
|
+
def initialize(attributes = nil, schema_name: if self.class.const_defined?(:SCHEMA_NAME,
|
|
10
|
+
false)
|
|
11
|
+
self.class::SCHEMA_NAME
|
|
12
|
+
end,
|
|
13
|
+
**keyword_attributes)
|
|
14
|
+
if attributes && !keyword_attributes.empty?
|
|
15
|
+
raise FlyIO::ArgumentError, "provide model attributes as a Hash or keywords, not both"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attributes ||= keyword_attributes
|
|
19
|
+
raise FlyIO::ArgumentError, "model attributes must be a Hash" unless attributes.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
@schema_name = schema_name
|
|
22
|
+
@attributes = attributes.each_with_object({}) do |(key, value), result|
|
|
23
|
+
result[key.to_s] = SchemaRegistry.coerce_property(schema_name, key.to_s, value)
|
|
24
|
+
end.freeze
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def [](key)
|
|
29
|
+
attributes[key.to_s]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def key?(key)
|
|
33
|
+
attributes.key?(key.to_s)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_h
|
|
37
|
+
attributes.transform_values { |value| serialize(value) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def method_missing(name, *arguments)
|
|
41
|
+
return self[name] if arguments.empty? && key?(name)
|
|
42
|
+
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def respond_to_missing?(name, include_private = false)
|
|
47
|
+
key?(name) || super
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ==(other)
|
|
51
|
+
other.is_a?(Model) && other.schema_name == schema_name && other.attributes == attributes
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def serialize(value)
|
|
57
|
+
case value
|
|
58
|
+
when Model then value.to_h
|
|
59
|
+
when Array then value.map { |item| serialize(item) }
|
|
60
|
+
when Hash then value.transform_values { |item| serialize(item) }
|
|
61
|
+
else value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FlyIO
|
|
6
|
+
module OperationRegistry
|
|
7
|
+
MANIFEST_PATHS = %w[operations.json additional_operations.json prometheus_operations.json].map do |name|
|
|
8
|
+
File.expand_path("generated/#{name}", __dir__)
|
|
9
|
+
end.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def operations
|
|
14
|
+
@operations ||= MANIFEST_PATHS.flat_map do |path|
|
|
15
|
+
JSON.parse(File.read(path)).fetch("operations")
|
|
16
|
+
end.map { |data| Operation.new(data) }.freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def resources
|
|
20
|
+
@resources ||= operations.group_by(&:ruby_resource).transform_values(&:freeze).freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fetch(identity_or_operation_id, method: nil, path: nil)
|
|
24
|
+
key = identity_or_operation_id.to_s
|
|
25
|
+
matches = operations.select do |operation|
|
|
26
|
+
(operation.identity == key || operation.operation_id == key) &&
|
|
27
|
+
(!method || operation.http_method == method.to_s.downcase) &&
|
|
28
|
+
(!path || operation.path == path)
|
|
29
|
+
end
|
|
30
|
+
raise FlyIO::ArgumentError, "unknown operation: #{key}" if matches.empty?
|
|
31
|
+
if matches.length > 1
|
|
32
|
+
raise FlyIO::ArgumentError, "ambiguous operation ID #{key}; specify method: and path:"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
matches.first
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Operation
|
|
39
|
+
attr_reader :data
|
|
40
|
+
|
|
41
|
+
def initialize(data)
|
|
42
|
+
@data = data.freeze
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
%w[identity surface stability base_url operation_id path ruby_resource ruby_method summary].each do |name|
|
|
47
|
+
define_method(name) { data[name] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def http_method
|
|
51
|
+
data.fetch("method")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parameters
|
|
55
|
+
data.fetch("parameters")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def request_body
|
|
59
|
+
data["request_body"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def responses
|
|
63
|
+
data.fetch("responses")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def response_schema(status, content_type)
|
|
67
|
+
response = responses[status.to_s] || responses["default"]
|
|
68
|
+
return unless response
|
|
69
|
+
|
|
70
|
+
content = response.fetch("content", {})
|
|
71
|
+
return if content.empty?
|
|
72
|
+
|
|
73
|
+
normalized_type = content_type.to_s.split(";", 2).first.to_s.downcase
|
|
74
|
+
exact = content[normalized_type]
|
|
75
|
+
exact&.fetch("schema", nil) || content.values.first&.fetch("schema", nil)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlyIO
|
|
4
|
+
module Redactor
|
|
5
|
+
FILTERED = "[FILTERED]"
|
|
6
|
+
SENSITIVE_KEYS = /authorization|token|secret|password|credential|private.?key|certificate/i
|
|
7
|
+
AUTHORIZATION_VALUE = %r{\b(?:Bearer|FlyV1)\s+[A-Za-z0-9._~+/=:-]+}i
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def redact(value)
|
|
12
|
+
value.to_s.gsub(AUTHORIZATION_VALUE) { |match| "#{match.split.first} #{FILTERED}" }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def redact_object(value, key = nil)
|
|
16
|
+
return FILTERED if key&.match?(SENSITIVE_KEYS)
|
|
17
|
+
|
|
18
|
+
case value
|
|
19
|
+
when Model
|
|
20
|
+
redact_object(value.to_h, key)
|
|
21
|
+
when Hash
|
|
22
|
+
value.each_with_object({}) do |(child_key, child), result|
|
|
23
|
+
result[child_key] = redact_object(child, child_key.to_s)
|
|
24
|
+
end
|
|
25
|
+
when Array
|
|
26
|
+
value.map { |child| redact_object(child) }
|
|
27
|
+
when String
|
|
28
|
+
redact(value)
|
|
29
|
+
else
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlyIO
|
|
4
|
+
module Resources
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :client
|
|
7
|
+
|
|
8
|
+
def initialize(client)
|
|
9
|
+
@client = client
|
|
10
|
+
freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def operation(operation_id, method: nil, path: nil, **arguments)
|
|
14
|
+
invoke(OperationRegistry.fetch(operation_id, method: method, path: path), arguments)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def invoke(operation, arguments)
|
|
20
|
+
arguments = arguments.dup
|
|
21
|
+
body = arguments.delete(:body) { FlyIO::UNSET }
|
|
22
|
+
extra_headers = arguments.delete(:headers) || {}
|
|
23
|
+
retry_unsafe = arguments.delete(:retry_unsafe)
|
|
24
|
+
request_timeout = arguments.delete(:request_timeout)
|
|
25
|
+
path_params = {}
|
|
26
|
+
query = {}
|
|
27
|
+
operation_headers = {}
|
|
28
|
+
|
|
29
|
+
operation.parameters.each do |parameter|
|
|
30
|
+
ruby_name = parameter.fetch("ruby_name").to_sym
|
|
31
|
+
present = arguments.key?(ruby_name)
|
|
32
|
+
value = arguments.delete(ruby_name)
|
|
33
|
+
if parameter["required"] && (!present || value.nil?)
|
|
34
|
+
raise FlyIO::ArgumentError, "#{operation.ruby_method}: #{ruby_name} is required"
|
|
35
|
+
end
|
|
36
|
+
next unless present
|
|
37
|
+
|
|
38
|
+
SchemaValidator.validate!(parameter["schema"], value, location: ruby_name.to_s)
|
|
39
|
+
target = {"path" => path_params, "query" => query, "header" => operation_headers}.fetch(parameter.fetch("in"))
|
|
40
|
+
target[parameter.fetch("name")] = value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
unless arguments.empty?
|
|
44
|
+
raise FlyIO::ArgumentError, "#{operation.ruby_method}: unknown keywords: #{arguments.keys.join(', ')}"
|
|
45
|
+
end
|
|
46
|
+
if operation.request_body&.fetch("required", false) && body.equal?(FlyIO::UNSET)
|
|
47
|
+
raise FlyIO::ArgumentError, "#{operation.ruby_method}: body is required"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
unless body.equal?(FlyIO::UNSET)
|
|
51
|
+
SchemaValidator.validate!(operation.request_body&.dig("schema"), body, location: "body")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
client.transport_for(operation).request(
|
|
55
|
+
method: operation.http_method,
|
|
56
|
+
path: operation.path,
|
|
57
|
+
path_params: path_params,
|
|
58
|
+
query: query,
|
|
59
|
+
headers: operation_headers.merge(extra_headers),
|
|
60
|
+
body: body,
|
|
61
|
+
retry_unsafe: retry_unsafe,
|
|
62
|
+
timeout: request_timeout,
|
|
63
|
+
operation: operation
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlyIO
|
|
4
|
+
module Resources
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def install!
|
|
8
|
+
OperationRegistry.resources.each do |resource_name, operations|
|
|
9
|
+
constant_name = resource_name.split("_").map(&:capitalize).join
|
|
10
|
+
klass = if const_defined?(constant_name, false)
|
|
11
|
+
const_get(constant_name, false)
|
|
12
|
+
else
|
|
13
|
+
const_set(constant_name, Class.new(Base))
|
|
14
|
+
end
|
|
15
|
+
operations.each do |operation|
|
|
16
|
+
next if klass.public_method_defined?(operation.ruby_method.to_sym, false)
|
|
17
|
+
|
|
18
|
+
klass.define_method(operation.ruby_method) do |**arguments|
|
|
19
|
+
invoke(operation, arguments)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def class_for(resource_name)
|
|
26
|
+
install!
|
|
27
|
+
const_get(resource_name.split("_").map(&:capitalize).join, false)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlyIO
|
|
4
|
+
class Response
|
|
5
|
+
attr_reader :status, :headers, :body, :raw_body, :request, :request_id
|
|
6
|
+
|
|
7
|
+
def initialize(status:, headers:, body:, raw_body:, request:)
|
|
8
|
+
@status = Integer(status)
|
|
9
|
+
@headers = headers.freeze
|
|
10
|
+
@body = body
|
|
11
|
+
@raw_body = raw_body
|
|
12
|
+
@request = request.freeze
|
|
13
|
+
@request_id = headers["fly-request-id"] || headers["x-request-id"]
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
alias data body
|
|
18
|
+
|
|
19
|
+
def success?
|
|
20
|
+
(200..299).cover?(status)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def accepted?
|
|
24
|
+
status == 202
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def no_content?
|
|
28
|
+
status == 204
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def [](key)
|
|
32
|
+
body.respond_to?(:[]) ? body[key] : nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def method_missing(name, *arguments)
|
|
36
|
+
return body.public_send(name) if arguments.empty? && body.respond_to?(name)
|
|
37
|
+
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def respond_to_missing?(name, include_private = false)
|
|
42
|
+
body.respond_to?(name, include_private) || super
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FlyIO
|
|
6
|
+
module SchemaRegistry
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def schemas
|
|
10
|
+
@schemas ||= JSON.parse(File.read(File.expand_path("generated/schemas.json", __dir__))).freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def install_model_constants!
|
|
14
|
+
schemas.each do |name, schema|
|
|
15
|
+
constant_name = schema.fetch("x-ruby-class")
|
|
16
|
+
next if Models.const_defined?(constant_name, false)
|
|
17
|
+
|
|
18
|
+
schema_name = name
|
|
19
|
+
klass = Class.new(Model)
|
|
20
|
+
klass.const_set(:SCHEMA_NAME, schema_name.freeze)
|
|
21
|
+
Models.const_set(constant_name, klass)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build(name, attributes)
|
|
26
|
+
install_model_constants!
|
|
27
|
+
Models.const_get(schemas.fetch(name).fetch("x-ruby-class"), false).new(attributes)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def coerce(schema, value)
|
|
31
|
+
return value if value.nil? || !schema || !schema.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
schema = resolve(schema)
|
|
34
|
+
if (reference = schema["$resolved_ref"]) && value.is_a?(Hash)
|
|
35
|
+
return build(reference, value)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
schema = flatten_all_of(schema)
|
|
39
|
+
|
|
40
|
+
case schema["type"]
|
|
41
|
+
when "array"
|
|
42
|
+
value.is_a?(Array) ? value.map { |item| coerce(schema["items"], item) } : value
|
|
43
|
+
when "object"
|
|
44
|
+
coerce_object(schema, value)
|
|
45
|
+
else
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def coerce_property(schema_name, property, value)
|
|
51
|
+
schema = schemas[schema_name]
|
|
52
|
+
coerce(schema&.dig("properties", property), value)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def resolve(schema)
|
|
56
|
+
reference = schema["$ref"]
|
|
57
|
+
return schema unless reference
|
|
58
|
+
|
|
59
|
+
name = if reference.start_with?("#/components/schemas/")
|
|
60
|
+
reference.delete_prefix("#/components/schemas/")
|
|
61
|
+
elsif reference.start_with?("#/additional/schemas/")
|
|
62
|
+
reference.delete_prefix("#/additional/schemas/")
|
|
63
|
+
else
|
|
64
|
+
raise FlyIO::ArgumentError, "unsupported schema reference: #{reference}"
|
|
65
|
+
end
|
|
66
|
+
schemas.fetch(name).merge("$resolved_ref" => name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def flatten_all_of(schema)
|
|
70
|
+
return schema unless schema["allOf"]
|
|
71
|
+
|
|
72
|
+
schema["allOf"].reduce(schema.except("allOf")) do |result, member|
|
|
73
|
+
resolved = flatten_all_of(resolve(member).except("$resolved_ref"))
|
|
74
|
+
result.merge(resolved) do |key, left, right|
|
|
75
|
+
key == "properties" ? left.merge(right) : right
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def coerce_object(schema, value)
|
|
81
|
+
return value unless value.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
properties = schema.fetch("properties", {})
|
|
84
|
+
value.each_with_object({}) do |(key, child), result|
|
|
85
|
+
result[key.to_s] = coerce(properties[key.to_s] || schema["additionalProperties"], child)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlyIO
|
|
4
|
+
module SchemaValidator
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def validate!(schema, value, location: "value")
|
|
8
|
+
return if schema.nil? || !schema.is_a?(Hash)
|
|
9
|
+
|
|
10
|
+
schema = SchemaRegistry.flatten_all_of(SchemaRegistry.resolve(schema))
|
|
11
|
+
raw = value.is_a?(Model) ? value.to_h : value
|
|
12
|
+
validate_type!(schema["type"], raw, location)
|
|
13
|
+
validate_enum!(schema["enum"], raw, location)
|
|
14
|
+
validate_object!(schema, raw, location) if raw.is_a?(Hash)
|
|
15
|
+
validate_array!(schema, raw, location) if raw.is_a?(Array)
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate_type!(type, value, location)
|
|
20
|
+
return if type.nil? || value.nil?
|
|
21
|
+
|
|
22
|
+
valid = case type
|
|
23
|
+
when "object" then value.is_a?(Hash)
|
|
24
|
+
when "array" then value.is_a?(Array)
|
|
25
|
+
when "string" then value.is_a?(String)
|
|
26
|
+
when "integer" then value.is_a?(Integer)
|
|
27
|
+
when "number" then value.is_a?(Numeric)
|
|
28
|
+
when "boolean" then [true, false].include?(value)
|
|
29
|
+
else true
|
|
30
|
+
end
|
|
31
|
+
raise FlyIO::ArgumentError, "#{location} must be #{type}" unless valid
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate_enum!(enum, value, location)
|
|
35
|
+
return unless enum && !value.nil? && !enum.include?(value)
|
|
36
|
+
|
|
37
|
+
raise FlyIO::ArgumentError, "#{location} must be one of: #{enum.join(', ')}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate_object!(schema, value, location)
|
|
41
|
+
normalized = value.transform_keys(&:to_s)
|
|
42
|
+
Array(schema["required"]).each do |name|
|
|
43
|
+
unless normalized.key?(name) && !normalized[name].nil?
|
|
44
|
+
raise FlyIO::ArgumentError, "#{location}.#{name} is required"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
schema.fetch("properties", {}).each do |name, property_schema|
|
|
48
|
+
validate!(property_schema, normalized[name], location: "#{location}.#{name}") if normalized.key?(name)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_array!(schema, value, location)
|
|
53
|
+
value.each_with_index { |item, index| validate!(schema["items"], item, location: "#{location}[#{index}]") }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|