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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/CONTRIBUTING.md +14 -0
  4. data/LICENSE +21 -0
  5. data/README.md +239 -0
  6. data/Rakefile +30 -0
  7. data/contracts/additional_rest/network_policies.json +94 -0
  8. data/contracts/additional_rest/prometheus.json +103 -0
  9. data/contracts/graphql/fly_go_operations.json +948 -0
  10. data/contracts/graphql/flyctl_named_operations.graphql +316 -0
  11. data/contracts/graphql/flyctl_operations.json +336 -0
  12. data/contracts/graphql/official_examples.json +75 -0
  13. data/contracts/graphql/schema.graphql +10995 -0
  14. data/contracts/graphql/source.json +29 -0
  15. data/contracts/machines/openapi.headers +9 -0
  16. data/contracts/machines/openapi.json +1 -0
  17. data/contracts/machines/source.json +20 -0
  18. data/contracts/public_surface_inventory.json +164 -0
  19. data/contracts/research_metadata.json +17 -0
  20. data/contracts/sources/metrics.html.md +474 -0
  21. data/contracts/sources/network-policies.html.markerb +142 -0
  22. data/docs/API.md +149 -0
  23. data/docs/SURFACES.md +60 -0
  24. data/lib/fly_io/client.rb +52 -0
  25. data/lib/fly_io/configuration.rb +115 -0
  26. data/lib/fly_io/errors.rb +45 -0
  27. data/lib/fly_io/generated/additional_operations.json +123 -0
  28. data/lib/fly_io/generated/graphql_operations.json +1658 -0
  29. data/lib/fly_io/generated/operations.json +6130 -0
  30. data/lib/fly_io/generated/prometheus_operations.json +503 -0
  31. data/lib/fly_io/generated/schemas.json +4237 -0
  32. data/lib/fly_io/graphql_client.rb +103 -0
  33. data/lib/fly_io/model.rb +65 -0
  34. data/lib/fly_io/operation_registry.rb +79 -0
  35. data/lib/fly_io/redactor.rb +34 -0
  36. data/lib/fly_io/resources/base.rb +68 -0
  37. data/lib/fly_io/resources.rb +30 -0
  38. data/lib/fly_io/response.rb +45 -0
  39. data/lib/fly_io/schema_registry.rb +89 -0
  40. data/lib/fly_io/schema_validator.rb +56 -0
  41. data/lib/fly_io/transport.rb +282 -0
  42. data/lib/fly_io/version.rb +5 -0
  43. data/lib/fly_io.rb +23 -0
  44. data/script/check_api_coverage +60 -0
  45. data/script/fetch_openapi +18 -0
  46. data/script/generate_api +302 -0
  47. 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
@@ -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