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.
- checksums.yaml +7 -0
- data/.yardopts +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +283 -0
- data/Rakefile +13 -0
- data/lib/latitude/api/api_object.rb +105 -0
- data/lib/latitude/api/api_resource.rb +260 -0
- data/lib/latitude/api/attributes.rb +64 -0
- data/lib/latitude/api/client.rb +80 -0
- data/lib/latitude/api/configuration.rb +88 -0
- data/lib/latitude/api/errors.rb +134 -0
- data/lib/latitude/api/json_api.rb +40 -0
- data/lib/latitude/api/list_object.rb +129 -0
- data/lib/latitude/api/objects.rb +216 -0
- data/lib/latitude/api/operations/action.rb +41 -0
- data/lib/latitude/api/operations/create.rb +22 -0
- data/lib/latitude/api/operations/delete.rb +22 -0
- data/lib/latitude/api/operations/list.rb +54 -0
- data/lib/latitude/api/operations/nested.rb +53 -0
- data/lib/latitude/api/operations/retrieve.rb +22 -0
- data/lib/latitude/api/operations/update.rb +22 -0
- data/lib/latitude/api/request_executor.rb +217 -0
- data/lib/latitude/api/request_options.rb +86 -0
- data/lib/latitude/api/request_params.rb +70 -0
- data/lib/latitude/api/resources/api_key.rb +61 -0
- data/lib/latitude/api/resources/billing_usage.rb +51 -0
- data/lib/latitude/api/resources/elastic_ip.rb +96 -0
- data/lib/latitude/api/resources/event.rb +55 -0
- data/lib/latitude/api/resources/firewall.rb +129 -0
- data/lib/latitude/api/resources/ip.rb +110 -0
- data/lib/latitude/api/resources/kubernetes_cluster.rb +184 -0
- data/lib/latitude/api/resources/plan.rb +658 -0
- data/lib/latitude/api/resources/project.rb +179 -0
- data/lib/latitude/api/resources/region.rb +41 -0
- data/lib/latitude/api/resources/role.rb +20 -0
- data/lib/latitude/api/resources/server.rb +251 -0
- data/lib/latitude/api/resources/ssh_key.rb +44 -0
- data/lib/latitude/api/resources/storage_filesystem.rb +36 -0
- data/lib/latitude/api/resources/storage_object.rb +85 -0
- data/lib/latitude/api/resources/storage_volume.rb +64 -0
- data/lib/latitude/api/resources/tag.rb +34 -0
- data/lib/latitude/api/resources/team.rb +169 -0
- data/lib/latitude/api/resources/traffic.rb +215 -0
- data/lib/latitude/api/resources/user_data.rb +38 -0
- data/lib/latitude/api/resources/user_profile.rb +51 -0
- data/lib/latitude/api/resources/user_team.rb +90 -0
- data/lib/latitude/api/resources/virtual_machine.rb +146 -0
- data/lib/latitude/api/resources/virtual_network.rb +162 -0
- data/lib/latitude/api/resources/vpn_session.rb +80 -0
- data/lib/latitude/api/singleton_api_resource.rb +22 -0
- data/lib/latitude/api/util.rb +64 -0
- data/lib/latitude/api/version.rb +7 -0
- data/lib/latitude/api.rb +147 -0
- data/lib/latitude/metadata/errors.rb +18 -0
- data/lib/latitude/metadata/session.rb +109 -0
- data/lib/latitude/metadata.rb +43 -0
- data/lib/latitude.rb +4 -0
- 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
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
|