latitude-api 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +283 -0
  5. data/Rakefile +13 -0
  6. data/lib/latitude/api/api_object.rb +105 -0
  7. data/lib/latitude/api/api_resource.rb +260 -0
  8. data/lib/latitude/api/attributes.rb +64 -0
  9. data/lib/latitude/api/client.rb +80 -0
  10. data/lib/latitude/api/configuration.rb +88 -0
  11. data/lib/latitude/api/errors.rb +134 -0
  12. data/lib/latitude/api/json_api.rb +40 -0
  13. data/lib/latitude/api/list_object.rb +129 -0
  14. data/lib/latitude/api/objects.rb +216 -0
  15. data/lib/latitude/api/operations/action.rb +41 -0
  16. data/lib/latitude/api/operations/create.rb +22 -0
  17. data/lib/latitude/api/operations/delete.rb +22 -0
  18. data/lib/latitude/api/operations/list.rb +54 -0
  19. data/lib/latitude/api/operations/nested.rb +53 -0
  20. data/lib/latitude/api/operations/retrieve.rb +22 -0
  21. data/lib/latitude/api/operations/update.rb +22 -0
  22. data/lib/latitude/api/request_executor.rb +217 -0
  23. data/lib/latitude/api/request_options.rb +86 -0
  24. data/lib/latitude/api/request_params.rb +70 -0
  25. data/lib/latitude/api/resources/api_key.rb +61 -0
  26. data/lib/latitude/api/resources/billing_usage.rb +51 -0
  27. data/lib/latitude/api/resources/elastic_ip.rb +96 -0
  28. data/lib/latitude/api/resources/event.rb +55 -0
  29. data/lib/latitude/api/resources/firewall.rb +129 -0
  30. data/lib/latitude/api/resources/ip.rb +110 -0
  31. data/lib/latitude/api/resources/kubernetes_cluster.rb +184 -0
  32. data/lib/latitude/api/resources/plan.rb +658 -0
  33. data/lib/latitude/api/resources/project.rb +179 -0
  34. data/lib/latitude/api/resources/region.rb +41 -0
  35. data/lib/latitude/api/resources/role.rb +20 -0
  36. data/lib/latitude/api/resources/server.rb +251 -0
  37. data/lib/latitude/api/resources/ssh_key.rb +44 -0
  38. data/lib/latitude/api/resources/storage_filesystem.rb +36 -0
  39. data/lib/latitude/api/resources/storage_object.rb +85 -0
  40. data/lib/latitude/api/resources/storage_volume.rb +64 -0
  41. data/lib/latitude/api/resources/tag.rb +34 -0
  42. data/lib/latitude/api/resources/team.rb +169 -0
  43. data/lib/latitude/api/resources/traffic.rb +215 -0
  44. data/lib/latitude/api/resources/user_data.rb +38 -0
  45. data/lib/latitude/api/resources/user_profile.rb +51 -0
  46. data/lib/latitude/api/resources/user_team.rb +90 -0
  47. data/lib/latitude/api/resources/virtual_machine.rb +146 -0
  48. data/lib/latitude/api/resources/virtual_network.rb +162 -0
  49. data/lib/latitude/api/resources/vpn_session.rb +80 -0
  50. data/lib/latitude/api/singleton_api_resource.rb +22 -0
  51. data/lib/latitude/api/util.rb +64 -0
  52. data/lib/latitude/api/version.rb +7 -0
  53. data/lib/latitude/api.rb +147 -0
  54. data/lib/latitude/metadata/errors.rb +18 -0
  55. data/lib/latitude/metadata/session.rb +109 -0
  56. data/lib/latitude/metadata.rb +43 -0
  57. data/lib/latitude.rb +4 -0
  58. metadata +132 -0
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ module Attributes
6
+ Spec = Struct.new(:name, :type, :read_only, keyword_init: true)
7
+
8
+ # Attribute names that collide with core Ruby/internal methods. For these we
9
+ # still record the spec (so the value is wrapped and documented) but skip the
10
+ # generated accessor; read them via #[] instead.
11
+ RESERVED_NAMES = %w[method class send __send__ object_id __id__ hash dup clone
12
+ freeze frozen? instance_variable_get instance_variable_set
13
+ respond_to? is_a? kind_of? equal? itself].freeze
14
+
15
+ module ClassMethods
16
+ def attribute(name, type = :string, **opts)
17
+ name = name.to_s
18
+ read_only = opts.fetch(:read_only, false)
19
+ attribute_specs[name] = Spec.new(name: name, type: type, read_only: read_only)
20
+
21
+ unless RESERVED_NAMES.include?(name)
22
+ define_method(name) { read_attribute(name) }
23
+ define_method("#{name}=") { |value| write_attribute(name, value) } unless read_only
24
+ end
25
+
26
+ attribute_specs[name]
27
+ end
28
+
29
+ def attribute_specs
30
+ @attribute_specs ||= (superclass.respond_to?(:attribute_specs) ? superclass.attribute_specs : {}).dup
31
+ end
32
+ end
33
+
34
+ module InstanceMethods
35
+ def attribute_spec(name)
36
+ self.class.respond_to?(:attribute_specs) ? self.class.attribute_specs[name.to_s] : nil
37
+ end
38
+
39
+ def read_attribute(name)
40
+ @values[name.to_s]
41
+ end
42
+
43
+ def wrap_attribute(key, value)
44
+ type = attribute_spec(key)&.type
45
+
46
+ if type.is_a?(Array)
47
+ klass = type.first
48
+ return Array(value).map { |v| v.is_a?(Hash) ? klass.new(v) : v } if object_class?(klass)
49
+ elsif object_class?(type)
50
+ return value.is_a?(Hash) ? type.new(value) : value
51
+ end
52
+
53
+ APIObject.wrap(value)
54
+ end
55
+
56
+ private
57
+
58
+ def object_class?(type)
59
+ type.is_a?(Class) && type <= APIObject
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ class Client
5
+ RESOURCE_ACCESSORS = {
6
+ servers: "Latitude::API::Resources::Server",
7
+ regions: "Latitude::API::Resources::Region",
8
+ roles: "Latitude::API::Resources::Role",
9
+ events: "Latitude::API::Resources::Event",
10
+ ips: "Latitude::API::Resources::IP",
11
+ plans: "Latitude::API::Resources::Plan",
12
+ billing_usage: "Latitude::API::Resources::BillingUsage",
13
+ traffic: "Latitude::API::Resources::Traffic",
14
+ user_profile: "Latitude::API::Resources::UserProfile",
15
+ user_teams: "Latitude::API::Resources::UserTeam",
16
+ projects: "Latitude::API::Resources::Project",
17
+ ssh_keys: "Latitude::API::Resources::SSHKey",
18
+ user_data: "Latitude::API::Resources::UserData",
19
+ tags: "Latitude::API::Resources::Tag",
20
+ team: "Latitude::API::Resources::Team",
21
+ api_keys: "Latitude::API::Resources::APIKey",
22
+ elastic_ips: "Latitude::API::Resources::ElasticIP",
23
+ virtual_networks: "Latitude::API::Resources::VirtualNetwork",
24
+ firewalls: "Latitude::API::Resources::Firewall",
25
+ vpn_sessions: "Latitude::API::Resources::VPNSession",
26
+ storage_filesystems: "Latitude::API::Resources::Storage::Filesystem",
27
+ storage_volumes: "Latitude::API::Resources::Storage::Volume",
28
+ storage_objects: "Latitude::API::Resources::Storage::Object",
29
+ virtual_machines: "Latitude::API::Resources::VirtualMachine",
30
+ kubernetes_clusters: "Latitude::API::Resources::KubernetesCluster",
31
+ }.freeze
32
+
33
+ attr_reader :config
34
+
35
+ def initialize(**overrides)
36
+ @config = Latitude.config.dup_with(**overrides)
37
+ @accessors = {}
38
+ end
39
+
40
+ def configure
41
+ yield @config
42
+ @config
43
+ end
44
+
45
+ def with_config(&blk)
46
+ Latitude::API::APIResource.with_config(@config, &blk)
47
+ end
48
+
49
+ RESOURCE_ACCESSORS.each do |name, class_name|
50
+ define_method(name) do
51
+ @accessors[name] ||= ResourceFacade.new(class_name: class_name, config: @config)
52
+ end
53
+ end
54
+
55
+ class ResourceFacade
56
+ def initialize(class_name:, config:)
57
+ @class_name = class_name
58
+ @config = config
59
+ end
60
+
61
+ def resource_class
62
+ @resource_class ||= ::Object.const_get(@class_name)
63
+ end
64
+
65
+ def method_missing(name, *args, **kwargs, &blk)
66
+ if resource_class.respond_to?(name)
67
+ Latitude::API::APIResource.with_config(@config) do
68
+ resource_class.public_send(name, *args, **kwargs, &blk)
69
+ end
70
+ else
71
+ super
72
+ end
73
+ end
74
+
75
+ def respond_to_missing?(name, include_private = false)
76
+ resource_class.respond_to?(name) || super
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ class Configuration
6
+ DEFAULT_API_BASE = "https://api.latitude.sh"
7
+ DEFAULT_OPEN_TIMEOUT = 30
8
+ DEFAULT_READ_TIMEOUT = 80
9
+ DEFAULT_WRITE_TIMEOUT = 30
10
+ DEFAULT_MAX_NETWORK_RETRIES = 0
11
+ DEFAULT_INITIAL_NETWORK_RETRY_DELAY = 0.5
12
+ DEFAULT_MAX_NETWORK_RETRY_DELAY = 5.0
13
+ DEFAULT_SENSITIVE_FIELDS = %w[
14
+ authorization
15
+ public_key
16
+ private_key
17
+ password
18
+ api_key
19
+ token
20
+ kubeconfig
21
+ credentials
22
+ ipmi_password
23
+ ].freeze
24
+
25
+ attr_accessor :api_key,
26
+ :api_base,
27
+ :api_version,
28
+ :open_timeout,
29
+ :read_timeout,
30
+ :write_timeout,
31
+ :max_network_retries,
32
+ :initial_network_retry_delay,
33
+ :max_network_retry_delay,
34
+ :proxy,
35
+ :verify_ssl_certs,
36
+ :ca_bundle_path,
37
+ :ca_store,
38
+ :logger,
39
+ :log_level,
40
+ :app_info,
41
+ :enable_telemetry,
42
+ :sensitive_fields
43
+
44
+ def initialize
45
+ @api_key = nil
46
+ @api_base = DEFAULT_API_BASE
47
+ @api_version = nil
48
+ @open_timeout = DEFAULT_OPEN_TIMEOUT
49
+ @read_timeout = DEFAULT_READ_TIMEOUT
50
+ @write_timeout = DEFAULT_WRITE_TIMEOUT
51
+ @max_network_retries = DEFAULT_MAX_NETWORK_RETRIES
52
+ @initial_network_retry_delay = DEFAULT_INITIAL_NETWORK_RETRY_DELAY
53
+ @max_network_retry_delay = DEFAULT_MAX_NETWORK_RETRY_DELAY
54
+ @proxy = nil
55
+ @verify_ssl_certs = true
56
+ @ca_bundle_path = nil
57
+ @ca_store = nil
58
+ @logger = nil
59
+ @log_level = :info
60
+ @app_info = nil
61
+ @enable_telemetry = true
62
+ @sensitive_fields = DEFAULT_SENSITIVE_FIELDS.dup
63
+ end
64
+
65
+ def dup_with(**overrides)
66
+ copy = Configuration.new
67
+ instance_variables.each do |ivar|
68
+ copy.instance_variable_set(ivar, instance_variable_get(ivar))
69
+ end
70
+ overrides.each { |k, v| copy.public_send("#{k}=", v) }
71
+ copy
72
+ end
73
+
74
+ def api_base_uri
75
+ URI.parse(@api_base)
76
+ rescue URI::InvalidURIError => e
77
+ raise ConfigurationError, "api_base is not a valid URL: #{@api_base.inspect} (#{e.message})"
78
+ end
79
+
80
+ def validate_api_key!
81
+ return unless @api_key.nil? || @api_key.to_s.empty?
82
+
83
+ raise ConfigurationError,
84
+ "No API key provided. Set it via Latitude.api_key = '...' or pass api_key: in request options."
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class ConnectionError < Error
9
+ attr_reader :cause_error
10
+
11
+ def initialize(message = nil, cause: nil)
12
+ super(message)
13
+ @cause_error = cause
14
+ end
15
+ end
16
+
17
+ class APIError < Error
18
+ class Entry
19
+ attr_reader :status, :code, :title, :detail, :meta, :raw
20
+
21
+ def initialize(raw)
22
+ @raw = raw || {}
23
+ @status = @raw["status"]
24
+ @code = @raw["code"]
25
+ @title = @raw["title"]
26
+ @detail = @raw["detail"] || @raw["message"]
27
+ @meta = @raw["meta"] || {}
28
+ end
29
+
30
+ def to_s
31
+ return @detail.to_s if @title.nil? || @title.empty?
32
+ return @title.to_s if @detail.nil? || @detail.empty?
33
+
34
+ "#{@title}: #{@detail}"
35
+ end
36
+
37
+ def to_h
38
+ @raw.dup
39
+ end
40
+ end
41
+
42
+ attr_reader :http_status, :http_headers, :http_body, :json_body, :request_id, :entries
43
+
44
+ def initialize(message = nil, http_status: nil, http_headers: nil, http_body: nil, json_body: nil, request_id: nil)
45
+ @http_status = http_status
46
+ @http_headers = http_headers || {}
47
+ @http_body = http_body
48
+ @json_body = json_body
49
+ @request_id = request_id
50
+ @entries = Array(json_body.is_a?(Hash) ? json_body["errors"] : nil).map { |h| Entry.new(h) }
51
+ super(message || default_message)
52
+ end
53
+
54
+ alias errors entries
55
+
56
+ def code
57
+ entries.first&.code
58
+ end
59
+
60
+ def title
61
+ entries.first&.title
62
+ end
63
+
64
+ def detail
65
+ entries.first&.detail
66
+ end
67
+
68
+ private
69
+
70
+ def default_message
71
+ first = entries.first
72
+ return first.to_s if first && !first.to_s.empty?
73
+ return "HTTP #{http_status}" if http_status
74
+
75
+ "API error"
76
+ end
77
+ end
78
+
79
+ class BadRequestError < APIError; end
80
+ class AuthenticationError < APIError; end
81
+
82
+ class PermissionError < APIError
83
+ READ_ONLY_CODES = %w[
84
+ READ_ONLY_API_KEY
85
+ READ_ONLY_KEY
86
+ read_only_api_key
87
+ read_only
88
+ ].freeze
89
+
90
+ def read_only_key?
91
+ entries.any? { |e| READ_ONLY_CODES.include?(e.code.to_s) }
92
+ end
93
+
94
+ def feature_not_enabled?
95
+ entries.any? { |e| e.code.to_s == "FEATURE_NOT_ENABLED" }
96
+ end
97
+ end
98
+
99
+ class NotFoundError < APIError; end
100
+ class ConflictError < APIError; end
101
+ class UnprocessableEntityError < APIError; end
102
+
103
+ class RateLimitError < APIError
104
+ def retry_after
105
+ meta_value = entries.map { |e| e.meta["retry_after"] }.compact.first
106
+ return Integer(meta_value) if meta_value
107
+
108
+ header_value = http_headers["retry-after"] || http_headers["Retry-After"]
109
+ return Integer(header_value) if header_value && header_value.to_s.match?(/\A\d+\z/)
110
+
111
+ nil
112
+ end
113
+ end
114
+
115
+ class ServerError < APIError; end
116
+
117
+ module API
118
+ class << self
119
+ def error_class_for_status(status)
120
+ case status
121
+ when 400 then BadRequestError
122
+ when 401 then AuthenticationError
123
+ when 403 then PermissionError
124
+ when 404 then NotFoundError
125
+ when 409 then ConflictError
126
+ when 422 then UnprocessableEntityError
127
+ when 429 then RateLimitError
128
+ when 500..599 then ServerError
129
+ else APIError
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ module JSONAPI
6
+ MEDIA_TYPE = "application/vnd.api+json"
7
+
8
+ module_function
9
+
10
+ def envelope(type:, attributes: nil, id: nil, relationships: nil)
11
+ data = { "type" => type.to_s }
12
+ data["id"] = id.to_s if id
13
+ data["attributes"] = Util.stringify_keys(attributes) if attributes
14
+ data["relationships"] = Util.stringify_keys(relationships) if relationships
15
+ { "data" => data }
16
+ end
17
+
18
+ def encode(type:, attributes: nil, id: nil, relationships: nil)
19
+ JSON.generate(envelope(type: type, attributes: attributes, id: id, relationships: relationships))
20
+ end
21
+
22
+ def parse(body)
23
+ return nil if body.nil? || body.empty?
24
+
25
+ JSON.parse(body)
26
+ rescue JSON::ParserError => e
27
+ raise APIError.new(
28
+ "Invalid JSON response from server: #{e.message}",
29
+ http_body: body,
30
+ )
31
+ end
32
+
33
+ def extract_errors(parsed)
34
+ return [] unless parsed.is_a?(Hash)
35
+
36
+ Array(parsed["errors"])
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ class ListObject
6
+ include Enumerable
7
+
8
+ attr_reader :data, :meta, :links, :request_params, :resource_class, :request_opts, :path_params
9
+
10
+ def initialize(resource_class:, parsed:, request_params: {}, request_opts: {}, path_params: {})
11
+ @resource_class = resource_class
12
+ @meta = parsed.is_a?(Hash) ? (parsed["meta"] || {}) : {}
13
+ @links = parsed.is_a?(Hash) ? (parsed["links"] || {}) : {}
14
+ raw_data = parsed.is_a?(Hash) ? Array(parsed["data"]) : []
15
+ @data = raw_data.map do |row|
16
+ resource_class.new(row, path_params: path_params, opts: request_opts)
17
+ end
18
+ @request_params = request_params || {}
19
+ @request_opts = request_opts || {}
20
+ @path_params = path_params || {}
21
+ end
22
+
23
+ def each(&blk)
24
+ @data.each(&blk)
25
+ end
26
+
27
+ def size
28
+ @data.size
29
+ end
30
+
31
+ alias length size
32
+
33
+ def empty?
34
+ @data.empty?
35
+ end
36
+
37
+ def [](index)
38
+ @data[index]
39
+ end
40
+
41
+ def first
42
+ @data.first
43
+ end
44
+
45
+ def last
46
+ @data.last
47
+ end
48
+
49
+ def to_a
50
+ @data.dup
51
+ end
52
+
53
+ def page_size
54
+ value = page_params["size"] || page_params[:size]
55
+ (value || 20).to_i
56
+ end
57
+
58
+ def page_number
59
+ value = page_params["number"] || page_params[:number]
60
+ (value || 1).to_i
61
+ end
62
+
63
+ def has_more?
64
+ return !!@links["next"] if @links && @links["next"]
65
+ return page_number < @meta["total_pages"].to_i if @meta && @meta["total_pages"]
66
+
67
+ @data.size >= page_size
68
+ end
69
+
70
+ def next_page(opts = {})
71
+ return empty_page unless has_more?
72
+
73
+ params = deep_dup(@request_params)
74
+ params[:page] ||= {}
75
+ params[:page][:number] = page_number + 1
76
+ params[:page][:size] = page_size
77
+ fetch(params, opts)
78
+ end
79
+
80
+ def previous_page(opts = {})
81
+ return empty_page if page_number <= 1
82
+
83
+ params = deep_dup(@request_params)
84
+ params[:page] ||= {}
85
+ params[:page][:number] = page_number - 1
86
+ params[:page][:size] = page_size
87
+ fetch(params, opts)
88
+ end
89
+
90
+ def auto_paging_each(&blk)
91
+ page = self
92
+ loop do
93
+ page.each(&blk)
94
+ break unless page.has_more?
95
+
96
+ page = page.next_page
97
+ end
98
+ self
99
+ end
100
+
101
+ private
102
+
103
+ def page_params
104
+ (@request_params[:page] || @request_params["page"] || {})
105
+ end
106
+
107
+ def fetch(params, opts)
108
+ merged_opts = @request_opts.merge(opts || {})
109
+ params = params.merge(_path_params: @path_params)
110
+ @resource_class.list(params, merged_opts)
111
+ end
112
+
113
+ def empty_page
114
+ self.class.new(resource_class: @resource_class, parsed: { "data" => [] },
115
+ request_params: @request_params, request_opts: @request_opts, path_params: @path_params)
116
+ end
117
+
118
+ def deep_dup(hash)
119
+ hash.each_with_object({}) do |(k, v), out|
120
+ out[k] = case v
121
+ when Hash then deep_dup(v)
122
+ when Array then v.dup
123
+ else v
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end