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,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module FlyIO
9
+ class Transport
10
+ METHODS = %i[get head options post put patch delete].freeze
11
+ SAFE_METHODS = %i[get head options].freeze
12
+ RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504].freeze
13
+ ERROR_CLASSES = {
14
+ 400 => ValidationError,
15
+ 401 => AuthenticationError,
16
+ 403 => AuthorizationError,
17
+ 404 => NotFoundError,
18
+ 408 => RequestTimeoutError,
19
+ 409 => ValidationError,
20
+ 410 => NotFoundError,
21
+ 412 => ValidationError,
22
+ 422 => ValidationError,
23
+ 429 => RateLimitError
24
+ }.freeze
25
+
26
+ attr_reader :configuration
27
+
28
+ def initialize(configuration)
29
+ @configuration = configuration
30
+ @connection = build_connection unless configuration.adapter.respond_to?(:call)
31
+ freeze
32
+ end
33
+
34
+ def request(method:, path:, path_params: {}, query: {}, headers: {}, body: FlyIO::UNSET,
35
+ content_type: "application/json", accept: "application/json", retry_unsafe: nil, operation: nil,
36
+ timeout: nil)
37
+ verb = validate_method(method)
38
+ expanded_path = build_path(path, path_params)
39
+ url = absolute_url(build_url(expanded_path, query))
40
+ request_headers = default_headers(accept).merge(stringify_keys(headers))
41
+ encoded_body = encode_body(body, content_type, request_headers)
42
+ metadata = safe_request_metadata(verb, expanded_path, query, request_headers, body, operation)
43
+ attempts = 0
44
+
45
+ loop do
46
+ begin
47
+ raw_response = perform(verb, url, request_headers, encoded_body, timeout)
48
+ rescue Faraday::TimeoutError
49
+ if attempts < configuration.max_retries && retry_allowed?(verb, retry_unsafe)
50
+ configuration.sleeper.call(retry_delay(attempts, {}))
51
+ attempts += 1
52
+ next
53
+ end
54
+ raise RequestTimeoutError.new("Fly.io API request timed out", request: metadata)
55
+ rescue Faraday::ConnectionFailed, Faraday::SSLError => e
56
+ if attempts < configuration.max_retries && retry_allowed?(verb, retry_unsafe)
57
+ configuration.sleeper.call(retry_delay(attempts, {}))
58
+ attempts += 1
59
+ next
60
+ end
61
+ raise TransportError.new("Fly.io API transport failed: #{e.class}", request: metadata)
62
+ end
63
+ if retry_status?(raw_response.status, verb, retry_unsafe) && attempts < configuration.max_retries
64
+ delay = retry_delay(attempts, raw_response.headers)
65
+ attempts += 1
66
+ configuration.sleeper.call(delay)
67
+ next
68
+ end
69
+ response = build_response(raw_response, metadata, operation)
70
+ raise_api_error(response) unless response.success?
71
+
72
+ log(:debug, "Fly.io API #{verb.to_s.upcase} #{expanded_path} -> #{response.status}", metadata)
73
+ return response
74
+ end
75
+ end
76
+
77
+ def build_path(template, path_params = {})
78
+ value = String(template)
79
+ raise FlyIO::ArgumentError, "path must start with /" unless value.start_with?("/")
80
+ if value.match?(%r{\A//|[?#\\\x00]})
81
+ raise FlyIO::ArgumentError,
82
+ "path must not contain a URL, query, fragment, backslash, or NUL"
83
+ end
84
+
85
+ provided = stringify_keys(path_params)
86
+ result = value.gsub(/\{([^}]+)\}/) do
87
+ name = Regexp.last_match(1)
88
+ raise FlyIO::ArgumentError, "missing required path parameter: #{name}" unless provided.key?(name)
89
+
90
+ escape_path_component(provided.fetch(name), name)
91
+ end
92
+ unused = provided.keys - value.scan(/\{([^}]+)\}/).flatten
93
+ raise FlyIO::ArgumentError, "unknown path parameters: #{unused.join(', ')}" unless unused.empty?
94
+ if result.split("/").any? { |segment| [".", ".."].include?(URI.decode_www_form_component(segment)) }
95
+ raise FlyIO::ArgumentError, "path traversal segments are not allowed"
96
+ end
97
+
98
+ result
99
+ rescue URI::InvalidURIError, ::ArgumentError
100
+ raise FlyIO::ArgumentError, "path contains invalid encoding"
101
+ end
102
+
103
+ private
104
+
105
+ def build_connection
106
+ Faraday.new(url: configuration.base_url, proxy: configuration.proxy) do |faraday|
107
+ faraday.options.open_timeout = configuration.open_timeout
108
+ faraday.options.timeout = configuration.request_timeout || configuration.read_timeout
109
+ faraday.options.read_timeout = configuration.read_timeout if faraday.options.respond_to?(:read_timeout=)
110
+ faraday.options.write_timeout = configuration.write_timeout if faraday.options.respond_to?(:write_timeout=)
111
+ faraday.adapter(configuration.adapter || Faraday.default_adapter)
112
+ end
113
+ end
114
+
115
+ def validate_method(method)
116
+ value = method.to_s.downcase.to_sym
117
+ raise FlyIO::ArgumentError, "unsupported HTTP method: #{method}" unless METHODS.include?(value)
118
+
119
+ value
120
+ end
121
+
122
+ def escape_path_component(value, name)
123
+ string = String(value)
124
+ raise FlyIO::ArgumentError, "path parameter #{name} must not be empty" if string.empty?
125
+ raise FlyIO::ArgumentError, "path parameter #{name} contains NUL" if string.include?("\0")
126
+
127
+ URI.encode_www_form_component(string).gsub("+", "%20")
128
+ end
129
+
130
+ def build_url(path, query)
131
+ pairs = query_pairs(query)
132
+ pairs.empty? ? path : "#{path}?#{URI.encode_www_form(pairs)}"
133
+ end
134
+
135
+ def absolute_url(relative_url)
136
+ base = URI.parse(configuration.base_url)
137
+ relative = URI.parse(relative_url)
138
+ base_path = base.path.to_s.sub(%r{/+\z}, "")
139
+ base.path = "#{base_path}/#{relative.path.delete_prefix('/')}"
140
+ base.query = relative.query
141
+ base.to_s
142
+ end
143
+
144
+ def query_pairs(query)
145
+ raise FlyIO::ArgumentError, "query must be a Hash" unless query.is_a?(Hash)
146
+
147
+ query.each_with_object([]) do |(key, value), pairs|
148
+ next if value.nil?
149
+
150
+ values = value.is_a?(Array) ? value : [value]
151
+ values.each { |item| pairs << [key.to_s, query_value(item)] unless item.nil? }
152
+ end
153
+ end
154
+
155
+ def query_value(value)
156
+ case value
157
+ when true then "true"
158
+ when false then "false"
159
+ else String(value)
160
+ end
161
+ end
162
+
163
+ def default_headers(accept)
164
+ {
165
+ "Accept" => accept,
166
+ "Authorization" => configuration.authorization_value,
167
+ "User-Agent" => configuration.user_agent
168
+ }
169
+ end
170
+
171
+ def encode_body(body, content_type, headers)
172
+ return nil if body.equal?(FlyIO::UNSET)
173
+
174
+ headers["Content-Type"] ||= content_type
175
+ serializable = serialize(body)
176
+ content_type.to_s.downcase.include?("json") ? JSON.generate(serializable) : serializable
177
+ end
178
+
179
+ def serialize(value)
180
+ case value
181
+ when Model then value.to_h
182
+ when Array then value.map { |item| serialize(item) }
183
+ when Hash then value.transform_keys(&:to_s).transform_values { |item| serialize(item) }
184
+ else value
185
+ end
186
+ end
187
+
188
+ def perform(method, url, headers, body, timeout)
189
+ if configuration.adapter.respond_to?(:call)
190
+ return configuration.adapter.call(method: method, url: url,
191
+ headers: headers, body: body, timeout: timeout)
192
+ end
193
+
194
+ @connection.run_request(method, url, body, headers) do |request|
195
+ request.options.timeout = timeout if timeout
196
+ end
197
+ end
198
+
199
+ def build_response(raw_response, request, operation)
200
+ status = Integer(raw_response.status)
201
+ headers = stringify_keys(raw_response.headers).transform_keys(&:downcase)
202
+ raw_body = raw_response.body
203
+ content_type = headers.fetch("content-type", "").downcase
204
+ parsed = parse_body(raw_body, content_type, status)
205
+ schema = operation&.response_schema(status, content_type)
206
+ body = schema ? SchemaRegistry.coerce(schema, parsed) : parsed
207
+ Response.new(status: status, headers: headers, body: body, raw_body: raw_body, request: request)
208
+ end
209
+
210
+ def parse_body(body, content_type, status)
211
+ return nil if status == 204 || body.nil? || body == ""
212
+ return body unless content_type.include?("json")
213
+
214
+ JSON.parse(body)
215
+ rescue JSON::ParserError
216
+ body
217
+ end
218
+
219
+ def raise_api_error(response)
220
+ klass = ERROR_CLASSES[response.status] || (response.status >= 500 ? ServerError : APIError)
221
+ details = Redactor.redact_object(response.body)
222
+ message = error_message(response.status, details, response.request_id)
223
+ raise klass.new(message, status: response.status, headers: safe_headers(response.headers), details: details,
224
+ request_id: response.request_id, request: response.request)
225
+ end
226
+
227
+ def error_message(status, details, request_id)
228
+ server_message = details.is_a?(Hash) && (details["error"] || details["message"])
229
+ ["Fly.io API request failed (HTTP #{status})", server_message,
230
+ ("request_id=#{request_id}" if request_id)].compact.join(": ")
231
+ end
232
+
233
+ def retry_status?(status, method, retry_unsafe)
234
+ RETRYABLE_STATUSES.include?(Integer(status)) && retry_allowed?(method, retry_unsafe)
235
+ end
236
+
237
+ def retry_allowed?(method, retry_unsafe)
238
+ SAFE_METHODS.include?(method) || retry_unsafe == true || (retry_unsafe.nil? && configuration.retry_unsafe)
239
+ end
240
+
241
+ def retry_delay(attempt, headers)
242
+ retry_after = stringify_keys(headers).find { |key, _| key.downcase == "retry-after" }&.last
243
+ parsed = parse_retry_after(retry_after)
244
+ return [parsed, configuration.max_retry_interval].min if parsed
245
+
246
+ base = [configuration.base_retry_interval * (2**attempt), configuration.max_retry_interval].min
247
+ [base + (base * configuration.retry_jitter * configuration.random.rand), configuration.max_retry_interval].min
248
+ end
249
+
250
+ def parse_retry_after(value)
251
+ return if value.nil?
252
+ return [Float(value), 0].max if value.to_s.match?(/\A\d+(?:\.\d+)?\z/)
253
+
254
+ [Time.httpdate(value.to_s) - Time.now, 0].max
255
+ rescue ::ArgumentError
256
+ nil
257
+ end
258
+
259
+ def safe_request_metadata(method, path, query, headers, body, operation)
260
+ {
261
+ method: method,
262
+ path: path,
263
+ query: Redactor.redact_object(query),
264
+ headers: safe_headers(headers),
265
+ body: Redactor.redact_object(body.equal?(FlyIO::UNSET) ? nil : serialize(body)),
266
+ operation_id: operation&.operation_id
267
+ }.freeze
268
+ end
269
+
270
+ def safe_headers(headers)
271
+ Redactor.redact_object(stringify_keys(headers))
272
+ end
273
+
274
+ def stringify_keys(hash)
275
+ hash.to_h.transform_keys(&:to_s)
276
+ end
277
+
278
+ def log(level, message, metadata)
279
+ configuration.logger&.public_send(level, "#{message} #{metadata.inspect}")
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlyIO
4
+ VERSION = "0.1.0"
5
+ end
data/lib/fly_io.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fly_io/version"
4
+ require_relative "fly_io/errors"
5
+ require_relative "fly_io/redactor"
6
+ require_relative "fly_io/configuration"
7
+ require_relative "fly_io/model"
8
+ require_relative "fly_io/schema_registry"
9
+ require_relative "fly_io/schema_validator"
10
+ require_relative "fly_io/response"
11
+ require_relative "fly_io/operation_registry"
12
+ require_relative "fly_io/transport"
13
+ require_relative "fly_io/resources/base"
14
+ require_relative "fly_io/resources"
15
+ require_relative "fly_io/client"
16
+ require_relative "fly_io/graphql_client"
17
+
18
+ module FlyIO
19
+ UNSET = Object.new.freeze
20
+
21
+ SchemaRegistry.install_model_constants!
22
+ Resources.install!
23
+ end
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "digest"
6
+
7
+ ROOT = File.expand_path("..", __dir__)
8
+ abort unless system(RbConfig.ruby, File.join(ROOT, "script/generate_api"), "--check")
9
+
10
+ additional = JSON.parse(File.read(File.join(ROOT, "lib/fly_io/generated/additional_operations.json")))
11
+ abort "Network Policies coverage drift" unless additional.fetch("operations").length == 3
12
+ puts "Network Policies REST coverage OK: 3 documentation-derived operations (response contracts undocumented upstream)"
13
+ network_source_hash = Digest::SHA256.file(File.join(ROOT, "contracts/sources/network-policies.html.markerb")).hexdigest
14
+ abort "Network Policies source drift" unless network_source_hash == additional.fetch("generated_from").fetch("source_sha256")
15
+
16
+ prometheus = JSON.parse(File.read(File.join(ROOT, "lib/fly_io/generated/prometheus_operations.json")))
17
+ abort "Prometheus endpoint-family coverage drift" unless prometheus.fetch("operations").length == 7
18
+ puts "Prometheus API coverage OK: 7 documented endpoint families (standard contract delegated upstream)"
19
+ metrics_source_hash = Digest::SHA256.file(File.join(ROOT, "contracts/sources/metrics.html.md")).hexdigest
20
+ abort "Prometheus source drift" unless metrics_source_hash == prometheus.fetch("generated_from").fetch("source_sha256")
21
+
22
+ graphql_path = File.join(ROOT, "lib/fly_io/generated/graphql_operations.json")
23
+ graphql = JSON.parse(File.read(graphql_path))
24
+ operations = graphql.fetch("operations")
25
+ names = operations.map { |operation| operation.fetch("name") }
26
+ abort "duplicate GraphQL operation names" unless names.uniq.length == names.length
27
+ operations.each do |operation|
28
+ abort "GraphQL operation #{operation['name']} lacks a document" if operation.fetch("document").strip.empty?
29
+ abort "GraphQL operation #{operation['name']} is not experimental" unless operation.fetch("stability") == "experimental"
30
+ end
31
+ counts = graphql.fetch("counts")
32
+ abort "GraphQL inventory count drift" unless counts == {
33
+ "flyctl_named" => 27, "flyctl_ad_hoc" => 2, "fly_go" => 85, "official_examples" => 3, "total" => 117
34
+ }
35
+ schema_path = File.join(ROOT, "contracts/graphql/schema.graphql")
36
+ schema_hash = Digest::SHA256.file(schema_path).hexdigest
37
+ abort "GraphQL evidence schema drift" unless schema_hash == graphql.fetch("schema").fetch("sha256")
38
+ schema_document = File.read(schema_path)
39
+ root_fields = {"query" => "Queries", "mutation" => "Mutations"}.to_h do |kind, type_name|
40
+ block = schema_document.match(/^type #{type_name} \{$(.*?)^\}/m)&.captures&.first
41
+ abort "GraphQL evidence schema lacks #{type_name}" unless block
42
+ [kind, block.scan(/^ ([A-Za-z_]\w*)\s*(?:\(|:)/).flatten]
43
+ end
44
+ abort "GraphQL Query root count drift" unless root_fields.fetch("query").length == 39
45
+ abort "GraphQL Mutation root count drift" unless root_fields.fetch("mutation").length == 141
46
+ operations.each do |operation|
47
+ missing = operation.fetch("root_fields") - root_fields.fetch(operation.fetch("operation_type"))
48
+ abort "GraphQL operation #{operation['name']} has unknown roots: #{missing.join(', ')}" unless missing.empty?
49
+ end
50
+ graphql_source = JSON.parse(File.read(File.join(ROOT, "contracts/graphql/source.json")))
51
+ {
52
+ "flyctl_operations.json" => graphql_source.dig("flyctl", "inventory_sha256"),
53
+ "fly_go_operations.json" => graphql_source.dig("fly_go", "inventory_sha256"),
54
+ "official_examples.json" => graphql_source.dig("official_examples", "inventory_sha256"),
55
+ "flyctl_named_operations.graphql" => graphql_source.dig("flyctl", "named_documents_sha256")
56
+ }.each do |name, expected|
57
+ actual = Digest::SHA256.file(File.join(ROOT, "contracts/graphql", name)).hexdigest
58
+ abort "GraphQL source drift: #{name}" unless actual == expected
59
+ end
60
+ puts "GraphQL audit OK: 29 flyctl + 85 fly-go + 3 official experimental operations; raw documents supported"
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ url = URI("https://docs.machines.dev/openapi.json")
9
+ response = Net::HTTP.get_response(url)
10
+ abort "OpenAPI fetch failed: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
11
+
12
+ snapshot = File.binread(File.expand_path("../contracts/machines/openapi.json", __dir__))
13
+ expected = Digest::SHA256.hexdigest(snapshot)
14
+ actual = Digest::SHA256.hexdigest(response.body)
15
+ abort "upstream OpenAPI drift: committed=#{expected} upstream=#{actual}" unless expected == actual
16
+
17
+ puts "OpenAPI upstream matches committed snapshot: sha256=#{actual} " \
18
+ "etag=#{response['etag'].inspect} last-modified=#{response['last-modified'].inspect}"