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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ad7537135ba5a563aae3317a22e9ce2ec10d7b875ae0a2ffc1b812b9d20441cb
4
+ data.tar.gz: 9ec8e48ca3706b3d8c8ede062ae488479e292291b1ccc687a86df23d0fa70a5d
5
+ SHA512:
6
+ metadata.gz: 5db971e47af3606101fd255f40ab3050cfad19336055366f2d4c76f91d48b729e329cd6ed85407bc6336ec114135ff01d2cdd90cc5df388f18f58d656035486d
7
+ data.tar.gz: 39389c048065f96391f13ebeca25997a8662d6a43feff75476177fd8321f5d245c521743e000d9122b13e1026d3e4280bdd58ac1dfe810ba66776d7359dc55a9
data/.yardopts ADDED
@@ -0,0 +1,10 @@
1
+ --markup markdown
2
+ --main README.md
3
+ --title latitude-api
4
+ --no-private
5
+ --protected
6
+ lib/**/*.rb
7
+ -
8
+ README.md
9
+ CHANGELOG.md
10
+ LICENSE.txt
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Josh Brody
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,283 @@
1
+ # latitude-api
2
+
3
+ A Ruby client for the [Latitude.sh](https://www.latitude.sh) REST API. Covers servers, projects, virtual machines, Kubernetes clusters, storage, networking, firewalls, elastic IPs, SSH keys, teams, and more. Ships with a client for the on-instance metadata service.
4
+
5
+ Requires Ruby 3.0+. Fork it if you need lower support—should probably be fine.
6
+
7
+ ## Install
8
+
9
+ ```ruby
10
+ gem "latitude-api"
11
+ ```
12
+
13
+ ```bash
14
+ bundle add latitude-api
15
+ ```
16
+
17
+ ## Quickstart
18
+
19
+ ```ruby
20
+ require "latitude"
21
+
22
+ Latitude.api_key = ENV.fetch("LATITUDE_API_KEY")
23
+
24
+ server = Latitude::Server.create(
25
+ project: "proj_abc",
26
+ plan: "c2-small-x86",
27
+ site: "ASH",
28
+ operating_system: "ubuntu_22_04_x64_lts",
29
+ hostname: "web-01",
30
+ )
31
+
32
+ server.id # => "sv_..."
33
+ server.hostname # => "web-01"
34
+ server.status # => "off"
35
+ server.plan.slug # => "c2-small-x86"
36
+
37
+ server.reboot
38
+ server.lock
39
+ server.delete
40
+ ```
41
+
42
+ ## Configuration
43
+
44
+ ```ruby
45
+ Latitude.configure do |c|
46
+ c.api_key = ENV.fetch("LATITUDE_API_KEY")
47
+ c.api_base = "https://api.latitude.sh" # default
48
+ c.api_version = "2023-06-01" # optional, sent as API-Version header
49
+ c.open_timeout = 30
50
+ c.read_timeout = 80
51
+ c.max_network_retries = 2 # retries 429/500/502/503/504 with backoff
52
+ c.logger = Logger.new($stdout)
53
+ end
54
+ ```
55
+
56
+ Per-request overrides live in the final hash argument of any call:
57
+
58
+ ```ruby
59
+ Latitude::Server.create(params, {
60
+ api_key: "lat_tenant_specific",
61
+ api_version: "2023-06-01",
62
+ idempotency_key: "svr-create-123",
63
+ open_timeout: 5,
64
+ })
65
+ ```
66
+
67
+ So, for multi-tenant apps, use a client:
68
+
69
+ ```ruby
70
+ client = Latitude::Client.new(api_key: "lat_tenant_A")
71
+ client.servers.list
72
+ client.projects.retrieve("proj_abc")
73
+ ```
74
+
75
+ ## Resources
76
+
77
+ All resources are under `Latitude::`:
78
+
79
+ | Resource | Class | Endpoint |
80
+ |---|---|---|
81
+ | Servers | `Latitude::Server` | `/servers` |
82
+ | Projects | `Latitude::Project` | `/projects` |
83
+ | Project SSH keys | `Latitude::Project::SSHKey` | `/projects/:project_id/ssh_keys` |
84
+ | Project user data | `Latitude::Project::UserData` | `/projects/:project_id/user_data` |
85
+ | SSH keys | `Latitude::SSHKey` | `/ssh_keys` |
86
+ | User data | `Latitude::UserData` | `/user_data` |
87
+ | Tags | `Latitude::Tag` | `/tags` |
88
+ | Teams | `Latitude::Team` | `/team` |
89
+ | Team members | `Latitude::Team::Member` | `/team/members` |
90
+ | API keys | `Latitude::APIKey` | `/auth/api_keys` |
91
+ | Elastic IPs | `Latitude::ElasticIP` | `/elastic_ips` |
92
+ | VLANs | `Latitude::VirtualNetwork` | `/virtual_networks` |
93
+ | VLAN assignments | `Latitude::VirtualNetwork::Assignment` | `/virtual_networks/assignments` |
94
+ | Firewalls | `Latitude::Firewall` | `/firewalls` |
95
+ | Firewall assignments | `Latitude::Firewall::Assignment` | `/firewalls/:firewall_id/assignments` |
96
+ | VPN sessions | `Latitude::VPNSession` | `/vpn_sessions` |
97
+ | Filesystems | `Latitude::Storage::Filesystem` | `/storage/filesystems` |
98
+ | Volumes | `Latitude::Storage::Volume` | `/storage/volumes` |
99
+ | Object storage | `Latitude::Storage::Object` | `/storage/objects` |
100
+ | Virtual machines | `Latitude::VirtualMachine` | `/virtual_machines` |
101
+ | Kubernetes clusters | `Latitude::KubernetesCluster` | `/kubernetes_clusters` |
102
+ | Plans | `Latitude::Plan` (+ `OperatingSystem`, `Bandwidth`, `Storage`, `VirtualMachine`) | `/plans` |
103
+ | Regions | `Latitude::Region` | `/regions` |
104
+ | Roles | `Latitude::Role` | `/roles` |
105
+ | Events | `Latitude::Event` | `/events` |
106
+ | IPs | `Latitude::IP` | `/ips` |
107
+ | Billing usage | `Latitude::BillingUsage` | `/billing/usage` |
108
+ | Traffic | `Latitude::Traffic` (+ `#quota`) | `/traffic` |
109
+ | User profile | `Latitude::User::Profile` | `/user/profile` |
110
+ | User teams | `Latitude::User::Team` | `/user/teams` |
111
+
112
+ Every full-CRUD resource supports a spooky-familiar operation surface:
113
+
114
+ ```ruby
115
+ Latitude::Project.list(filter: { name: { match: "prod" } }, sort: "-created_at", page: { size: 50 })
116
+ Latitude::Project.retrieve("proj_abc")
117
+ Latitude::Project.create(name: "Payments", provisioning_type: "on_demand")
118
+ Latitude::Project.update("proj_abc", name: "Payments 2.0")
119
+ Latitude::Project.delete("proj_abc")
120
+
121
+ project = Latitude::Project.retrieve("proj_abc")
122
+ project.name = "renamed"
123
+ project.save
124
+ project.delete
125
+ ```
126
+
127
+ ### Filters, sort, pagination
128
+
129
+ Follows the [Latitude API filter grammar](https://docs.latitude.sh):
130
+
131
+ ```ruby
132
+ Latitude::Server.list(
133
+ filter: {
134
+ project: "proj_abc",
135
+ hostname: { prefix: "web" },
136
+ ram: { gte: 32 },
137
+ tags: ["tag_prod", "tag_db"],
138
+ },
139
+ sort: "-created_at",
140
+ page: { size: 50, number: 1 },
141
+ extra_fields: { servers: "credentials" },
142
+ )
143
+ ```
144
+
145
+ Auto-paginate:
146
+
147
+ ```ruby
148
+ Latitude::Server.list(filter: { project: "proj_abc" }).auto_paging_each do |server|
149
+ # ...
150
+ end
151
+
152
+ # or materialize the whole collection
153
+ Latitude::Server.all(filter: { project: "proj_abc" })
154
+ ```
155
+
156
+ ### Server power and maintenance actions
157
+
158
+ ```ruby
159
+ server = Latitude::Server.retrieve("sv_abc")
160
+
161
+ server.reboot
162
+ server.power_on
163
+ server.power_off
164
+ server.reset
165
+
166
+ server.lock
167
+ server.unlock
168
+
169
+ server.rescue_mode
170
+ server.exit_rescue_mode
171
+
172
+ server.schedule_deletion(at: "2026-05-01T00:00:00Z", reason: "decommission")
173
+ server.unschedule_deletion
174
+
175
+ server.reinstall(
176
+ operating_system: "ubuntu_22_04_x64_lts",
177
+ hostname: "web-01",
178
+ ssh_keys: ["ssh_abc"],
179
+ )
180
+
181
+ server.deploy_config
182
+ server.update_deploy_config(hostname: "web-02")
183
+
184
+ server.create_out_of_band_connection(port: 22)
185
+ server.list_out_of_band_connections
186
+ ```
187
+
188
+ ### Nested resources
189
+
190
+ ```ruby
191
+ Latitude::Project::SSHKey.list(project_id: "proj_abc")
192
+ Latitude::Project::SSHKey.create(project_id: "proj_abc", name: "bastion", public_key: "ssh-rsa ...")
193
+
194
+ project = Latitude::Project.retrieve("proj_abc")
195
+ project.ssh_keys.create(name: "bastion", public_key: "ssh-rsa ...")
196
+ project.user_data.list
197
+ ```
198
+
199
+ ## Errors
200
+
201
+ Every API failure raises a typed exception:
202
+
203
+ ```ruby
204
+ begin
205
+ Latitude::Server.retrieve("sv_bogus")
206
+ rescue Latitude::NotFoundError => e
207
+ e.http_status # => 404
208
+ e.code # => "not_found"
209
+ e.detail # => "Specified Record Not Found"
210
+ e.request_id # from response header
211
+ e.errors # Array<APIError::Entry> with raw payloads
212
+ end
213
+ ```
214
+
215
+ Hierarchy is straightforward:
216
+
217
+ - `Latitude::Error`
218
+ - `Latitude::ConfigurationError`
219
+ - `Latitude::ConnectionError`
220
+ - `Latitude::APIError`
221
+ - `Latitude::BadRequestError` (400)
222
+ - `Latitude::AuthenticationError` (401)
223
+ - `Latitude::PermissionError` (403) — `#feature_not_enabled?`, `#read_only_key?`
224
+ - `Latitude::NotFoundError` (404)
225
+ - `Latitude::ConflictError` (409)
226
+ - `Latitude::UnprocessableEntityError` (422)
227
+ - `Latitude::RateLimitError` (429) — `#retry_after` reads `meta.retry_after`
228
+ - `Latitude::ServerError` (5xx)
229
+
230
+ ## Rate limits
231
+
232
+ The API rate limits per API key; these are handled in the gem but you should be mindful yourself:
233
+
234
+ - Reads (GET): 60 requests/minute
235
+ - Writes (POST/PUT/PATCH/DELETE): 20 requests/minute
236
+
237
+ Set `max_network_retries` to auto-backoff across 429s; `RateLimitError#retry_after` is honored between retries.
238
+
239
+ ## Versioning
240
+
241
+ As of this commit, only supports Latitude `2023-06-01`. You can override this if you want:
242
+
243
+ ```ruby
244
+ Latitude.api_version = "2023-06-01" # global
245
+ Latitude::Server.list({}, { api_version: "2022-07-18" }) # per call
246
+ ```
247
+
248
+ ## Instance metadata service
249
+
250
+ One quirk I haven't decided to bake in because I don't need it (if you do, let me know and I'll do my best):
251
+
252
+ _Only_ if your code runs on a Latitude.sh bare metal server, `Latitude::Metadata` reads the on-instance metadata service (`http://169.254.169.254/metadata/v1`) using IMDSv2-style tokens. Separate from the main API—different auth, same library.
253
+
254
+ ```ruby
255
+ Latitude::Metadata.hostname # "web-01"
256
+ Latitude::Metadata.public_ipv4 # "203.0.113.10"
257
+ Latitude::Metadata.region # "SAO"
258
+ Latitude::Metadata.tags # [{key:, value:}, ...]
259
+ Latitude::Metadata.userdata # cloud-init string or nil
260
+ Latitude::Metadata.all # full metadata object
261
+
262
+ meta = Latitude::Metadata.all
263
+ meta.network.interfaces.first.addresses.first.address
264
+ ```
265
+
266
+ Not reachable from outside a Latitude server. Outside that context, calls raise `Latitude::Metadata::Unavailable`.
267
+
268
+ I've thought about making a protected HTTP server expose this but I don't imagine anyone needs it that can't do it themselves.
269
+
270
+ ## Development
271
+
272
+ ```bash
273
+ bin/setup
274
+ bundle exec rake test
275
+ ```
276
+
277
+ ## Contributing
278
+
279
+ Bug reports and pull requests welcome at [github.com/joshmn/latitude-api](https://github.com/joshdotmn/latitude-api).
280
+
281
+ ## License
282
+
283
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ t.warning = false
11
+ end
12
+
13
+ task default: :test
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ class APIObject
6
+ extend Attributes::ClassMethods
7
+ include Attributes::InstanceMethods
8
+
9
+ RESERVED = %i[attributes id type meta values].freeze
10
+
11
+ attr_reader :raw
12
+
13
+ def self.wrap(value)
14
+ case value
15
+ when Hash
16
+ new(value)
17
+ when Array
18
+ value.map { |v| wrap(v) }
19
+ else
20
+ value
21
+ end
22
+ end
23
+
24
+ def initialize(hash = {})
25
+ @raw = hash || {}
26
+ @values = {}
27
+ @raw.each { |k, v| @values[k.to_s] = wrap_attribute(k, v) }
28
+ end
29
+
30
+ def [](key)
31
+ @values[key.to_s]
32
+ end
33
+
34
+ def key?(key)
35
+ @values.key?(key.to_s)
36
+ end
37
+
38
+ def keys
39
+ @values.keys
40
+ end
41
+
42
+ def each(&blk)
43
+ @values.each(&blk)
44
+ end
45
+
46
+ def to_h
47
+ @values.each_with_object({}) do |(k, v), out|
48
+ out[k] = unwrap(v)
49
+ end
50
+ end
51
+
52
+ alias to_hash to_h
53
+
54
+ def as_json(*_)
55
+ to_h
56
+ end
57
+
58
+ def ==(other)
59
+ return false unless other.is_a?(self.class)
60
+
61
+ to_h == other.to_h
62
+ end
63
+
64
+ alias eql? ==
65
+
66
+ def hash
67
+ to_h.hash
68
+ end
69
+
70
+ def inspect
71
+ "#<#{self.class.name} #{to_h.inspect}>"
72
+ end
73
+
74
+ def respond_to_missing?(name, include_private = false)
75
+ key = normalize_method_name(name)
76
+ return true if @values.key?(key)
77
+ return true if key.end_with?("?") && @values.key?(key.chomp("?"))
78
+
79
+ super
80
+ end
81
+
82
+ def method_missing(name, *args, &blk)
83
+ key = normalize_method_name(name)
84
+ return @values[key] if @values.key?(key) && args.empty?
85
+ return !!@values[key.chomp("?")] if key.end_with?("?") && @values.key?(key.chomp("?")) && args.empty?
86
+
87
+ super
88
+ end
89
+
90
+ private
91
+
92
+ def normalize_method_name(name)
93
+ name.to_s
94
+ end
95
+
96
+ def unwrap(value)
97
+ case value
98
+ when APIObject then value.to_h
99
+ when Array then value.map { |v| unwrap(v) }
100
+ else value
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Latitude
4
+ module API
5
+ class APIResource
6
+ extend Attributes::ClassMethods
7
+ include Attributes::InstanceMethods
8
+
9
+ class << self
10
+ def resource_type(value = nil)
11
+ @resource_type = value if value
12
+ @resource_type || raise(NotImplementedError, "#{name} must declare resource_type")
13
+ end
14
+
15
+ def resource_path(value = nil)
16
+ @resource_path = value if value
17
+ @resource_path || raise(NotImplementedError, "#{name} must declare resource_path")
18
+ end
19
+
20
+ def id_prefix(value = nil)
21
+ @id_prefix = value if value
22
+ @id_prefix
23
+ end
24
+
25
+ def inherited(subclass)
26
+ super
27
+ subclass.instance_variable_set(:@resource_type, @resource_type)
28
+ subclass.instance_variable_set(:@resource_path, @resource_path)
29
+ subclass.instance_variable_set(:@id_prefix, @id_prefix)
30
+ end
31
+
32
+ def class_url(path_params: {})
33
+ fill_path(resource_path, path_params)
34
+ end
35
+
36
+ def instance_url(id, path_params: {})
37
+ raise ArgumentError, "id is required" if id.nil? || id.to_s.empty?
38
+
39
+ "#{class_url(path_params: path_params)}/#{URI.encode_www_form_component(id)}"
40
+ end
41
+
42
+ def execute_request(method:, path:, query: nil, body: nil, opts: {}, headers: {})
43
+ options = RequestOptions.wrap(opts)
44
+ effective = options.apply_to(config_source)
45
+ RequestExecutor.new(effective).execute(
46
+ method: method,
47
+ path: path,
48
+ query: query,
49
+ body: body,
50
+ headers: headers,
51
+ idempotency_key: options.idempotency_key,
52
+ extra_headers: options.headers,
53
+ )
54
+ end
55
+
56
+ def construct_from(parsed, opts: {}, path_params: {})
57
+ data = parsed.is_a?(Hash) ? parsed["data"] : nil
58
+ raise APIError, "response missing data envelope" unless data.is_a?(Hash)
59
+
60
+ new(data, meta: parsed["meta"], path_params: path_params, opts: opts)
61
+ end
62
+
63
+ def config_source
64
+ Thread.current[:latitude_api_config] || Latitude.config
65
+ end
66
+
67
+ def with_config(config)
68
+ prev = Thread.current[:latitude_api_config]
69
+ Thread.current[:latitude_api_config] = config
70
+ yield
71
+ ensure
72
+ Thread.current[:latitude_api_config] = prev
73
+ end
74
+
75
+ def extract_path_params(params, keys)
76
+ extracted = {}
77
+ keys.each do |k|
78
+ v = params.delete(k) || params.delete(k.to_s)
79
+ extracted[k] = v if v
80
+ end
81
+ extracted
82
+ end
83
+
84
+ def path_param_keys
85
+ resource_path.scan(/:(\w+)/).flatten.map(&:to_sym)
86
+ end
87
+
88
+ private
89
+
90
+ def fill_path(template, path_params)
91
+ filled = template.dup
92
+ path_params.each do |k, v|
93
+ token = ":#{k}"
94
+ raise ArgumentError, "path param #{k} not in template #{template}" unless filled.include?(token)
95
+
96
+ filled = filled.gsub(token, URI.encode_www_form_component(v.to_s))
97
+ end
98
+ missing = filled.scan(/:(\w+)/).flatten
99
+ raise ArgumentError, "missing path params: #{missing.join(', ')}" unless missing.empty?
100
+
101
+ filled
102
+ end
103
+ end
104
+
105
+ attr_reader :id, :type, :meta, :path_params, :raw_data
106
+
107
+ def initialize(data, meta: nil, path_params: {}, opts: {})
108
+ @raw_data = data
109
+ @id = data["id"]
110
+ @type = data["type"]
111
+ @meta = data["meta"] || meta || {}
112
+ @path_params = path_params || {}
113
+ @opts = opts || {}
114
+ @values = {}
115
+ @unsaved = []
116
+ Array(data["attributes"]).each do |k, v|
117
+ @values[k.to_s] = wrap_attribute(k, v)
118
+ end
119
+ end
120
+
121
+ def attributes
122
+ @values.each_with_object({}) { |(k, v), h| h[k] = unwrap(v) }
123
+ end
124
+
125
+ def to_h
126
+ out = {}
127
+ out["id"] = @id if @id
128
+ out["type"] = @type if @type
129
+ out["attributes"] = attributes
130
+ out["meta"] = @meta unless @meta.nil? || @meta.empty?
131
+ out
132
+ end
133
+
134
+ alias to_hash to_h
135
+
136
+ def as_json(*_)
137
+ to_h
138
+ end
139
+
140
+ def ==(other)
141
+ other.is_a?(self.class) && other.id == @id
142
+ end
143
+
144
+ alias eql? ==
145
+
146
+ def hash
147
+ [@type, @id].hash
148
+ end
149
+
150
+ def inspect
151
+ "#<#{self.class.name} id=#{@id.inspect} #{attributes.map { |k, v| "#{k}=#{v.inspect}" }.join(' ')}>"
152
+ end
153
+
154
+ def [](key)
155
+ @values[key.to_s]
156
+ end
157
+
158
+ def []=(key, value)
159
+ key_s = key.to_s
160
+ @values[key_s] = wrap_attribute(key_s, value)
161
+ @unsaved << key_s unless @unsaved.include?(key_s)
162
+ value
163
+ end
164
+
165
+ def write_attribute(name, value)
166
+ self[name] = value
167
+ end
168
+
169
+ def unsaved_attributes
170
+ @unsaved.each_with_object({}) { |k, h| h[k] = unwrap(@values[k]) }
171
+ end
172
+
173
+ def changed?
174
+ !@unsaved.empty?
175
+ end
176
+
177
+ def refresh(opts = {})
178
+ parsed = self.class.execute_request(method: :get, path: resource_url, opts: opts)
179
+ update_from(parsed)
180
+ self
181
+ end
182
+
183
+ def save(opts = {})
184
+ return self unless changed?
185
+
186
+ payload = JSONAPI.encode(type: @type || self.class.resource_type, id: @id, attributes: unsaved_attributes)
187
+ parsed = self.class.execute_request(method: :patch, path: resource_url, body: payload, opts: opts)
188
+ update_from(parsed)
189
+ @unsaved.clear
190
+ self
191
+ end
192
+
193
+ def delete(opts = {})
194
+ self.class.execute_request(method: :delete, path: resource_url, opts: opts)
195
+ self
196
+ end
197
+
198
+ alias destroy delete
199
+
200
+ def resource_url
201
+ self.class.instance_url(@id, path_params: @path_params)
202
+ end
203
+
204
+ def respond_to_missing?(name, include_private = false)
205
+ return true if @values.key?(name.to_s)
206
+ return true if name.to_s.end_with?("=") && !read_only_attribute?(name.to_s.chomp("="))
207
+
208
+ super
209
+ end
210
+
211
+ def method_missing(name, *args)
212
+ name_s = name.to_s
213
+ if name_s.end_with?("=") && args.size == 1
214
+ attr_name = name_s.chomp("=")
215
+ if read_only_attribute?(attr_name)
216
+ raise NoMethodError, "cannot assign to read-only attribute #{attr_name}"
217
+ end
218
+
219
+ return self[attr_name] = args.first
220
+ end
221
+
222
+ return @values[name_s] if @values.key?(name_s) && args.empty?
223
+
224
+ super
225
+ end
226
+
227
+ READ_ONLY_ATTRIBUTES = %w[id type created_at updated_at].freeze
228
+
229
+ private
230
+
231
+ def read_only_attribute?(name)
232
+ return true if READ_ONLY_ATTRIBUTES.include?(name)
233
+
234
+ spec = attribute_spec(name)
235
+ spec ? spec.read_only : false
236
+ end
237
+
238
+ def update_from(parsed)
239
+ data = parsed.is_a?(Hash) ? parsed["data"] : nil
240
+ return unless data.is_a?(Hash)
241
+
242
+ @raw_data = data
243
+ @id = data["id"] if data["id"]
244
+ @type = data["type"] if data["type"]
245
+ @meta = data["meta"] if data["meta"]
246
+ @values = {}
247
+ Array(data["attributes"]).each { |k, v| @values[k.to_s] = wrap_attribute(k, v) }
248
+ self
249
+ end
250
+
251
+ def unwrap(value)
252
+ case value
253
+ when APIObject then value.to_h
254
+ when Array then value.map { |v| unwrap(v) }
255
+ else value
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end