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 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
@@ -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
@@ -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,6 @@
1
+ module Dkey
2
+ class Error < StandardError; end
3
+
4
+ # Raised by operations before any network call when the arguments are unusable.
5
+ class ArgumentError < Error; end
6
+ end
@@ -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
@@ -0,0 +1,3 @@
1
+ module Dkey
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -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: []