parakey-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: a7a9e0253c3a8e5a39e372c39c77be783d54ae0875290af41a2215588ed96b1c
4
+ data.tar.gz: 1cf12063617bcc57125e2560e9cc44b6e4906c06e93101be114ca1ea3fae3e24
5
+ SHA512:
6
+ metadata.gz: 1539da5296f053cfaa0cf0bbf0ecb22ab12f4d95811a5a44f94b1e46af1b029ddc3d0807296f0d7b16d5078568b2380681bab3b6ecf7720a547ab8c98fd9e85a
7
+ data.tar.gz: e63ec011675cf935074272dee5f01f5e3ad5ffbb1be966ad5234f6d35d10827e4329fdf41fbe0f6fcc93f7a60c3a2ad3ae597243deffca50a815ef948874caaa
data/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-11-10
6
+
7
+ First release as `parakey-client`, extracted from the Parakey integration Guestit runs in production. The specs are
8
+ that integration's provider specs, ported with their recorded VCR cassettes (scrubbed of emails, names and unit
9
+ identifiers — this repo is public).
10
+
11
+ ### Added
12
+
13
+ - `Parakey.configure` for the access token, the domain id, the base URL and the request timeout. No Rails
14
+ dependency.
15
+ - Wire API on the `Parakey` module: `connection` and `request` with the retry policy (reads retry 429/502/503/504
16
+ and connection-level failures; a write never retries), the `Access-Token` auth header and the `ApiError` wrap with
17
+ `#unapplied?` delivery classification.
18
+ - `sig/parakey.rbs` declaring the public API.
19
+ - Operations, each returning a dry-monads `Result`:
20
+ - Access grants: `Create` (regular and time-controlled windows), `Delete` and `Index`, sharing the
21
+ `Parakey::AccessGrants` kind and status vocabulary.
22
+ - `Keys::Index` for the API-enabled accesses, `Locks::Index` for the devices joined with their accesses and
23
+ battery charge.
24
+ - Members: `Index` with wire filters and `Destroy` by email.
25
+ - `AccessEvents::Normalize` for event-log items and webhook deliveries.
26
+
27
+ [Unreleased]: https://github.com/guestitab/parakey-client/compare/v0.1.0...staging
28
+ [0.1.0]: https://github.com/guestitab/parakey-client/releases/tag/v0.1.0
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,149 @@
1
+ # parakey-client
2
+
3
+ A Ruby client for the [Parakey API](https://api.parakey.co/). Each endpoint is one operation class that returns a
4
+ [dry-monads](https://dry-rb.org/gems/dry-monads/) `Result`. The gem handles authentication, retries and response
5
+ normalization. It does not depend on Rails, only on ActiveSupport and Faraday. Your app supplies the access token
6
+ and the domain id.
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 'parakey-client'
17
+ ```
18
+
19
+ Bundler loads it for you. Outside Bundler, `require 'parakey'`.
20
+
21
+ ## Configuration
22
+
23
+ ```ruby
24
+ Parakey.configure do |config|
25
+ config.access_token = ENV.fetch('PARAKEY_ACCESS_TOKEN')
26
+ config.domain_id = ENV.fetch('PARAKEY_DOMAIN_ID')
27
+ end
28
+ ```
29
+
30
+ | Setting | Default | What it does |
31
+ | --- | --- | --- |
32
+ | `access_token` | `nil` | The account's static API token, sent as the `Access-Token` header. Parakey issues one per account; there is no OAuth minting and no token store. |
33
+ | `domain_id` | `nil` | The domain every list and member call scopes to (the `domainId` parameter). |
34
+ | `base_url` | `https://api.parakey.co/` | API root. |
35
+ | `timeout` | `nil` | Faraday request timeout in seconds. |
36
+
37
+ ## Usage
38
+
39
+ Operations live under `Parakey::<Resource>::<Action>`. Build a fresh instance for every call:
40
+
41
+ ```ruby
42
+ result = Parakey::AccessGrants::Index.new.call(skip: 0, take: 100)
43
+
44
+ if result.success?
45
+ result.value! # => [{ external_id: 'f163gXNFWc', recipient_email: '...', kind: 'regular', ... }]
46
+ else
47
+ result.failure # => a Parakey::Error or a Faraday::Error
48
+ end
49
+ ```
50
+
51
+ `call` never raises. It returns `Success(value)` or `Failure(error)`. `call!` returns the `Success` and raises the
52
+ error where `call` would return a `Failure`. Response bodies come back as hashes with symbol keys.
53
+
54
+ The gem covers access grants (create, delete, index), accesses (`Keys::Index`), devices (`Locks::Index`, joining
55
+ the accesses that reach each device), members (index, destroy) and access events (`AccessEvents::Normalize`, which
56
+ maps one event-log item or webhook delivery to plain attributes). [CHANGELOG.md](CHANGELOG.md) lists every
57
+ operation.
58
+
59
+ ### Grants, keys and locks
60
+
61
+ An access key (a grant) points at an access (a reusable key) and a user. A key with both `startDate` and
62
+ `expirationDate` is time-controlled (`kind: 'tca'`); any other is permanent (`kind: 'regular'`). Creating a
63
+ time-controlled grant without the window, or a permanent one with one, is refused before any request:
64
+
65
+ ```ruby
66
+ Parakey::AccessGrants::Create.new.call(
67
+ attributes: {
68
+ access_id: 'hJ7Jt5aPIL',
69
+ recipient_email: 'guest@example.com',
70
+ kind: 'tca',
71
+ start_date: Time.utc(2026, 11, 10),
72
+ end_date: Time.utc(2026, 11, 12)
73
+ }
74
+ )
75
+ ```
76
+
77
+ Parakey upserts an access key by access, user and window: re-creating an identical grant resolves to the existing
78
+ key. Deletes answer 404 for an already-missing key or member, which the gem reads as success.
79
+
80
+ Lists page 1-based with `page` and `limit` on the wire; the operations take `skip` and `take` and derive the page.
81
+ `Keys::Index` is always filtered to API-enabled accesses; `Locks::Index` reports `batteryStatus` as a percentage
82
+ (`high`/`medium`/`low`/`veryLow` → 100/75/50/25).
83
+
84
+ ### Errors
85
+
86
+ | Class | Raised when |
87
+ | --- | --- |
88
+ | `Parakey::ArgumentError` | The arguments are unusable. Raised before any request. |
89
+ | `Parakey::ApiError` | Parakey answered with an error status. `#status` holds the HTTP status. |
90
+ | `Parakey::Error` | The parent of the two above, also raised when no access token is configured. |
91
+
92
+ ### Retries and delivery
93
+
94
+ Reads retry 429/502/503/504 and connection-level failures with backoff. A write never retries on a status, and a
95
+ connection-level failure retries on reads only — `POST /access-keys` has no provider idempotency key. After a
96
+ failed write:
97
+
98
+ ```ruby
99
+ error.is_a?(Parakey::ApiError) && error.unapplied? # the write certainly did not take, so a retry is safe
100
+ ```
101
+
102
+ Anything else (a server error, a timeout after the request left) leaves delivery unknown: read back before
103
+ retrying.
104
+
105
+ ### Webhooks
106
+
107
+ Parakey POSTs event-log items to your webhook URL with the account's webhook key in the `?token=` query parameter.
108
+ Verify it in your app (a `secure_compare` against your stored key), then normalize the payload:
109
+
110
+ ```ruby
111
+ Parakey::AccessEvents::Normalize.new.call(item: params)
112
+ # => Success({ external_id:, event_type:, occurred_at:, grant_external_id:, provider_lock_id:, access_id:, metadata: })
113
+ ```
114
+
115
+ A delivery gets six attempts over about five hours and is then dropped, and Parakey disables a webhook after 10
116
+ failures in five days or 100 in a day. Dropped events can be recovered from `GET /event-logs`
117
+ (`Accept-Version: 2.0`), polling by `createdAt`.
118
+
119
+ ## Type signatures
120
+
121
+ The gem ships RBS for its public API in `sig/parakey.rbs`. `rbs collection install` picks it up from your
122
+ `Gemfile.lock`, so Steep sees it with no extra setup.
123
+
124
+ ## Development
125
+
126
+ ```sh
127
+ bundle install
128
+ bundle exec rake # specs and RuboCop
129
+ bundle exec rspec # specs only
130
+ bundle exec rubocop -A # autocorrect; review every change
131
+ ```
132
+
133
+ The specs run offline against recorded VCR cassettes in `spec/fixtures/cassettes`. Recording a new cassette needs
134
+ live credentials for a Parakey account. Scrub the token, the domain id (the spec_helper filters handle both) and
135
+ any real emails, names or unit identifiers from the cassette before you commit it — this repo is public.
136
+
137
+ ## Releasing
138
+
139
+ 1. Bump `Parakey::VERSION` in `lib/parakey/version.rb` and move the `Unreleased` notes in `CHANGELOG.md` under the
140
+ new version.
141
+ 2. Commit on `staging` and run `bundle exec rake`.
142
+ 3. Run `gem signin` once per machine. The gem requires MFA, so every push asks for a one-time code.
143
+ 4. Run `bundle exec rake release`. It builds `pkg/parakey-client-<version>.gem`, tags `v<version>`, pushes the
144
+ branch and the tag, and pushes the gem to RubyGems.
145
+ 5. Fast-forward `production` to the release tag.
146
+
147
+ ## License
148
+
149
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,42 @@
1
+ module Parakey
2
+ module AccessEvents
3
+ # Normalizes one event-log item or webhook delivery into the access event
4
+ # attributes the host persists. The wire item is left untouched under
5
+ # metadata[:payload].
6
+ class Normalize
7
+ include Parakey::Operation
8
+
9
+ # Device maintenance activities report as a sync, not an unlock.
10
+ SYNC_ACTIVITIES = %w[calibrate dfu setTime].freeze #: ::Array[::String]
11
+
12
+ # @rbs (item: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
13
+ def call(item:)
14
+ argument! item: :required if item.blank?
15
+
16
+ Success(attributes(item))
17
+ end
18
+
19
+ private
20
+
21
+ # @rbs (::Hash[::Symbol, untyped] item) -> ::Hash[::Symbol, untyped]
22
+ def attributes(item)
23
+ {
24
+ external_id: item[:id],
25
+ event_type: event_type(item),
26
+ occurred_at: item[:timestamp].presence&.in_time_zone,
27
+ grant_external_id: item.dig(:info, :accessKey, :id).presence,
28
+ provider_lock_id: item.dig(:info, :device, :id).presence,
29
+ access_id: item.dig(:info, :access, :id).presence,
30
+ metadata: { payload: item }
31
+ }
32
+ end
33
+
34
+ # @rbs (::Hash[::Symbol, untyped] item) -> ::String?
35
+ def event_type(item)
36
+ return 'synced' if SYNC_ACTIVITIES.include?(item[:activity])
37
+
38
+ item[:activity].presence
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,47 @@
1
+ module Parakey
2
+ module AccessGrants
3
+ # Creates an access key: permanent for a regular grant, time-windowed for a
4
+ # time-controlled one. Parakey upserts by access, user and window, so a repeat
5
+ # of an identical grant resolves to the existing key instead of duplicating it.
6
+ class Create
7
+ include Parakey::Operation
8
+
9
+ # @rbs (attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
10
+ def call(attributes:)
11
+ argument! access_id: :required if attributes[:access_id].blank?
12
+ argument! kind: :invalid unless KINDS.include?(attributes[:kind])
13
+ argument! recipient_email: :required if attributes[:recipient_email].blank?
14
+
15
+ validate_validity_window!(attributes)
16
+
17
+ body = Parakey.request(:post, 'access-keys', params: payload(attributes))
18
+
19
+ Success(external_id: body.fetch(:id), metadata: { raw: body })
20
+ end
21
+
22
+ private
23
+
24
+ # A regular grant never carries a window; a time-controlled one always
25
+ # does, ending after it starts.
26
+ # @rbs (::Hash[::Symbol, untyped] attributes) -> void
27
+ def validate_validity_window!(attributes)
28
+ if attributes[:kind] == KIND_TIME_CONTROLLED
29
+ argument! validity_window: :required if attributes[:start_date].blank? || attributes[:end_date].blank?
30
+ argument! end_date: :invalid if attributes[:start_date].in_time_zone >= attributes[:end_date].in_time_zone
31
+ elsif attributes[:start_date].present? || attributes[:end_date].present?
32
+ argument! validity_window: :invalid
33
+ end
34
+ end
35
+
36
+ # @rbs (::Hash[::Symbol, untyped] attributes) -> ::Hash[::Symbol, untyped]
37
+ def payload(attributes)
38
+ {
39
+ access: { id: attributes[:access_id] },
40
+ user: { email: attributes[:recipient_email] },
41
+ startDate: attributes[:start_date]&.iso8601,
42
+ expirationDate: attributes[:end_date]&.iso8601
43
+ }.compact
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,19 @@
1
+ module Parakey
2
+ module AccessGrants
3
+ # Deletes an access key. A key that is already gone answers 404, which reads
4
+ # as success: the desired end state is reached either way.
5
+ class Delete
6
+ include Parakey::Operation
7
+
8
+ # @rbs (attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[bool]
9
+ def call(attributes:)
10
+ argument! external_id: :required if attributes[:external_id].blank?
11
+
12
+ path = "access-keys/#{ERB::Util.url_encode(attributes[:external_id])}"
13
+ Parakey.request(:delete, path, accepted_statuses: [404])
14
+
15
+ Success(true)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,42 @@
1
+ module Parakey
2
+ module AccessGrants
3
+ # Lists the domain's access keys, one page per call.
4
+ class Index
5
+ include Parakey::Operation
6
+
7
+ # @rbs (?skip: ::Integer, ?take: ::Integer) -> Dry::Monads::Result[::Array[::Hash[::Symbol, untyped]]]
8
+ def call(skip: 0, take: 100)
9
+ body = Parakey.request(:get, 'access-keys', params: page_params(skip, take))
10
+
11
+ Success(body.fetch(:items, []).map { |item| attributes(item) })
12
+ end
13
+
14
+ private
15
+
16
+ # @rbs (::Hash[::Symbol, untyped] item) -> ::Hash[::Symbol, untyped]
17
+ def attributes(item)
18
+ user = item[:user] || {}
19
+
20
+ {
21
+ external_id: item[:id],
22
+ label: item[:aliasTag],
23
+ recipient_email: user[:email],
24
+ recipient_name: user[:name],
25
+ status: item[:enabled] ? STATUS_ACTIVE : STATUS_INACTIVE,
26
+ kind: grant_kind(item),
27
+ access_id: item.dig(:access, :id),
28
+ start_date: item[:startDate],
29
+ end_date: item[:expirationDate]
30
+ }
31
+ end
32
+
33
+ # A key with both dates is time-controlled; anything else is permanent.
34
+ # @rbs (::Hash[::Symbol, untyped] item) -> ::String
35
+ def grant_kind(item)
36
+ return KIND_TIME_CONTROLLED if item[:startDate].present? && item[:expirationDate].present?
37
+
38
+ KIND_REGULAR
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,13 @@
1
+ module Parakey
2
+ # The normalized vocabulary the access-key operations return. Parakey access
3
+ # keys come in two kinds: a permanent key (regular) and a time-windowed key
4
+ # (time-controlled access, tca); a key is active while enabled.
5
+ module AccessGrants
6
+ KIND_REGULAR = 'regular'.freeze
7
+ KIND_TIME_CONTROLLED = 'tca'.freeze
8
+ KINDS = [KIND_REGULAR, KIND_TIME_CONTROLLED].freeze #: ::Array[::String]
9
+
10
+ STATUS_ACTIVE = 'active'.freeze
11
+ STATUS_INACTIVE = 'inactive'.freeze
12
+ end
13
+ end
@@ -0,0 +1,20 @@
1
+ module Parakey
2
+ # A rejected Parakey 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,49 @@
1
+ module Parakey
2
+ BASE_URL = 'https://api.parakey.co/'.freeze #: ::String
3
+
4
+ # Access-key POSTs create grants without a provider idempotency key, so a write
5
+ # never retries; reads retry on a status or a connection-level failure.
6
+ RETRY_OPTIONS = {
7
+ max: 3,
8
+ interval: 0.5,
9
+ interval_randomness: 0.5,
10
+ backoff_factor: 2,
11
+ retry_statuses: [429, 502, 503, 504],
12
+ methods: %i[get delete],
13
+ exceptions: [*Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS, Faraday::ConnectionFailed, Faraday::SSLError]
14
+ }.freeze #: ::Hash[::Symbol, untyped]
15
+
16
+ # @rbs () -> Faraday::Connection
17
+ def self.connection
18
+ @connection ||= Faraday.new(url: config.base_url) do |builder|
19
+ builder.request :json
20
+ builder.request :retry, RETRY_OPTIONS
21
+ builder.response :json, parser_options: { symbolize_names: true, mode: :rails, decoder: [Oj, :load] }
22
+ builder.options[:timeout] = config.timeout
23
+ end
24
+ end
25
+
26
+ # @rbs (::Symbol method, ::String path, ?params: ::Hash[::Symbol, untyped]?, ?accepted_statuses: ::Array[::Integer]) -> untyped
27
+ def self.request(method, path, params: nil, accepted_statuses: [])
28
+ response = connection.public_send(method, path, params, auth_headers)
29
+
30
+ return response.body if response.success? || accepted_statuses.include?(response.status)
31
+
32
+ raise ApiError.new(error_message(response), status: response.status)
33
+ end
34
+
35
+ # @rbs () -> ::Hash[::String, ::String]
36
+ private_class_method def self.auth_headers
37
+ access_token = config.access_token.presence || raise(Error, 'Parakey access token is required.')
38
+
39
+ { 'Access-Token' => access_token }
40
+ end
41
+
42
+ # @rbs (Faraday::Response response) -> ::String
43
+ private_class_method def self.error_message(response)
44
+ body = response.body
45
+ message = body.is_a?(Hash) ? body[:message] : nil
46
+
47
+ message.presence || body.to_s.presence || "Provider request failed with status #{response.status}"
48
+ end
49
+ end
@@ -0,0 +1,16 @@
1
+ module Parakey
2
+ # Injected by the host app (see the Parakey.configure initializer): the static
3
+ # API access token and the domain every call scopes to. Keeping these out of
4
+ # the gem is what makes it host-agnostic.
5
+ #
6
+ # Parakey issues one long-lived access token per account (no OAuth minting), so
7
+ # there is no token store to inject; the host reads the token from its own
8
+ # credentials and hands it over here.
9
+ class Config
10
+ attr_accessor :access_token, :domain_id, :base_url, :timeout
11
+
12
+ def initialize
13
+ @base_url = BASE_URL
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,6 @@
1
+ module Parakey
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,34 @@
1
+ module Parakey
2
+ module Keys
3
+ # Lists the domain's accesses (the reusable "keys" a grant points at),
4
+ # restricted to API-enabled ones. Extra wire filters merge in verbatim, e.g.
5
+ # { deviceIds: { in: [...] } }.
6
+ class Index
7
+ include Parakey::Operation
8
+
9
+ ACCESS_FILTERS = {
10
+ allowedOrigins: 'api',
11
+ enabled: true
12
+ }.freeze
13
+
14
+ # @rbs (?skip: ::Integer, ?take: ::Integer, ?filters: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::Array[::Hash[::Symbol, untyped]]]
15
+ def call(skip: 0, take: 100, filters: {})
16
+ params = ACCESS_FILTERS.merge(filters, page_params(skip, take))
17
+ body = Parakey.request(:get, 'accesses', params:)
18
+
19
+ Success(body.fetch(:items, []).map { |item| attributes(item) })
20
+ end
21
+
22
+ private
23
+
24
+ # @rbs (::Hash[::Symbol, untyped] item) -> ::Hash[::Symbol, untyped]
25
+ def attributes(item)
26
+ {
27
+ external_id: item[:id],
28
+ name: item[:name].presence,
29
+ device_ids: item.fetch(:devices, []).pluck(:id)
30
+ }.compact
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,60 @@
1
+ module Parakey
2
+ module Locks
3
+ # Lists the domain's devices, each with the accesses that reach it. One page
4
+ # of devices per call; the accesses join pages through Keys::Index on its own.
5
+ class Index
6
+ include Parakey::Operation
7
+
8
+ BATTERY_CHARGES = {
9
+ 'high' => 100,
10
+ 'medium' => 75,
11
+ 'low' => 50,
12
+ 'veryLow' => 25
13
+ }.freeze #: ::Hash[::String, ::Integer]
14
+
15
+ ACCESS_BATCH_SIZE = 500
16
+
17
+ # The wire reports no device status; a device the API lists reads as online.
18
+ STATUS_ONLINE = 'online'.freeze
19
+
20
+ # @rbs (?skip: ::Integer, ?take: ::Integer) -> Dry::Monads::Result[::Array[::Hash[::Symbol, untyped]]]
21
+ def call(skip: 0, take: 100)
22
+ devices = Parakey.request(:get, 'devices', params: page_params(skip, take)).fetch(:items, [])
23
+ accesses = accesses(devices.pluck(:id))
24
+
25
+ Success(devices.map { |device| attributes(device, accesses) })
26
+ end
27
+
28
+ private
29
+
30
+ # @rbs (::Array[::String] device_ids) -> ::Array[::Hash[::Symbol, untyped]]
31
+ def accesses(device_ids)
32
+ return [] if device_ids.empty?
33
+
34
+ filters = { deviceIds: { in: device_ids } }
35
+ result = []
36
+
37
+ loop do
38
+ page = Parakey::Keys::Index.new.call!(skip: result.size, take: ACCESS_BATCH_SIZE, filters:).value!
39
+ result.concat(page)
40
+ break if page.size < ACCESS_BATCH_SIZE
41
+ end
42
+
43
+ result
44
+ end
45
+
46
+ # @rbs (::Hash[::Symbol, untyped] device, ::Array[::Hash[::Symbol, untyped]] accesses) -> ::Hash[::Symbol, untyped]
47
+ def attributes(device, accesses)
48
+ reaching = accesses.select { |access| access[:device_ids].include?(device[:id]) }
49
+
50
+ {
51
+ external_id: device[:id],
52
+ name: device[:name],
53
+ status: STATUS_ONLINE,
54
+ charge: BATTERY_CHARGES[device[:batteryStatus]],
55
+ keys: reaching.map { |access| access.slice(:external_id, :name) }
56
+ }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,23 @@
1
+ module Parakey
2
+ module Members
3
+ # Removes a domain member by email. An unknown member answers 404, which
4
+ # reads as success: the member is gone either way.
5
+ class Destroy
6
+ include Parakey::Operation
7
+
8
+ # @rbs (email: ::String?) -> Dry::Monads::Result[bool]
9
+ def call(email:)
10
+ argument! email: :required if email.blank?
11
+ argument! domain_id: :required if Parakey.config.domain_id.blank?
12
+
13
+ filter = {
14
+ domainId: Parakey.config.domain_id,
15
+ email:
16
+ }
17
+ Parakey.request(:post, 'members/delete', params: { filter: }, accepted_statuses: [404])
18
+
19
+ Success(true)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ module Parakey
2
+ module Members
3
+ # Lists the domain's members, one page per call, items in the wire's own
4
+ # shape (symbolized camelCase keys). Wire filters merge in verbatim, e.g.
5
+ # { status: 'emailProblem' } for members whose invitation email bounced.
6
+ class Index
7
+ include Parakey::Operation
8
+
9
+ # @rbs (?skip: ::Integer, ?take: ::Integer, ?filters: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::Array[::Hash[::Symbol, untyped]]]
10
+ def call(skip: 0, take: 100, filters: {})
11
+ body = Parakey.request(:get, 'members', params: filters.merge(page_params(skip, take)))
12
+
13
+ Success(body.fetch(:items, []))
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,45 @@
1
+ module Parakey
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!(**attrs)
29
+ key, reason = attrs.first
30
+ raise Parakey::ArgumentError, "#{key}: #{reason}"
31
+ end
32
+
33
+ # Lists page 1-based by page and limit on the wire; the operations take an item offset.
34
+ # @rbs (::Integer skip, ::Integer take) -> ::Hash[::Symbol, untyped]
35
+ def page_params(skip, take)
36
+ argument! take: :invalid unless take.to_i.positive?
37
+
38
+ {
39
+ domainId: Parakey.config.domain_id,
40
+ limit: take,
41
+ page: skip.div(take) + 1
42
+ }
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,3 @@
1
+ module Parakey
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -0,0 +1 @@
1
+ require_relative 'parakey'
data/lib/parakey.rb ADDED
@@ -0,0 +1,37 @@
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 'parakey/version'
10
+ require_relative 'parakey/error'
11
+ require_relative 'parakey/api_error'
12
+ require_relative 'parakey/config'
13
+ require_relative 'parakey/operation'
14
+ require_relative 'parakey/client'
15
+ require_relative 'parakey/access_grants'
16
+ require_relative 'parakey/access_events/normalize'
17
+ require_relative 'parakey/access_grants/create'
18
+ require_relative 'parakey/access_grants/delete'
19
+ require_relative 'parakey/access_grants/index'
20
+ require_relative 'parakey/keys/index'
21
+ require_relative 'parakey/locks/index'
22
+ require_relative 'parakey/members/destroy'
23
+ require_relative 'parakey/members/index'
24
+
25
+ module Parakey
26
+ class << self
27
+ # @rbs () { (Config) -> void } -> void
28
+ def configure
29
+ yield(config)
30
+ end
31
+
32
+ # @rbs () -> Config
33
+ def config
34
+ @config ||= Config.new
35
+ end
36
+ end
37
+ end
data/sig/parakey.rbs ADDED
@@ -0,0 +1,107 @@
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 Parakey
4
+ VERSION: ::String
5
+
6
+ BASE_URL: ::String
7
+ RETRY_OPTIONS: ::Hash[::Symbol, untyped]
8
+
9
+ def self.configure: () { (Config) -> void } -> void
10
+ def self.config: () -> Config
11
+
12
+ def self.connection: () -> untyped
13
+ def self.request: (::Symbol method, ::String path, ?params: ::Hash[::Symbol, untyped]?, ?accepted_statuses: ::Array[::Integer]) -> untyped
14
+
15
+ class Error < ::StandardError
16
+ end
17
+
18
+ # Raised by operations before any network call when the arguments are unusable.
19
+ class ArgumentError < Error
20
+ end
21
+
22
+ # A rejected Parakey response; #unapplied? marks a write that never landed.
23
+ class ApiError < Error
24
+ attr_reader status: ::Integer
25
+
26
+ def initialize: (::String message, status: ::Integer) -> void
27
+
28
+ def unapplied?: () -> bool
29
+ end
30
+
31
+ class Config
32
+ attr_accessor access_token: ::String?
33
+ attr_accessor domain_id: ::String?
34
+ attr_accessor base_url: ::String
35
+ attr_accessor timeout: (::Integer | ::Float)?
36
+ end
37
+
38
+ # #call! returns the same Result as #call, raising the failure instead of
39
+ # wrapping it — hence the `alias call! call` on every operation below.
40
+ module Operation
41
+ def call!: (*untyped, **untyped) -> untyped
42
+ end
43
+
44
+ module AccessGrants
45
+ KIND_REGULAR: ::String
46
+ KIND_TIME_CONTROLLED: ::String
47
+ KINDS: ::Array[::String]
48
+ STATUS_ACTIVE: ::String
49
+ STATUS_INACTIVE: ::String
50
+
51
+ class Create
52
+ def call: (attributes: ::Hash[::Symbol, untyped]) -> ::Dry::Monads::Result[::Hash[::Symbol, untyped]]
53
+ alias call! call
54
+ end
55
+
56
+ class Delete
57
+ def call: (attributes: ::Hash[::Symbol, untyped]) -> ::Dry::Monads::Result[bool]
58
+ alias call! call
59
+ end
60
+
61
+ class Index
62
+ def call: (?skip: ::Integer, ?take: ::Integer) -> ::Dry::Monads::Result[::Array[::Hash[::Symbol, untyped]]]
63
+ alias call! call
64
+ end
65
+ end
66
+
67
+ module AccessEvents
68
+ class Normalize
69
+ SYNC_ACTIVITIES: ::Array[::String]
70
+
71
+ def call: (item: ::Hash[::Symbol, untyped]) -> ::Dry::Monads::Result[::Hash[::Symbol, untyped]]
72
+ alias call! call
73
+ end
74
+ end
75
+
76
+ module Keys
77
+ class Index
78
+ ACCESS_FILTERS: ::Hash[::Symbol, untyped]
79
+
80
+ def call: (?skip: ::Integer, ?take: ::Integer, ?filters: ::Hash[::Symbol, untyped]) -> ::Dry::Monads::Result[::Array[::Hash[::Symbol, untyped]]]
81
+ alias call! call
82
+ end
83
+ end
84
+
85
+ module Locks
86
+ class Index
87
+ BATTERY_CHARGES: ::Hash[::String, ::Integer]
88
+ ACCESS_BATCH_SIZE: ::Integer
89
+ STATUS_ONLINE: ::String
90
+
91
+ def call: (?skip: ::Integer, ?take: ::Integer) -> ::Dry::Monads::Result[::Array[::Hash[::Symbol, untyped]]]
92
+ alias call! call
93
+ end
94
+ end
95
+
96
+ module Members
97
+ class Index
98
+ def call: (?skip: ::Integer, ?take: ::Integer, ?filters: ::Hash[::Symbol, untyped]) -> ::Dry::Monads::Result[::Array[::Hash[::Symbol, untyped]]]
99
+ alias call! call
100
+ end
101
+
102
+ class Destroy
103
+ def call: (email: ::String?) -> ::Dry::Monads::Result[bool]
104
+ alias call! call
105
+ end
106
+ end
107
+ end
metadata ADDED
@@ -0,0 +1,136 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: parakey-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 Parakey API endpoint, returning dry-monads Results,
83
+ with retries and response normalization. Framework-free: the host injects the access
84
+ token and the domain id.'
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - CHANGELOG.md
90
+ - LICENSE
91
+ - README.md
92
+ - lib/parakey-client.rb
93
+ - lib/parakey.rb
94
+ - lib/parakey/access_events/normalize.rb
95
+ - lib/parakey/access_grants.rb
96
+ - lib/parakey/access_grants/create.rb
97
+ - lib/parakey/access_grants/delete.rb
98
+ - lib/parakey/access_grants/index.rb
99
+ - lib/parakey/api_error.rb
100
+ - lib/parakey/client.rb
101
+ - lib/parakey/config.rb
102
+ - lib/parakey/error.rb
103
+ - lib/parakey/keys/index.rb
104
+ - lib/parakey/locks/index.rb
105
+ - lib/parakey/members/destroy.rb
106
+ - lib/parakey/members/index.rb
107
+ - lib/parakey/operation.rb
108
+ - lib/parakey/version.rb
109
+ - sig/parakey.rbs
110
+ homepage: https://github.com/guestitab/parakey-client
111
+ licenses:
112
+ - MIT
113
+ metadata:
114
+ allowed_push_host: https://rubygems.org
115
+ source_code_uri: https://github.com/guestitab/parakey-client
116
+ changelog_uri: https://github.com/guestitab/parakey-client/blob/staging/CHANGELOG.md
117
+ bug_tracker_uri: https://github.com/guestitab/parakey-client/issues
118
+ rubygems_mfa_required: 'true'
119
+ rdoc_options: []
120
+ require_paths:
121
+ - lib
122
+ required_ruby_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '4.0'
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ requirements: []
133
+ rubygems_version: 4.0.23
134
+ specification_version: 4
135
+ summary: Ruby client for the Parakey API
136
+ test_files: []