praxicraft 0.1.1
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 +19 -0
- data/LICENSE +21 -0
- data/README.md +233 -0
- data/lib/praxicraft/client.rb +289 -0
- data/lib/praxicraft/errors.rb +51 -0
- data/lib/praxicraft/resources/assessments.rb +78 -0
- data/lib/praxicraft/resources/invites.rb +55 -0
- data/lib/praxicraft/resources/org.rb +27 -0
- data/lib/praxicraft/resources/pipelines.rb +55 -0
- data/lib/praxicraft/resources/results.rb +86 -0
- data/lib/praxicraft/resources/webhooks.rb +67 -0
- data/lib/praxicraft/version.rb +5 -0
- data/lib/praxicraft/webhooks.rb +34 -0
- data/lib/praxicraft.rb +15 -0
- metadata +88 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 26c92d762b713245618649e5fe3da4e13edcf98568d5dcfd19d82dd372131c2b
|
|
4
|
+
data.tar.gz: 6e8b264590158078499b9b9400845f2164b6e288f0220cc52cde6dc3ff5b4c19
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 519a74d3ed0df3cbfa62c4cf3c09b238ca72341af4564ab62a1458ab2eff22f182f5599490a46faf5d58ab6672c5b87c5e75417d74ed05f4ddb5d98894097c08
|
|
7
|
+
data.tar.gz: c99f3744dd05bab9bee7858565066dc581aa2c1a91b66c3904696c30d6e66b976babb11dac29c723f231a4fafe9c677e29225743c5a3e60ce189bfeff7105f4b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.1] — 2026-08-21
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Align error mapping and retry behaviour with the Node SDK (status-based exceptions, 8s `Retry-After` cap).
|
|
8
|
+
- Fix path-segment encoding so spaces become `%20`.
|
|
9
|
+
|
|
10
|
+
## [0.1.0] — 2026-08-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Initial Assess Public API SDK.
|
|
15
|
+
- `Client` with Bearer API-key auth (`PRAXICRAFT_API_KEY` / `PRAXICRAFT_API_BASE_URL`).
|
|
16
|
+
- Automatic retries on `429` / `5xx` / transport errors (default 2), honouring `Retry-After`.
|
|
17
|
+
- Typed / mapped errors from `{ error: { code, message } }`.
|
|
18
|
+
- Resources: org, assessments, invites, results, webhooks, pipelines.
|
|
19
|
+
- Webhook signature helper for `X-Praxicraft-Signature`.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PraxiCraft
|
|
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,233 @@
|
|
|
1
|
+
# Praxicraft Assess Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby client for the **[Praxicraft Assess](https://assess.praxicraft.com)** Public API.
|
|
4
|
+
|
|
5
|
+
Use it to invite candidates, check invite quota, manage webhooks, enroll hiring pipelines, and fetch results from your ATS, backend, or automation scripts.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
gem install praxicraft
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Until RubyGems publish, install from GitHub:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Gemfile
|
|
15
|
+
gem "praxicraft", git: "https://github.com/praxicraft-platform/praxicraft-ruby.git"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Requires Ruby 3.1+.** Full API reference: [docs.praxicraft.com/sdks/ruby](https://docs.praxicraft.com/sdks/ruby)
|
|
19
|
+
|
|
20
|
+
## Table of Contents
|
|
21
|
+
|
|
22
|
+
- [Authentication](#authentication)
|
|
23
|
+
- [Quickstart](#quickstart)
|
|
24
|
+
- [What you can do](#what-you-can-do)
|
|
25
|
+
- [Check invite quota before bulk sends](#check-invite-quota-before-bulk-sends)
|
|
26
|
+
- [Bulk invites](#bulk-invites)
|
|
27
|
+
- [Build and activate an assessment via API](#build-and-activate-an-assessment-via-api)
|
|
28
|
+
- [Register and test a webhook](#register-and-test-a-webhook)
|
|
29
|
+
- [Enroll into a hiring pipeline](#enroll-into-a-hiring-pipeline)
|
|
30
|
+
- [Paginate cohort results](#paginate-cohort-results)
|
|
31
|
+
- [Verify webhook signatures](#verify-webhook-signatures)
|
|
32
|
+
- [Errors](#errors)
|
|
33
|
+
- [Requirements & support](#requirements--support)
|
|
34
|
+
- [License](#license)
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Authentication
|
|
39
|
+
|
|
40
|
+
Create an organisation API key in Assess:
|
|
41
|
+
|
|
42
|
+
**Assess → Developer → API Keys** → create key → copy `ct_live_…` (shown once).
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export PRAXICRAFT_API_KEY="ct_live_xxxxxxxxxxxxxxxx"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or pass the key when constructing the client:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
require "praxicraft"
|
|
52
|
+
|
|
53
|
+
client = Praxicraft::Client.new(api_key: "ct_live_xxxxxxxxxxxxxxxx")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Optional: override the API host with `PRAXICRAFT_API_BASE_URL` or `Praxicraft::Client.new(base_url: "...")`.
|
|
57
|
+
Default host: `https://assess.praxicraft.com`.
|
|
58
|
+
|
|
59
|
+
Never commit API keys. Prefer environment variables or a secrets manager.
|
|
60
|
+
|
|
61
|
+
Scopes and rotation: [Authentication](https://docs.praxicraft.com/authentication)
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
require "praxicraft"
|
|
69
|
+
|
|
70
|
+
client = Praxicraft::Client.new # reads PRAXICRAFT_API_KEY
|
|
71
|
+
|
|
72
|
+
page = client.assessments.list
|
|
73
|
+
page.fetch("results", []).each do |assessment|
|
|
74
|
+
puts "#{assessment["slug"]} #{assessment["status"]}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Invite a candidate (idempotent on email — safe to retry)
|
|
78
|
+
invite = client.invites.create(
|
|
79
|
+
"senior-backend-screen",
|
|
80
|
+
email: "candidate@example.com",
|
|
81
|
+
name: "Jane Doe",
|
|
82
|
+
send_email: true
|
|
83
|
+
)
|
|
84
|
+
puts "#{invite["invite_token"]} #{invite["invite_url"]}"
|
|
85
|
+
|
|
86
|
+
result = client.results.retrieve(invite["invite_token"])
|
|
87
|
+
p result
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Responses are **flat JSON** (same shape as the Public API — no `{ "data": … }` wrapper).
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## What you can do
|
|
95
|
+
|
|
96
|
+
| Resource | Common methods |
|
|
97
|
+
|----------|----------------|
|
|
98
|
+
| `client.org` | `retrieve`, `stats` |
|
|
99
|
+
| `client.assessments` | `list`, `retrieve`, `create`, `update`, `activate`, `list_cases`, `attach_cases`, `replace_cases`, `remove_case` |
|
|
100
|
+
| `client.invites` | `create`, `bulk_create`, `list`, `retrieve`, `remind`, `cancel` |
|
|
101
|
+
| `client.results` | `list`, `retrieve`, `iter_all` |
|
|
102
|
+
| `client.webhooks` | `list`, `create`, `retrieve`, `update`, `delete`, `test`, `deliveries` |
|
|
103
|
+
| `client.pipelines` | `list`, `retrieve`, `enroll`, `bulk_enroll`, `list_enrollments`, `get_enrollment` |
|
|
104
|
+
| `Praxicraft::Webhooks.verify_signature` | Verify `X-Praxicraft-Signature` on webhook payloads |
|
|
105
|
+
|
|
106
|
+
All paths target `/api/v1/public/…` on the Assess host.
|
|
107
|
+
|
|
108
|
+
### Check invite quota before bulk sends
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
org = client.org.retrieve
|
|
112
|
+
if (org["invites_remaining"] || 0) < candidates.length
|
|
113
|
+
abort "Not enough invites remaining this month"
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Bulk invites
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
client.invites.bulk_create(
|
|
121
|
+
"senior-backend-screen",
|
|
122
|
+
[
|
|
123
|
+
{ "email" => "a@example.com", "name" => "Alex" },
|
|
124
|
+
{ "email" => "b@example.com", "name" => "Blair" }
|
|
125
|
+
],
|
|
126
|
+
send_email: true
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Build and activate an assessment via API
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
assessment = client.assessments.create(title: "Backend screen")
|
|
134
|
+
client.assessments.attach_cases(
|
|
135
|
+
assessment["slug"],
|
|
136
|
+
cases: [{ "case_id" => "<platform-or-org-case-uuid>", "source" => "platform" }]
|
|
137
|
+
)
|
|
138
|
+
client.assessments.activate(assessment["slug"])
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Register and test a webhook
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
hook = client.webhooks.create(
|
|
145
|
+
url: "https://example.com/hooks/praxicraft",
|
|
146
|
+
events: ["assessment.completed", "candidate.passed"]
|
|
147
|
+
)
|
|
148
|
+
# Store hook["secret_key"] (whsec_…) — shown once
|
|
149
|
+
client.webhooks.test(hook["id"])
|
|
150
|
+
client.webhooks.update(hook["id"], is_active: true)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Enroll into a hiring pipeline
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
enrollment = client.pipelines.enroll(
|
|
157
|
+
"grad-2025",
|
|
158
|
+
email: "alex@example.com",
|
|
159
|
+
name: "Alex Lee",
|
|
160
|
+
send_email: true
|
|
161
|
+
)
|
|
162
|
+
status = client.pipelines.get_enrollment(enrollment["enrollment_id"])
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Paginate cohort results
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
client.results.iter_all("senior-backend-screen", page_size: 50) do |row|
|
|
169
|
+
puts "#{row["email"]} #{row["score_percentage"]} #{row["passed"]}"
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Verify webhook signatures
|
|
174
|
+
|
|
175
|
+
Assess signs the **raw request body** with your webhook secret (`whsec_…`):
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
def handle_webhook(raw_body, signature_header, secret)
|
|
179
|
+
Praxicraft::Webhooks.verify_signature(secret, raw_body, signature_header)
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Header format: `X-Praxicraft-Signature: sha256=<hex>`
|
|
184
|
+
|
|
185
|
+
Event catalog and payload examples: [Webhooks](https://docs.praxicraft.com/webhooks)
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Errors
|
|
190
|
+
|
|
191
|
+
Public API errors look like:
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
{
|
|
195
|
+
"error": {
|
|
196
|
+
"code": "INSUFFICIENT_SCOPE",
|
|
197
|
+
"message": "This API key does not have the 'candidates:read' scope."
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The SDK raises typed exceptions. **Branch on `exc.error_code` (or `exc.code`)**, not the message text:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
begin
|
|
206
|
+
client.invites.create("demo", email: "candidate@example.com")
|
|
207
|
+
rescue Praxicraft::ValidationError => exc
|
|
208
|
+
puts exc.error_code, exc.details
|
|
209
|
+
rescue Praxicraft::InsufficientScopeError => exc
|
|
210
|
+
puts exc.error_code
|
|
211
|
+
rescue Praxicraft::AuthenticationError => exc
|
|
212
|
+
puts exc.error_code
|
|
213
|
+
rescue Praxicraft::RateLimitError => exc
|
|
214
|
+
puts exc.retry_after
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Error codes: [Errors](https://docs.praxicraft.com/errors)
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Requirements & support
|
|
223
|
+
|
|
224
|
+
- Ruby **3.1**, **3.2**, or **3.3+**
|
|
225
|
+
- Uses stdlib `Net::HTTP` (no extra runtime gems)
|
|
226
|
+
- Product docs: [docs.praxicraft.com](https://docs.praxicraft.com)
|
|
227
|
+
- Issues: [GitHub Issues](https://github.com/praxicraft-platform/praxicraft-ruby/issues)
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "time"
|
|
7
|
+
require "openssl"
|
|
8
|
+
|
|
9
|
+
module Praxicraft
|
|
10
|
+
class Client
|
|
11
|
+
DEFAULT_BASE_URL = "https://assess.praxicraft.com"
|
|
12
|
+
DEFAULT_API_PREFIX = "/api/v1/public"
|
|
13
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
14
|
+
DEFAULT_MAX_RETRIES = 2
|
|
15
|
+
|
|
16
|
+
attr_reader :api_key, :base_url, :api_prefix, :timeout_seconds, :max_retries
|
|
17
|
+
attr_reader :org, :assessments, :invites, :results, :webhooks, :pipelines
|
|
18
|
+
|
|
19
|
+
# Options keys: :api_key, :base_url, :timeout_seconds, :max_retries, :http_handler
|
|
20
|
+
# http_handler: callable(method, url, headers, body) -> { status:, headers:, body: }
|
|
21
|
+
def initialize(options = nil, **kwargs)
|
|
22
|
+
opts = {}
|
|
23
|
+
opts.merge!(options.transform_keys(&:to_sym)) if options.is_a?(Hash)
|
|
24
|
+
opts.merge!(kwargs)
|
|
25
|
+
|
|
26
|
+
key = (opts[:api_key] || ENV["PRAXICRAFT_API_KEY"] || "").to_s.strip
|
|
27
|
+
if key.empty?
|
|
28
|
+
raise APIError.new(
|
|
29
|
+
"No API key provided. Pass api_key or set PRAXICRAFT_API_KEY.",
|
|
30
|
+
"MISSING_API_KEY"
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
base = (opts[:base_url] || ENV["PRAXICRAFT_API_BASE_URL"] || DEFAULT_BASE_URL).to_s.strip
|
|
35
|
+
base = base.sub(%r{/*\z}, "")
|
|
36
|
+
if base.empty?
|
|
37
|
+
raise APIError.new("baseUrl must be a non-empty URL.", "INVALID_BASE_URL")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@api_key = key
|
|
41
|
+
@base_url = base
|
|
42
|
+
@api_prefix = DEFAULT_API_PREFIX
|
|
43
|
+
@timeout_seconds = (opts[:timeout_seconds] || DEFAULT_TIMEOUT_SECONDS).to_f
|
|
44
|
+
@max_retries = [0, (opts[:max_retries] || DEFAULT_MAX_RETRIES).to_i].max
|
|
45
|
+
@http_handler = opts[:http_handler]
|
|
46
|
+
|
|
47
|
+
@org = Resources::Org.new(self)
|
|
48
|
+
@assessments = Resources::Assessments.new(self)
|
|
49
|
+
@invites = Resources::Invites.new(self)
|
|
50
|
+
@results = Resources::Results.new(self)
|
|
51
|
+
@webhooks = Resources::Webhooks.new(self)
|
|
52
|
+
@pipelines = Resources::Pipelines.new(self)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get(path, params = nil)
|
|
56
|
+
request("GET", path, params: params)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def post(path, json = nil)
|
|
60
|
+
request("POST", path, json: json)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def put(path, json = nil)
|
|
64
|
+
request("PUT", path, json: json)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def patch(path, json = nil)
|
|
68
|
+
request("PATCH", path, json: json)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def delete(path, json = nil)
|
|
72
|
+
request("DELETE", path, json: json)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.path_segment(value, label = "id")
|
|
76
|
+
text = value.to_s.strip
|
|
77
|
+
if text.empty?
|
|
78
|
+
raise APIError.new("#{label} must be a non-empty string", "INVALID_PATH")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Path-component encoding (spaces as %20), matching Node encodeURIComponent.
|
|
82
|
+
URI.encode_www_form_component(text).gsub("+", "%20")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def path_segment(value, label = "id")
|
|
86
|
+
self.class.path_segment(value, label)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def request(method, path, params: nil, json: nil)
|
|
90
|
+
attempts = @max_retries + 1
|
|
91
|
+
last_error = nil
|
|
92
|
+
|
|
93
|
+
attempts.times do |attempt|
|
|
94
|
+
if attempt.positive?
|
|
95
|
+
retry_after = nil
|
|
96
|
+
if last_error.is_a?(APIStatusError)
|
|
97
|
+
retry_after = last_error.headers["retry-after"]
|
|
98
|
+
end
|
|
99
|
+
sleep(self.class.retry_delay_seconds(attempt - 1, retry_after))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
begin
|
|
103
|
+
return request_once(method, path, params: params, json: json)
|
|
104
|
+
rescue APIConnectionError => e
|
|
105
|
+
last_error = e
|
|
106
|
+
next if attempt < attempts - 1
|
|
107
|
+
|
|
108
|
+
raise
|
|
109
|
+
rescue APIStatusError => e
|
|
110
|
+
last_error = e
|
|
111
|
+
next if self.class.should_retry_status?(e.status_code) && attempt < attempts - 1
|
|
112
|
+
|
|
113
|
+
raise
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
raise last_error || APIConnectionError.new
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
RETRY_BASE_SECONDS = 0.5
|
|
121
|
+
RETRY_CAP_SECONDS = 8.0
|
|
122
|
+
RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504].freeze
|
|
123
|
+
|
|
124
|
+
def self.should_retry_status?(status)
|
|
125
|
+
RETRYABLE_STATUS_CODES.include?(status)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.parse_retry_after_seconds(retry_after)
|
|
129
|
+
return nil if retry_after.nil? || retry_after.to_s.strip.empty?
|
|
130
|
+
|
|
131
|
+
text = retry_after.to_s.strip
|
|
132
|
+
return [0.0, text.to_f].max if text.match?(/\A\d+(\.\d+)?\z/)
|
|
133
|
+
|
|
134
|
+
begin
|
|
135
|
+
when_time = Time.httpdate(text)
|
|
136
|
+
[0.0, when_time - Time.now].max
|
|
137
|
+
rescue ArgumentError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.retry_delay_seconds(retry_index, retry_after)
|
|
143
|
+
parsed = parse_retry_after_seconds(retry_after)
|
|
144
|
+
return [parsed, RETRY_CAP_SECONDS].min unless parsed.nil?
|
|
145
|
+
|
|
146
|
+
ceiling = [RETRY_CAP_SECONDS, RETRY_BASE_SECONDS * (2**retry_index)].min
|
|
147
|
+
rand * ceiling
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.raise_for_status(status_code, body, headers, raw)
|
|
151
|
+
error = {}
|
|
152
|
+
if body.is_a?(Hash) && body["error"].is_a?(Hash)
|
|
153
|
+
error = body["error"]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
code = error["code"].is_a?(String) ? error["code"] : nil
|
|
157
|
+
message = error["message"].is_a?(String) ? error["message"] : nil
|
|
158
|
+
details = error["details"]
|
|
159
|
+
required_plan = error["required_plan"].is_a?(String) ? error["required_plan"] : nil
|
|
160
|
+
|
|
161
|
+
if message.nil? || message.empty?
|
|
162
|
+
trimmed = raw.to_s.strip
|
|
163
|
+
message = trimmed.empty? ? "API request failed with status #{status_code}." : trimmed[0, 500]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
retry_after = parse_retry_after_seconds(headers["retry-after"])
|
|
167
|
+
|
|
168
|
+
kwargs = {
|
|
169
|
+
status_code: status_code,
|
|
170
|
+
error_code: code,
|
|
171
|
+
details: details,
|
|
172
|
+
response_body: body,
|
|
173
|
+
headers: headers,
|
|
174
|
+
required_plan: required_plan,
|
|
175
|
+
retry_after: retry_after
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
raise AuthenticationError.new(message, **kwargs) if status_code == 401
|
|
179
|
+
raise InsufficientScopeError.new(message, **kwargs) if status_code == 403
|
|
180
|
+
raise NotFoundError.new(message, **kwargs) if status_code == 404
|
|
181
|
+
raise RateLimitError.new(message, **kwargs) if status_code == 429
|
|
182
|
+
raise ValidationError.new(message, **kwargs) if status_code >= 400 && status_code < 500
|
|
183
|
+
|
|
184
|
+
raise APIStatusError.new(message, **kwargs)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def request_once(method, path, params: nil, json: nil)
|
|
190
|
+
url = "#{@base_url}#{@api_prefix}#{path}"
|
|
191
|
+
if params && !params.empty?
|
|
192
|
+
query = URI.encode_www_form(flatten_params(params))
|
|
193
|
+
url = "#{url}#{url.include?('?') ? '&' : '?'}#{query}" unless query.empty?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
headers = {
|
|
197
|
+
"Accept" => "application/json",
|
|
198
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
199
|
+
"User-Agent" => "praxicraft-ruby/#{VERSION}"
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
body = nil
|
|
203
|
+
unless json.nil?
|
|
204
|
+
body = JSON.generate(json)
|
|
205
|
+
headers["Content-Type"] = "application/json"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
begin
|
|
209
|
+
response = if @http_handler
|
|
210
|
+
@http_handler.call(method, url, headers, body)
|
|
211
|
+
else
|
|
212
|
+
net_http_request(method, url, headers, body)
|
|
213
|
+
end
|
|
214
|
+
rescue APIConnectionError
|
|
215
|
+
raise
|
|
216
|
+
rescue StandardError => e
|
|
217
|
+
raise APIConnectionError, e.message
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
status = response[:status] || response["status"]
|
|
221
|
+
resp_headers = normalize_headers(response[:headers] || response["headers"] || {})
|
|
222
|
+
raw = (response[:body] || response["body"] || "").to_s
|
|
223
|
+
|
|
224
|
+
decoded = nil
|
|
225
|
+
unless raw.empty?
|
|
226
|
+
begin
|
|
227
|
+
decoded = JSON.parse(raw)
|
|
228
|
+
rescue JSON::ParserError
|
|
229
|
+
if status.to_i >= 200 && status.to_i < 300
|
|
230
|
+
raise APIError.new("Invalid JSON response (HTTP #{status}).", "INVALID_JSON")
|
|
231
|
+
end
|
|
232
|
+
decoded = raw
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
return decoded if status.to_i >= 200 && status.to_i < 300
|
|
237
|
+
|
|
238
|
+
self.class.raise_for_status(status.to_i, decoded, resp_headers, raw)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def net_http_request(method, url, headers, body)
|
|
242
|
+
uri = URI.parse(url)
|
|
243
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
244
|
+
http.use_ssl = uri.scheme == "https"
|
|
245
|
+
http.open_timeout = @timeout_seconds
|
|
246
|
+
http.read_timeout = @timeout_seconds
|
|
247
|
+
|
|
248
|
+
request = Net::HTTPGenericRequest.new(
|
|
249
|
+
method.upcase,
|
|
250
|
+
!body.nil?,
|
|
251
|
+
true,
|
|
252
|
+
uri.request_uri,
|
|
253
|
+
headers
|
|
254
|
+
)
|
|
255
|
+
request.body = body unless body.nil?
|
|
256
|
+
|
|
257
|
+
res = http.request(request)
|
|
258
|
+
{
|
|
259
|
+
status: res.code.to_i,
|
|
260
|
+
headers: res.each_header.to_h,
|
|
261
|
+
body: res.body.to_s
|
|
262
|
+
}
|
|
263
|
+
rescue Timeout::Error, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError, OpenSSL::SSL::SSLError => e
|
|
264
|
+
raise APIConnectionError, e.message
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def flatten_params(params)
|
|
268
|
+
out = {}
|
|
269
|
+
params.each do |k, v|
|
|
270
|
+
next if v.nil?
|
|
271
|
+
|
|
272
|
+
out[k.to_s] = if v == true
|
|
273
|
+
"true"
|
|
274
|
+
elsif v == false
|
|
275
|
+
"false"
|
|
276
|
+
else
|
|
277
|
+
v
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
out
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def normalize_headers(headers)
|
|
284
|
+
headers.each_with_object({}) do |(k, v), acc|
|
|
285
|
+
acc[k.to_s.downcase] = v.to_s
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Praxicraft
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :error_code
|
|
6
|
+
|
|
7
|
+
def initialize(message, error_code = nil)
|
|
8
|
+
super(message)
|
|
9
|
+
@error_code = error_code
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
alias code error_code
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class APIError < Error; end
|
|
16
|
+
|
|
17
|
+
class APIConnectionError < APIError
|
|
18
|
+
def initialize(message = "Failed to connect to the Praxicraft API.")
|
|
19
|
+
super(message, "CONNECTION_ERROR")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class APIStatusError < APIError
|
|
24
|
+
attr_reader :status_code, :details, :response_body, :headers, :required_plan, :retry_after
|
|
25
|
+
|
|
26
|
+
def initialize(
|
|
27
|
+
message,
|
|
28
|
+
status_code:,
|
|
29
|
+
error_code: nil,
|
|
30
|
+
details: nil,
|
|
31
|
+
response_body: nil,
|
|
32
|
+
headers: {},
|
|
33
|
+
required_plan: nil,
|
|
34
|
+
retry_after: nil
|
|
35
|
+
)
|
|
36
|
+
super(message, error_code)
|
|
37
|
+
@status_code = status_code
|
|
38
|
+
@details = details
|
|
39
|
+
@response_body = response_body
|
|
40
|
+
@headers = headers || {}
|
|
41
|
+
@required_plan = required_plan
|
|
42
|
+
@retry_after = retry_after
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class AuthenticationError < APIStatusError; end
|
|
47
|
+
class InsufficientScopeError < APIStatusError; end
|
|
48
|
+
class NotFoundError < APIStatusError; end
|
|
49
|
+
class ValidationError < APIStatusError; end
|
|
50
|
+
class RateLimitError < APIStatusError; end
|
|
51
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Praxicraft
|
|
4
|
+
module Resources
|
|
5
|
+
class Assessments
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def list(params = nil)
|
|
11
|
+
@client.get("/assessments/", normalize(params))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def retrieve(assessment)
|
|
15
|
+
key = Client.path_segment(assessment, "assessment")
|
|
16
|
+
@client.get("/assessments/#{key}/")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create(fields = nil, **kwargs)
|
|
20
|
+
body = normalize(fields || kwargs)
|
|
21
|
+
@client.post("/assessments/create/", body)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def update(assessment, fields = nil, **kwargs)
|
|
25
|
+
body = normalize(fields || kwargs)
|
|
26
|
+
if body.nil? || body.empty?
|
|
27
|
+
raise APIError.new("update() requires at least one field to change", "INVALID_ARGUMENT")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
key = Client.path_segment(assessment, "assessment")
|
|
31
|
+
@client.patch("/assessments/#{key}/update/", body)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def activate(assessment)
|
|
35
|
+
update(assessment, "status" => "active")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def list_cases(assessment, params = nil)
|
|
39
|
+
key = Client.path_segment(assessment, "assessment")
|
|
40
|
+
@client.get("/assessments/#{key}/cases/", normalize(params))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def attach_cases(assessment, args = nil, **kwargs)
|
|
44
|
+
body = normalize(args || kwargs)
|
|
45
|
+
if body.nil? || body.empty?
|
|
46
|
+
raise APIError.new("attach_cases() requires cases or case_id", "INVALID_ARGUMENT")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
key = Client.path_segment(assessment, "assessment")
|
|
50
|
+
@client.post("/assessments/#{key}/cases/attach/", body)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def replace_cases(assessment, cases, extra = nil, **kwargs)
|
|
54
|
+
key = Client.path_segment(assessment, "assessment")
|
|
55
|
+
body = { "cases" => cases }.merge(normalize(extra || kwargs) || {})
|
|
56
|
+
@client.put("/assessments/#{key}/cases/replace/", body)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def remove_case(assessment, assessment_case_id)
|
|
60
|
+
key = Client.path_segment(assessment, "assessment")
|
|
61
|
+
case_id = assessment_case_id.to_s.strip
|
|
62
|
+
if case_id.empty?
|
|
63
|
+
raise APIError.new("assessment_case_id must be a non-empty string", "INVALID_ARGUMENT")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@client.delete("/assessments/#{key}/cases/remove/", { "assessment_case_id" => case_id })
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def normalize(hash)
|
|
72
|
+
return nil if hash.nil?
|
|
73
|
+
|
|
74
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Praxicraft
|
|
4
|
+
module Resources
|
|
5
|
+
class Invites
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def list(params = nil)
|
|
11
|
+
@client.get("/invites/", normalize(params))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def retrieve(invite_token)
|
|
15
|
+
token = Client.path_segment(invite_token, "invite_token")
|
|
16
|
+
@client.get("/invites/#{token}/")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create(assessment, args = nil, **kwargs)
|
|
20
|
+
body = normalize(args || kwargs) || {}
|
|
21
|
+
email = body["email"]
|
|
22
|
+
if email.nil? || email.to_s.strip.empty?
|
|
23
|
+
raise APIError.new("email is required", "INVALID_ARGUMENT")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
key = Client.path_segment(assessment, "assessment")
|
|
27
|
+
@client.post("/assessments/#{key}/invites/", body)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def bulk_create(assessment, candidates, args = nil, **kwargs)
|
|
31
|
+
key = Client.path_segment(assessment, "assessment")
|
|
32
|
+
body = { "candidates" => candidates }.merge(normalize(args || kwargs) || {})
|
|
33
|
+
@client.post("/assessments/#{key}/invites/bulk/", body)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def remind(invite_token)
|
|
37
|
+
token = Client.path_segment(invite_token, "invite_token")
|
|
38
|
+
@client.post("/invites/#{token}/remind/")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def cancel(invite_token)
|
|
42
|
+
token = Client.path_segment(invite_token, "invite_token")
|
|
43
|
+
@client.delete("/invites/#{token}/")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def normalize(hash)
|
|
49
|
+
return nil if hash.nil?
|
|
50
|
+
|
|
51
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Praxicraft
|
|
4
|
+
module Resources
|
|
5
|
+
class Org
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def retrieve
|
|
11
|
+
@client.get("/org/")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def stats(params = nil)
|
|
15
|
+
@client.get("/org/stats/", normalize(params))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def normalize(hash)
|
|
21
|
+
return nil if hash.nil?
|
|
22
|
+
|
|
23
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Praxicraft
|
|
4
|
+
module Resources
|
|
5
|
+
class Pipelines
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def list(params = nil)
|
|
11
|
+
@client.get("/pipelines/", normalize(params))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def retrieve(pipeline)
|
|
15
|
+
key = Client.path_segment(pipeline, "pipeline")
|
|
16
|
+
@client.get("/pipelines/#{key}/")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def enroll(pipeline, args = nil, **kwargs)
|
|
20
|
+
body = normalize(args || kwargs) || {}
|
|
21
|
+
email = body["email"]
|
|
22
|
+
if email.nil? || email.to_s.strip.empty?
|
|
23
|
+
raise APIError.new("email is required", "INVALID_ARGUMENT")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
key = Client.path_segment(pipeline, "pipeline")
|
|
27
|
+
@client.post("/pipelines/#{key}/enroll/", body)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def bulk_enroll(pipeline, candidates, args = nil, **kwargs)
|
|
31
|
+
key = Client.path_segment(pipeline, "pipeline")
|
|
32
|
+
body = { "candidates" => candidates }.merge(normalize(args || kwargs) || {})
|
|
33
|
+
@client.post("/pipelines/#{key}/enroll/bulk/", body)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def list_enrollments(pipeline, params = nil)
|
|
37
|
+
key = Client.path_segment(pipeline, "pipeline")
|
|
38
|
+
@client.get("/pipelines/#{key}/enrollments/", normalize(params))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def get_enrollment(enrollment_id)
|
|
42
|
+
key = Client.path_segment(enrollment_id, "enrollment_id")
|
|
43
|
+
@client.get("/pipelines/enrollments/#{key}/")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def normalize(hash)
|
|
49
|
+
return nil if hash.nil?
|
|
50
|
+
|
|
51
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Praxicraft
|
|
6
|
+
module Resources
|
|
7
|
+
class Results
|
|
8
|
+
MAX_RESULT_PAGES = 10_000
|
|
9
|
+
|
|
10
|
+
def initialize(client)
|
|
11
|
+
@client = client
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# kwargs / hash: cursor, page_size, params
|
|
15
|
+
def list(assessment, args = nil, **kwargs)
|
|
16
|
+
opts = symbolize(args || kwargs)
|
|
17
|
+
query = normalize(opts[:params] || {}) || {}
|
|
18
|
+
query["cursor"] = opts[:cursor] if opts.key?(:cursor)
|
|
19
|
+
query["page_size"] = opts[:page_size] if opts.key?(:page_size)
|
|
20
|
+
|
|
21
|
+
key = Client.path_segment(assessment, "assessment")
|
|
22
|
+
@client.get("/assessments/#{key}/results/", query)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def retrieve(invite_token)
|
|
26
|
+
token = Client.path_segment(invite_token, "invite_token")
|
|
27
|
+
@client.get("/invites/#{token}/result/")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def iter_all(assessment, args = nil, **kwargs, &block)
|
|
31
|
+
opts = symbolize(args || kwargs)
|
|
32
|
+
return enum_for(:iter_all, assessment, opts) unless block_given?
|
|
33
|
+
|
|
34
|
+
cursor = nil
|
|
35
|
+
seen = {}
|
|
36
|
+
|
|
37
|
+
MAX_RESULT_PAGES.times do
|
|
38
|
+
page_args = opts.dup
|
|
39
|
+
page_args[:cursor] = cursor unless cursor.nil?
|
|
40
|
+
page = list(assessment, page_args)
|
|
41
|
+
return unless page.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
results = page["results"] || page[:results]
|
|
44
|
+
results.each(&block) if results.is_a?(Array)
|
|
45
|
+
|
|
46
|
+
nxt = next_cursor(page)
|
|
47
|
+
return if nxt.nil? || seen[nxt]
|
|
48
|
+
|
|
49
|
+
seen[nxt] = true
|
|
50
|
+
cursor = nxt
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def symbolize(hash)
|
|
57
|
+
return {} if hash.nil?
|
|
58
|
+
|
|
59
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def normalize(hash)
|
|
63
|
+
return nil if hash.nil?
|
|
64
|
+
|
|
65
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def next_cursor(page)
|
|
69
|
+
nc = page["next_cursor"] || page[:next_cursor]
|
|
70
|
+
return nc if nc.is_a?(String) && !nc.empty?
|
|
71
|
+
|
|
72
|
+
next_link = page["next"] || page[:next]
|
|
73
|
+
return nil unless next_link.is_a?(String) && !next_link.empty?
|
|
74
|
+
|
|
75
|
+
uri = URI.parse(next_link)
|
|
76
|
+
return nil if uri.query.nil?
|
|
77
|
+
|
|
78
|
+
q = URI.decode_www_form(uri.query).to_h
|
|
79
|
+
c = q["cursor"]
|
|
80
|
+
c.is_a?(String) && !c.empty? ? c : nil
|
|
81
|
+
rescue URI::InvalidURIError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Praxicraft
|
|
4
|
+
module Resources
|
|
5
|
+
class Webhooks
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def list(params = nil)
|
|
11
|
+
@client.get("/webhooks/", normalize(params))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create(args = nil, **kwargs)
|
|
15
|
+
body = normalize(args || kwargs) || {}
|
|
16
|
+
url = body["url"]
|
|
17
|
+
events = body["events"]
|
|
18
|
+
if url.nil? || url.to_s.strip.empty?
|
|
19
|
+
raise APIError.new("url is required", "INVALID_ARGUMENT")
|
|
20
|
+
end
|
|
21
|
+
if !events.is_a?(Array) || events.empty?
|
|
22
|
+
raise APIError.new("events must be a non-empty list", "INVALID_ARGUMENT")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
@client.post("/webhooks/create/", body)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def retrieve(webhook_id)
|
|
29
|
+
key = Client.path_segment(webhook_id, "webhook_id")
|
|
30
|
+
@client.get("/webhooks/#{key}/")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def update(webhook_id, fields = nil, **kwargs)
|
|
34
|
+
body = normalize(fields || kwargs)
|
|
35
|
+
if body.nil? || body.empty?
|
|
36
|
+
raise APIError.new("update() requires at least one field to change", "INVALID_ARGUMENT")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
key = Client.path_segment(webhook_id, "webhook_id")
|
|
40
|
+
@client.patch("/webhooks/#{key}/", body)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def delete(webhook_id)
|
|
44
|
+
key = Client.path_segment(webhook_id, "webhook_id")
|
|
45
|
+
@client.delete("/webhooks/#{key}/")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def deliveries(webhook_id)
|
|
49
|
+
key = Client.path_segment(webhook_id, "webhook_id")
|
|
50
|
+
@client.get("/webhooks/#{key}/deliveries/")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test(webhook_id)
|
|
54
|
+
key = Client.path_segment(webhook_id, "webhook_id")
|
|
55
|
+
@client.post("/webhooks/#{key}/test/")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def normalize(hash)
|
|
61
|
+
return nil if hash.nil?
|
|
62
|
+
|
|
63
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Praxicraft
|
|
6
|
+
module Webhooks
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Verify an X-Praxicraft-Signature header (sha256=<hex> or legacy bare hex).
|
|
10
|
+
# +body+ may be nil (treated as empty payload).
|
|
11
|
+
def verify_signature(secret, body, header_sig)
|
|
12
|
+
return false if secret.nil? || secret.empty? || header_sig.nil? || header_sig.empty?
|
|
13
|
+
|
|
14
|
+
payload = body.nil? ? "" : body.to_s
|
|
15
|
+
digest = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
|
|
16
|
+
expected = "sha256=#{digest}"
|
|
17
|
+
|
|
18
|
+
if header_sig.start_with?("sha256=")
|
|
19
|
+
secure_compare(expected, header_sig)
|
|
20
|
+
else
|
|
21
|
+
secure_compare(digest, header_sig) || secure_compare(expected, header_sig)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def secure_compare(a, b)
|
|
26
|
+
return false unless a.bytesize == b.bytesize
|
|
27
|
+
|
|
28
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
29
|
+
rescue StandardError
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
private_class_method :secure_compare
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/praxicraft.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "praxicraft/version"
|
|
4
|
+
require_relative "praxicraft/errors"
|
|
5
|
+
require_relative "praxicraft/webhooks"
|
|
6
|
+
require_relative "praxicraft/resources/org"
|
|
7
|
+
require_relative "praxicraft/resources/assessments"
|
|
8
|
+
require_relative "praxicraft/resources/invites"
|
|
9
|
+
require_relative "praxicraft/resources/results"
|
|
10
|
+
require_relative "praxicraft/resources/webhooks"
|
|
11
|
+
require_relative "praxicraft/resources/pipelines"
|
|
12
|
+
require_relative "praxicraft/client"
|
|
13
|
+
|
|
14
|
+
module Praxicraft
|
|
15
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: praxicraft
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- PraxiCraft
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-21 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
description: Official Ruby client for the Praxicraft Assess Public API.
|
|
42
|
+
email:
|
|
43
|
+
- support@praxicraft.com
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- CHANGELOG.md
|
|
49
|
+
- LICENSE
|
|
50
|
+
- README.md
|
|
51
|
+
- lib/praxicraft.rb
|
|
52
|
+
- lib/praxicraft/client.rb
|
|
53
|
+
- lib/praxicraft/errors.rb
|
|
54
|
+
- lib/praxicraft/resources/assessments.rb
|
|
55
|
+
- lib/praxicraft/resources/invites.rb
|
|
56
|
+
- lib/praxicraft/resources/org.rb
|
|
57
|
+
- lib/praxicraft/resources/pipelines.rb
|
|
58
|
+
- lib/praxicraft/resources/results.rb
|
|
59
|
+
- lib/praxicraft/resources/webhooks.rb
|
|
60
|
+
- lib/praxicraft/version.rb
|
|
61
|
+
- lib/praxicraft/webhooks.rb
|
|
62
|
+
homepage: https://docs.praxicraft.com/sdks/ruby
|
|
63
|
+
licenses:
|
|
64
|
+
- MIT
|
|
65
|
+
metadata:
|
|
66
|
+
homepage_uri: https://docs.praxicraft.com/sdks/ruby
|
|
67
|
+
source_code_uri: https://github.com/praxicraft-platform/praxicraft-ruby
|
|
68
|
+
changelog_uri: https://github.com/praxicraft-platform/praxicraft-ruby/blob/main/CHANGELOG.md
|
|
69
|
+
post_install_message:
|
|
70
|
+
rdoc_options: []
|
|
71
|
+
require_paths:
|
|
72
|
+
- lib
|
|
73
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: 3.1.0
|
|
78
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0'
|
|
83
|
+
requirements: []
|
|
84
|
+
rubygems_version: 3.5.22
|
|
85
|
+
signing_key:
|
|
86
|
+
specification_version: 4
|
|
87
|
+
summary: Official Ruby SDK for the Praxicraft Assess Public API
|
|
88
|
+
test_files: []
|