dkey-client 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +151 -0
- data/lib/dkey/access_grants/create.rb +63 -0
- data/lib/dkey/access_grants/delete.rb +29 -0
- data/lib/dkey/access_grants.rb +9 -0
- data/lib/dkey/access_tokens/upsert.rb +38 -0
- data/lib/dkey/api_error.rb +20 -0
- data/lib/dkey/client.rb +70 -0
- data/lib/dkey/config.rb +18 -0
- data/lib/dkey/error.rb +6 -0
- data/lib/dkey/locks/index.rb +44 -0
- data/lib/dkey/memory_token_store.rb +22 -0
- data/lib/dkey/operation.rb +32 -0
- data/lib/dkey/version.rb +3 -0
- data/lib/dkey-client.rb +1 -0
- data/lib/dkey.rb +34 -0
- data/sig/dkey.rbs +92 -0
- metadata +133 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 99835816bfd5af46b6dfa9307f51d65a3be4983ff2dca632ba4f9e5d50a19ef3
|
|
4
|
+
data.tar.gz: fb3a63d174dd5c5f868cb2f10c4051f87f9dea45ed1e2e21f57bacdc232d6cad
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ad8fe46a8c6d67c1b07cbca2f0ce9149fd47f2b2a0d7a5d59b4831514f183058794cd51379ad1c891805214749f6374cfccb07f0a7e71af4be5dda1a43b6372a
|
|
7
|
+
data.tar.gz: 56fc9d07537fdef920e5023fb387d176d709e13e7026ac59ccd5f641a18be775f9d3f82409766e67826111406b8736eb69939add8b9ee07f710b77e2f0429880
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.0
|
|
6
|
+
|
|
7
|
+
- Initial release, extracted from Guestit Core's `Providers::Stargate::Dkey`.
|
|
8
|
+
- `Dkey::AccessGrants::Create` — `PUT lock/codecard`, time-controlled codecards only.
|
|
9
|
+
- `Dkey::AccessGrants::Delete` — `PATCH lock/codecard/:id/deactivate`, idempotent.
|
|
10
|
+
- `Dkey::Locks::Index` — `GET lock/unit/:unit_id`, the unit's locks in one call.
|
|
11
|
+
- `Dkey::AccessTokens::Upsert` — `POST login`, mints and stores the auth token via the injected token store.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Guestit
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# dkey-client
|
|
2
|
+
|
|
3
|
+
A Ruby client for the [Telkey (dKey) API](https://manager.telkey.com/api/v2/). Each endpoint is one operation class
|
|
4
|
+
that returns a [dry-monads](https://dry-rb.org/gems/dry-monads/) `Result`. The gem handles authentication, retries
|
|
5
|
+
and response normalization. It does not depend on Rails, only on ActiveSupport and Faraday. Your app supplies the
|
|
6
|
+
account credentials, the unit id and the auth-token store.
|
|
7
|
+
|
|
8
|
+
[Guestit](https://guestit.se) built it and runs it in production.
|
|
9
|
+
|
|
10
|
+
Requires Ruby 4.0 or later.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
# Gemfile
|
|
16
|
+
gem 'dkey-client'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Bundler loads it for you. Outside Bundler, `require 'dkey'`.
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
Dkey.configure do |config|
|
|
25
|
+
config.client_id = ENV.fetch('TELKEY_CLIENT_ID') # the api_key; doubles as the X-User-Id header
|
|
26
|
+
config.secret = ENV.fetch('TELKEY_SECRET') # the api_secret
|
|
27
|
+
config.unit_id = ENV.fetch('TELKEY_UNIT_ID')
|
|
28
|
+
config.token_store = MyTokenStore.new # optional, defaults to Dkey::MemoryTokenStore
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| Setting | Default | What it does |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `client_id` | `nil` | The account's api key, sent as the `X-User-Id` header and used to log in. |
|
|
35
|
+
| `secret` | `nil` | The account's api secret, used to log in. |
|
|
36
|
+
| `unit_id` | `nil` | The unit every lock listing scopes to. |
|
|
37
|
+
| `base_url` | `https://manager.telkey.com/api/v2/` | API root. |
|
|
38
|
+
| `timeout` | `nil` | Faraday request timeout in seconds. |
|
|
39
|
+
| `token_store` | `Dkey::MemoryTokenStore.new` | Where the minted auth token persists. |
|
|
40
|
+
|
|
41
|
+
## Auth tokens
|
|
42
|
+
|
|
43
|
+
Telkey mints an auth token on `POST login` and the response carries no expiry, so the gem holds a token for 12
|
|
44
|
+
hours and then logs in again. Every authenticated call sends `X-User-Id` (the client id) and `X-Auth-Token` (the
|
|
45
|
+
minted token); `AccessTokens::Upsert` reads the store first and only logs in when the stored token is missing or
|
|
46
|
+
expired.
|
|
47
|
+
|
|
48
|
+
A token store answers `read` (the token or `nil`) and `save(access_token:, expires_at:)`; a token responds to
|
|
49
|
+
`access_token` and `expired?`. The default `Dkey::MemoryTokenStore` keeps the token per process — inject a real
|
|
50
|
+
store to share it across processes or survive deploys.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
Operations live under `Dkey::<Resource>::<Action>`. Build a fresh instance for every call:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
result = Dkey::Locks::Index.new.call
|
|
58
|
+
|
|
59
|
+
if result.success?
|
|
60
|
+
result.value! # => [{ external_id: 'A1B2C3', name: 'Front door', status: 'online', charge: nil, keys: [...] }]
|
|
61
|
+
else
|
|
62
|
+
result.failure # => a Dkey::Error or a Faraday::Error
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`call` never raises. It returns `Success(value)` or `Failure(error)`. `call!` returns the `Success` and raises the
|
|
67
|
+
error where `call` would return a `Failure`. Response bodies come back as hashes with symbol keys.
|
|
68
|
+
|
|
69
|
+
The gem covers codecards (`AccessGrants::Create`, `AccessGrants::Delete`), the unit's locks (`Locks::Index`) and
|
|
70
|
+
the login (`AccessTokens::Upsert`, which the client calls for you). [CHANGELOG.md](CHANGELOG.md) lists every
|
|
71
|
+
operation.
|
|
72
|
+
|
|
73
|
+
### Grants and locks
|
|
74
|
+
|
|
75
|
+
A grant is a codecard: a time-bound guest link for one lock. Telkey has no reusable keys and no permanent grants,
|
|
76
|
+
so only the time-controlled kind (`kind: 'tca'`) is supported, always with a validity window; anything else is
|
|
77
|
+
refused before any request:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
Dkey::AccessGrants::Create.new.call(
|
|
81
|
+
attributes: {
|
|
82
|
+
access_id: 'A1B2C3', # the lock's serial number
|
|
83
|
+
kind: 'tca',
|
|
84
|
+
label: 'Guest',
|
|
85
|
+
start_date: Time.utc(2026, 11, 10),
|
|
86
|
+
end_date: Time.utc(2026, 11, 12)
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`Create` returns only a share link (`https://…/a/<token>`); the token is the codecard id the `Delete` call
|
|
92
|
+
deactivates, and the full link rides along in `metadata[:raw]` for the guest. Deactivation is idempotent:
|
|
93
|
+
re-revoking an already-deactivated codecard still returns 200, while an unknown id answers 403.
|
|
94
|
+
|
|
95
|
+
`Locks::Index` returns every lock of the configured unit in one call — Telkey has no filters and no paging. The
|
|
96
|
+
wire reports no connectivity or battery level, so a listed lock reads as `online` with a blank charge, and an
|
|
97
|
+
empty lock label falls back to the serial number.
|
|
98
|
+
|
|
99
|
+
### Errors
|
|
100
|
+
|
|
101
|
+
| Class | Raised when |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `Dkey::ArgumentError` | The arguments are unusable. Raised before any request, with a message the host can show as it is. |
|
|
104
|
+
| `Dkey::ApiError` | Telkey answered with an error status. `#status` holds the HTTP status. |
|
|
105
|
+
| `Dkey::Error` | The parent of the two above, also raised when the client id, secret or unit id is missing. |
|
|
106
|
+
|
|
107
|
+
### Retries and delivery
|
|
108
|
+
|
|
109
|
+
Reads retry 429/502/503/504, timeouts and connection-level failures with backoff. A codecard create and the login
|
|
110
|
+
never retry, since `PUT /lock/codecard` has no provider idempotency key. The deactivation is idempotent and retries
|
|
111
|
+
a connection-level failure only. After a failed write:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
error.is_a?(Dkey::ApiError) && error.unapplied? # the write certainly did not take, so a retry is safe
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Anything else (a server error, a timeout after the request left) leaves delivery unknown: read back before
|
|
118
|
+
retrying.
|
|
119
|
+
|
|
120
|
+
## Type signatures
|
|
121
|
+
|
|
122
|
+
The gem ships RBS for its public API in `sig/dkey.rbs`. `rbs collection install` picks it up from your
|
|
123
|
+
`Gemfile.lock`, so Steep sees it with no extra setup.
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
bundle install
|
|
129
|
+
bundle exec rake # specs and RuboCop
|
|
130
|
+
bundle exec rspec # specs only
|
|
131
|
+
bundle exec rubocop -A # autocorrect; review every change
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The specs run offline against recorded VCR cassettes in `spec/fixtures/cassettes`. Recording a new cassette needs
|
|
135
|
+
live credentials for a Telkey account. Scrub the client id, the secret, the unit id and the auth token (the
|
|
136
|
+
spec_helper filters handle all four) and any real lock serials or codecard tokens from the cassette before you
|
|
137
|
+
commit it — this repo is public.
|
|
138
|
+
|
|
139
|
+
## Releasing
|
|
140
|
+
|
|
141
|
+
1. Bump `Dkey::VERSION` in `lib/dkey/version.rb` and move the `Unreleased` notes in `CHANGELOG.md` under the new
|
|
142
|
+
version.
|
|
143
|
+
2. Commit on `staging` and run `bundle exec rake`.
|
|
144
|
+
3. Run `gem signin` once per machine. The gem requires MFA, so every push asks for a one-time code.
|
|
145
|
+
4. Run `bundle exec rake release`. It builds `pkg/dkey-client-<version>.gem`, tags `v<version>`, pushes the branch
|
|
146
|
+
and the tag, and pushes the gem to RubyGems.
|
|
147
|
+
5. Fast-forward `production` to the release tag.
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Dkey
|
|
2
|
+
module AccessGrants
|
|
3
|
+
# Creates a codecard: a time-bound guest link for one lock. Telkey has no
|
|
4
|
+
# reusable keys and no permanent grants, so only the time-controlled kind is
|
|
5
|
+
# supported, always with a validity window.
|
|
6
|
+
class Create
|
|
7
|
+
include Dkey::Operation
|
|
8
|
+
|
|
9
|
+
# @rbs (attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
10
|
+
def call(attributes:)
|
|
11
|
+
argument! 'Access ID is required.' if attributes[:access_id].blank?
|
|
12
|
+
argument! 'Regular grants are not supported by the Telkey provider.' unless attributes[:kind] == KIND_TIME_CONTROLLED
|
|
13
|
+
argument! 'Start date and end date are both required.' if attributes[:start_date].blank? || attributes[:end_date].blank?
|
|
14
|
+
argument! 'End date must be after start date.' if attributes[:start_date].in_time_zone >= attributes[:end_date].in_time_zone
|
|
15
|
+
|
|
16
|
+
Success(create_codecard(attributes))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# @rbs (::Hash[::Symbol, untyped] attributes) -> ::Hash[::Symbol, untyped]
|
|
22
|
+
def create_codecard(attributes)
|
|
23
|
+
data = codecard_data(Dkey.request(:put, 'lock/codecard', params: codecard_payload(attributes)))
|
|
24
|
+
external_id = codecard_id(data)
|
|
25
|
+
raise Error, 'Dkey codecard id is required' if external_id.blank?
|
|
26
|
+
|
|
27
|
+
{ external_id:, metadata: { raw: data } }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @rbs (::Hash[::Symbol, untyped] attributes) -> ::Hash[::Symbol, untyped]
|
|
31
|
+
def codecard_payload(attributes)
|
|
32
|
+
{
|
|
33
|
+
valid_from: iso8601(attributes[:start_date]),
|
|
34
|
+
valid_to: iso8601(attributes[:end_date]),
|
|
35
|
+
locks: [attributes[:access_id]],
|
|
36
|
+
label: attributes[:label].presence,
|
|
37
|
+
key_type: 0
|
|
38
|
+
}.compact
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @rbs (::Hash[::Symbol, untyped] body) -> ::Hash[::Symbol, untyped]
|
|
42
|
+
def codecard_data(body)
|
|
43
|
+
body.is_a?(Hash) ? (body[:data].presence || {}) : {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Telkey returns only a share link (https://…/a/<token>); the <token> is the codecard id
|
|
47
|
+
# used to deactivate it later. The full link is kept in metadata for the guest.
|
|
48
|
+
|
|
49
|
+
# @rbs (::Hash[::Symbol, untyped] data) -> ::String?
|
|
50
|
+
def codecard_id(data)
|
|
51
|
+
link = data[:link]
|
|
52
|
+
return if link.blank?
|
|
53
|
+
|
|
54
|
+
link[%r{/a/([^/?#]+)}, 1] || link[%r{/([^/?#]+)/?\z}, 1]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @rbs ((::Date | ::Time) value) -> ::String
|
|
58
|
+
def iso8601(value)
|
|
59
|
+
value.in_time_zone.utc.iso8601
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Dkey
|
|
2
|
+
module AccessGrants
|
|
3
|
+
# Deactivates a codecard. Telkey deactivation is idempotent: re-revoking an
|
|
4
|
+
# already-deactivated codecard still returns 200. (An unknown codecard id
|
|
5
|
+
# returns 403, which surfaces as a failure.)
|
|
6
|
+
class Delete
|
|
7
|
+
include Dkey::Operation
|
|
8
|
+
|
|
9
|
+
# @rbs (attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[bool]
|
|
10
|
+
def call(attributes:)
|
|
11
|
+
argument! 'External ID is required.' if attributes[:external_id].blank?
|
|
12
|
+
argument! 'Regular grants are not supported by the Telkey provider.' unless attributes[:kind] == KIND_TIME_CONTROLLED
|
|
13
|
+
|
|
14
|
+
deactivate_codecard(attributes)
|
|
15
|
+
|
|
16
|
+
Success(true)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# @rbs (::Hash[::Symbol, untyped] attributes) -> untyped
|
|
22
|
+
def deactivate_codecard(attributes)
|
|
23
|
+
path = format('lock/codecard/%<id>s/deactivate', id: ERB::Util.url_encode(attributes[:external_id]))
|
|
24
|
+
|
|
25
|
+
Dkey.request(:patch, path)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module Dkey
|
|
2
|
+
# Telkey issues only time-bound codecards, so the one grant kind the
|
|
3
|
+
# access-grant operations accept is time-controlled (tca). The host passes its
|
|
4
|
+
# own grant vocabulary through and gets a refusal before the wire for any
|
|
5
|
+
# other kind.
|
|
6
|
+
module AccessGrants
|
|
7
|
+
KIND_TIME_CONTROLLED = 'tca'.freeze #: ::String
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Dkey
|
|
2
|
+
module AccessTokens
|
|
3
|
+
# Logs in with the account's api credentials and stores the minted auth token.
|
|
4
|
+
# The login response carries no expiry, so the token is held for TOKEN_TTL and
|
|
5
|
+
# then re-minted.
|
|
6
|
+
class Upsert
|
|
7
|
+
include Dkey::Operation
|
|
8
|
+
|
|
9
|
+
TOKEN_TTL = 12.hours
|
|
10
|
+
|
|
11
|
+
# @rbs () -> Dry::Monads::Result[untyped]
|
|
12
|
+
def call
|
|
13
|
+
Success(store.read || mint)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# @rbs () -> untyped
|
|
19
|
+
def store
|
|
20
|
+
Dkey.config.token_store
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs () -> untyped
|
|
24
|
+
def mint
|
|
25
|
+
body = Dkey.request(:post, 'login', params: { api_key: Dkey.client_id, api_secret: Dkey.secret }, headers: {})
|
|
26
|
+
|
|
27
|
+
store.save(access_token: auth_token(body), expires_at: TOKEN_TTL.from_now)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @rbs (untyped body) -> ::String
|
|
31
|
+
def auth_token(body)
|
|
32
|
+
token = body.dig(:data, :authToken) if body.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
token.presence || raise(Error, 'Dkey login response is missing an auth token.')
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Dkey
|
|
2
|
+
# A rejected Telkey response, carrying the HTTP status. #unapplied? marks a
|
|
3
|
+
# refusal where the write certainly did not land, so a retry is safe; anything
|
|
4
|
+
# else (a server error, a timeout after a write) leaves delivery unknown.
|
|
5
|
+
class ApiError < Error
|
|
6
|
+
attr_reader :status #: ::Integer
|
|
7
|
+
|
|
8
|
+
# @rbs (::String message, status: ::Integer) -> void
|
|
9
|
+
def initialize(message, status:)
|
|
10
|
+
super(message)
|
|
11
|
+
|
|
12
|
+
@status = status
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @rbs () -> bool
|
|
16
|
+
def unapplied?
|
|
17
|
+
status.between?(400, 499) && [408, 409].exclude?(status)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/dkey/client.rb
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Dkey
|
|
2
|
+
BASE_URL = 'https://manager.telkey.com/api/v2/'.freeze #: ::String
|
|
3
|
+
|
|
4
|
+
CONNECTION_LEVEL_ERRORS = [Faraday::ConnectionFailed, Faraday::SSLError].freeze #: ::Array[untyped]
|
|
5
|
+
|
|
6
|
+
# A codecard PUT creates a grant without a provider idempotency key and the
|
|
7
|
+
# login POST mints a token, so neither ever retries. A read retries a refused
|
|
8
|
+
# status, a timeout and a connection-level failure. The deactivation PATCH is
|
|
9
|
+
# idempotent and retries a connection-level failure only.
|
|
10
|
+
RETRY_OPTIONS = {
|
|
11
|
+
max: 3,
|
|
12
|
+
interval: 0.5,
|
|
13
|
+
interval_randomness: 0.5,
|
|
14
|
+
backoff_factor: 2,
|
|
15
|
+
retry_statuses: [429, 502, 503, 504],
|
|
16
|
+
methods: Faraday::Retry::Middleware::IDEMPOTENT_METHODS - [:put],
|
|
17
|
+
exceptions: [*Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS, *CONNECTION_LEVEL_ERRORS],
|
|
18
|
+
retry_if: ->(env, exception) do
|
|
19
|
+
next false if %i[post put].include?(env.method)
|
|
20
|
+
|
|
21
|
+
CONNECTION_LEVEL_ERRORS.any? { |klass| exception.is_a?(klass) }
|
|
22
|
+
end
|
|
23
|
+
}.freeze #: ::Hash[::Symbol, untyped]
|
|
24
|
+
|
|
25
|
+
# @rbs () -> Faraday::Connection
|
|
26
|
+
def self.connection
|
|
27
|
+
@connection ||= Faraday.new(url: config.base_url, headers: { 'Accept' => 'application/json' }) do |builder|
|
|
28
|
+
builder.request :json
|
|
29
|
+
builder.request :retry, RETRY_OPTIONS
|
|
30
|
+
builder.response :json, parser_options: { symbolize_names: true, mode: :rails, decoder: [Oj, :load] }
|
|
31
|
+
builder.options[:timeout] = config.timeout
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @rbs (::Symbol method, ::String path, ?params: untyped, ?headers: ::Hash[::String, ::String]) -> untyped
|
|
36
|
+
def self.request(method, path, params: nil, headers: auth_headers)
|
|
37
|
+
response = connection.public_send(method, path, params, headers)
|
|
38
|
+
|
|
39
|
+
return response.body if response.success?
|
|
40
|
+
|
|
41
|
+
raise ApiError.new(error_message(response), status: response.status)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Telkey identifies the account by its api_key, which doubles as the X-User-Id header value.
|
|
45
|
+
|
|
46
|
+
# @rbs () -> ::String
|
|
47
|
+
def self.client_id
|
|
48
|
+
config.client_id.presence || raise(Error, 'Dkey client_id is required.')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @rbs () -> ::String
|
|
52
|
+
def self.secret
|
|
53
|
+
config.secret.presence || raise(Error, 'Dkey secret is required.')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @rbs () -> ::Hash[::String, ::String]
|
|
57
|
+
private_class_method def self.auth_headers
|
|
58
|
+
{ 'X-User-Id' => client_id, 'X-Auth-Token' => AccessTokens::Upsert.new.call!.value!.access_token }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Telkey returns error details under :error_message.
|
|
62
|
+
|
|
63
|
+
# @rbs (Faraday::Response response) -> ::String
|
|
64
|
+
private_class_method def self.error_message(response)
|
|
65
|
+
body = response.body
|
|
66
|
+
message = body.is_a?(Hash) ? body[:error_message] : nil
|
|
67
|
+
|
|
68
|
+
message.presence || body.to_s.presence || "Provider request failed with status #{response.status}"
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/dkey/config.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Dkey
|
|
2
|
+
# Injected by the host app (see the Dkey.configure initializer): the account's
|
|
3
|
+
# client id (the api_key, which doubles as the X-User-Id header value), the
|
|
4
|
+
# secret (api_secret), the unit every lock call scopes to, and the store the
|
|
5
|
+
# minted auth token persists in. Keeping these out of the gem is what makes it
|
|
6
|
+
# host-agnostic.
|
|
7
|
+
#
|
|
8
|
+
# Telkey mints an auth token per login, so unlike a static-token provider the
|
|
9
|
+
# gem needs a token store; the default MemoryTokenStore keeps it per process.
|
|
10
|
+
class Config
|
|
11
|
+
attr_accessor :client_id, :secret, :unit_id, :base_url, :timeout, :token_store
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@base_url = BASE_URL
|
|
15
|
+
@token_store = MemoryTokenStore.new
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/dkey/error.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Dkey
|
|
2
|
+
module Locks
|
|
3
|
+
# Lists the unit's locks. Telkey has no lock list filters and no paging: one
|
|
4
|
+
# call returns every lock of the configured unit.
|
|
5
|
+
class Index
|
|
6
|
+
include Dkey::Operation
|
|
7
|
+
|
|
8
|
+
# The wire reports no connectivity or battery level, so a listed lock reads
|
|
9
|
+
# as online and the charge is left blank.
|
|
10
|
+
STATUS_ONLINE = 'online'.freeze
|
|
11
|
+
|
|
12
|
+
# @rbs () -> Dry::Monads::Result[::Array[::Hash[::Symbol, untyped]]]
|
|
13
|
+
def call
|
|
14
|
+
unit_id = Dkey.config.unit_id.presence || raise(Error, 'Dkey unit_id is not configured.')
|
|
15
|
+
|
|
16
|
+
body = Dkey.request(:get, "lock/unit/#{unit_id}")
|
|
17
|
+
|
|
18
|
+
Success(locks(body).map { |item| attributes(item) })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# @rbs (::Hash[::Symbol, untyped] body) -> ::Array[::Hash[::Symbol, untyped]]
|
|
24
|
+
def locks(body)
|
|
25
|
+
body.dig(:data, :locks).presence || []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Labels are often empty, so fall back to the serial number.
|
|
29
|
+
|
|
30
|
+
# @rbs (::Hash[::Symbol, untyped] item) -> ::Hash[::Symbol, untyped]
|
|
31
|
+
def attributes(item)
|
|
32
|
+
name = item[:label].presence || item[:serialNumber]
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
external_id: item[:serialNumber],
|
|
36
|
+
name:,
|
|
37
|
+
status: STATUS_ONLINE,
|
|
38
|
+
charge: nil,
|
|
39
|
+
keys: [{ external_id: item[:serialNumber], name: }]
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Dkey
|
|
2
|
+
# Default in-memory auth-token store. A process restart re-logs in; a host that
|
|
3
|
+
# needs the token shared across processes or surviving restarts injects its own
|
|
4
|
+
# store with the same read/save contract (see Dkey.configure).
|
|
5
|
+
class MemoryTokenStore
|
|
6
|
+
Token = Struct.new(:access_token, :expires_at, keyword_init: true) do
|
|
7
|
+
def expired?
|
|
8
|
+
expires_at < Time.now
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @rbs () -> Token?
|
|
13
|
+
def read
|
|
14
|
+
@token if @token && !@token.expired?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @rbs (access_token: ::String, expires_at: untyped) -> Token
|
|
18
|
+
def save(access_token:, expires_at:)
|
|
19
|
+
@token = Token.new(access_token:, expires_at:)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Dkey
|
|
2
|
+
# Shared monadic contract for the client operations: #call returns a Result,
|
|
3
|
+
# #call! unwraps it and raises the underlying failure. An error raised inside
|
|
4
|
+
# #call becomes Failure(error), mirroring the host's operation base.
|
|
5
|
+
module Operation
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.include Dry::Monads[:result]
|
|
8
|
+
base.prepend(Call)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module Call
|
|
12
|
+
def call(*, **, &)
|
|
13
|
+
super
|
|
14
|
+
rescue StandardError => e
|
|
15
|
+
Failure(e)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call!(...)
|
|
20
|
+
result = call(...)
|
|
21
|
+
raise result.failure if result.failure?
|
|
22
|
+
|
|
23
|
+
result
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def argument!(message)
|
|
29
|
+
raise Dkey::ArgumentError, message
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/dkey/version.rb
ADDED
data/lib/dkey-client.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require_relative 'dkey'
|
data/lib/dkey.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require 'active_support'
|
|
2
|
+
require 'active_support/core_ext'
|
|
3
|
+
require 'dry/monads'
|
|
4
|
+
require 'erb'
|
|
5
|
+
require 'faraday'
|
|
6
|
+
require 'faraday/retry'
|
|
7
|
+
require 'oj'
|
|
8
|
+
|
|
9
|
+
require_relative 'dkey/version'
|
|
10
|
+
require_relative 'dkey/error'
|
|
11
|
+
require_relative 'dkey/api_error'
|
|
12
|
+
require_relative 'dkey/memory_token_store'
|
|
13
|
+
require_relative 'dkey/config'
|
|
14
|
+
require_relative 'dkey/operation'
|
|
15
|
+
require_relative 'dkey/client'
|
|
16
|
+
require_relative 'dkey/access_grants'
|
|
17
|
+
require_relative 'dkey/access_tokens/upsert'
|
|
18
|
+
require_relative 'dkey/access_grants/create'
|
|
19
|
+
require_relative 'dkey/access_grants/delete'
|
|
20
|
+
require_relative 'dkey/locks/index'
|
|
21
|
+
|
|
22
|
+
module Dkey
|
|
23
|
+
class << self
|
|
24
|
+
# @rbs () { (Config) -> void } -> void
|
|
25
|
+
def configure
|
|
26
|
+
yield(config)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @rbs () -> Config
|
|
30
|
+
def config
|
|
31
|
+
@config ||= Config.new
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/sig/dkey.rbs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Public API of the gem, mirroring the inline `# @rbs` annotations in lib/.
|
|
2
|
+
# Shipped with the gem so host apps pick it up through `rbs collection`.
|
|
3
|
+
module Dkey
|
|
4
|
+
VERSION: ::String
|
|
5
|
+
|
|
6
|
+
BASE_URL: ::String
|
|
7
|
+
CONNECTION_LEVEL_ERRORS: ::Array[untyped]
|
|
8
|
+
RETRY_OPTIONS: ::Hash[::Symbol, untyped]
|
|
9
|
+
|
|
10
|
+
def self.configure: () { (Config) -> void } -> void
|
|
11
|
+
def self.config: () -> Config
|
|
12
|
+
|
|
13
|
+
def self.connection: () -> untyped
|
|
14
|
+
def self.request: (::Symbol method, ::String path, ?params: untyped, ?headers: ::Hash[::String, ::String]) -> untyped
|
|
15
|
+
def self.client_id: () -> ::String
|
|
16
|
+
def self.secret: () -> ::String
|
|
17
|
+
|
|
18
|
+
class Error < ::StandardError
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Raised by operations before any network call when the arguments are unusable.
|
|
22
|
+
class ArgumentError < Error
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A rejected Telkey response; #unapplied? marks a write that never landed.
|
|
26
|
+
class ApiError < Error
|
|
27
|
+
attr_reader status: ::Integer
|
|
28
|
+
|
|
29
|
+
def initialize: (::String message, status: ::Integer) -> void
|
|
30
|
+
|
|
31
|
+
def unapplied?: () -> bool
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Config
|
|
35
|
+
attr_accessor client_id: ::String?
|
|
36
|
+
attr_accessor secret: ::String?
|
|
37
|
+
attr_accessor unit_id: ::String?
|
|
38
|
+
attr_accessor base_url: ::String
|
|
39
|
+
attr_accessor timeout: (::Integer | ::Float)?
|
|
40
|
+
attr_accessor token_store: untyped
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class MemoryTokenStore
|
|
44
|
+
class Token
|
|
45
|
+
attr_accessor access_token: ::String
|
|
46
|
+
attr_accessor expires_at: untyped
|
|
47
|
+
|
|
48
|
+
def expired?: () -> bool
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read: () -> Token?
|
|
52
|
+
def save: (access_token: ::String, expires_at: untyped) -> Token
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# #call! returns the same Result as #call, raising the failure instead of
|
|
56
|
+
# wrapping it — hence the `alias call! call` on every operation below.
|
|
57
|
+
module Operation
|
|
58
|
+
def call!: (*untyped, **untyped) -> untyped
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
module AccessTokens
|
|
62
|
+
class Upsert
|
|
63
|
+
TOKEN_TTL: ::ActiveSupport::Duration
|
|
64
|
+
|
|
65
|
+
def call: () -> ::Dry::Monads::Result[untyped]
|
|
66
|
+
alias call! call
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
module AccessGrants
|
|
71
|
+
KIND_TIME_CONTROLLED: ::String
|
|
72
|
+
|
|
73
|
+
class Create
|
|
74
|
+
def call: (attributes: ::Hash[::Symbol, untyped]) -> ::Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
75
|
+
alias call! call
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class Delete
|
|
79
|
+
def call: (attributes: ::Hash[::Symbol, untyped]) -> ::Dry::Monads::Result[bool]
|
|
80
|
+
alias call! call
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
module Locks
|
|
85
|
+
class Index
|
|
86
|
+
STATUS_ONLINE: ::String
|
|
87
|
+
|
|
88
|
+
def call: () -> ::Dry::Monads::Result[::Array[::Hash[::Symbol, untyped]]]
|
|
89
|
+
alias call! call
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: dkey-client
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Guestit
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activesupport
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: dry-monads
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.10'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.10'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: faraday
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.14'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.14'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: faraday-retry
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '2.4'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '2.4'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: oj
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.17'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '3.17'
|
|
82
|
+
description: 'One operation per Telkey dKey API endpoint, returning dry-monads Results,
|
|
83
|
+
with retries and response normalization. Framework-free: the host injects the account
|
|
84
|
+
credentials, the unit id and the auth-token store.'
|
|
85
|
+
executables: []
|
|
86
|
+
extensions: []
|
|
87
|
+
extra_rdoc_files: []
|
|
88
|
+
files:
|
|
89
|
+
- CHANGELOG.md
|
|
90
|
+
- LICENSE
|
|
91
|
+
- README.md
|
|
92
|
+
- lib/dkey-client.rb
|
|
93
|
+
- lib/dkey.rb
|
|
94
|
+
- lib/dkey/access_grants.rb
|
|
95
|
+
- lib/dkey/access_grants/create.rb
|
|
96
|
+
- lib/dkey/access_grants/delete.rb
|
|
97
|
+
- lib/dkey/access_tokens/upsert.rb
|
|
98
|
+
- lib/dkey/api_error.rb
|
|
99
|
+
- lib/dkey/client.rb
|
|
100
|
+
- lib/dkey/config.rb
|
|
101
|
+
- lib/dkey/error.rb
|
|
102
|
+
- lib/dkey/locks/index.rb
|
|
103
|
+
- lib/dkey/memory_token_store.rb
|
|
104
|
+
- lib/dkey/operation.rb
|
|
105
|
+
- lib/dkey/version.rb
|
|
106
|
+
- sig/dkey.rbs
|
|
107
|
+
homepage: https://github.com/guestitab/dkey-client
|
|
108
|
+
licenses:
|
|
109
|
+
- MIT
|
|
110
|
+
metadata:
|
|
111
|
+
allowed_push_host: https://rubygems.org
|
|
112
|
+
source_code_uri: https://github.com/guestitab/dkey-client
|
|
113
|
+
changelog_uri: https://github.com/guestitab/dkey-client/blob/staging/CHANGELOG.md
|
|
114
|
+
bug_tracker_uri: https://github.com/guestitab/dkey-client/issues
|
|
115
|
+
rubygems_mfa_required: 'true'
|
|
116
|
+
rdoc_options: []
|
|
117
|
+
require_paths:
|
|
118
|
+
- lib
|
|
119
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - ">="
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '4.0'
|
|
124
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
125
|
+
requirements:
|
|
126
|
+
- - ">="
|
|
127
|
+
- !ruby/object:Gem::Version
|
|
128
|
+
version: '0'
|
|
129
|
+
requirements: []
|
|
130
|
+
rubygems_version: 4.0.23
|
|
131
|
+
specification_version: 4
|
|
132
|
+
summary: Ruby client for the Telkey (dKey) API
|
|
133
|
+
test_files: []
|