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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Praxicraft
4
+ VERSION = "0.1.1"
5
+ 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: []