purple-client 0.1.9.3 → 0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 381595d6be33f7e1f3c7a838182db63d5f157ba3978fa70f99b78c75391a9c06
4
- data.tar.gz: 48aa5d67fd40fd4d8c8b1bf482e4e493d69f77dd8d9c8fe7011382a74437806f
3
+ metadata.gz: 5ec58b7f5f8e255925536cf2a25eb351f3d1750b90c6fbb14afc1da0adff46ef
4
+ data.tar.gz: 4d5b8472eb6aeaccc49c9271f3bd512abeb82bd6704c33c61702b46c99b09cf2
5
5
  SHA512:
6
- metadata.gz: '084bbb42e3790b1e291c5f95dc5928ec23acb1d6df597b8471e7f6effb279c08eff9f99281f046bc9e84658ec03cd39c943d8aa4a510fa79397afe0e4240550e'
7
- data.tar.gz: cd0c4805e785f1cc92087b12a902fb23c78c90e15c540d52a4cedf78990a9c3cedd2ebfaf6d5583617e6a7c71b40db0fdfc208e8147e1b1eb0f0aad4a1b47ddc
6
+ metadata.gz: 9facb7ca2fe19250fb571127f3cf9e0b5b13713ad742b11ff10e423b6baba16bca2faddfe72f89811fbbff428ccfa7cc6fbc57f55ad08e4bca643aac7add6a32
7
+ data.tar.gz: a5d8da246488a4d47982f58703dd37545b722a8b638d9fcdd31e4372e127f9688f1ae77428b4b8a71bc28d19bf8027cfcb250a7f65bf35e505960d01d4fc8918
data/AGENTS.md ADDED
@@ -0,0 +1,154 @@
1
+ # LLM Guide for Purple::Client Wrapper Clients
2
+
3
+ This repository uses **wrapper clients** built on `Purple::Client`. A wrapper client is a thin Ruby class that declaratively describes an API using the DSL (domain, paths, params, responses, and response schemas). The DSL then generates methods that perform HTTP requests and validate/shape responses.
4
+
5
+ **A wrapper client is NOT:**
6
+ - A generic HTTP client or SDK with its own request/response plumbing.
7
+ - A place for business logic, caching, retries, or complex data transformations.
8
+ - A replacement for application-level services or models.
9
+
10
+ ## Golden path structure
11
+
12
+ When adding a new wrapper, follow this structure:
13
+
14
+ 1. **Namespace + client class** (module name matches provider/service name)
15
+ 2. **`domain`** declaration
16
+ 3. **`path` tree** that mirrors API routes
17
+ 4. **`params`** and request **`body`** definitions where applicable
18
+ 5. **`response`** blocks for expected status codes
19
+ 6. **`root_method`** for each callable endpoint
20
+
21
+ ## Naming conventions
22
+
23
+ - `root_method` should be a **verb/action** (`convert`, `live_rates`, `historical_rates`).
24
+ - Parameter names should mirror the API parameter names. Ruby `snake_case` is fine—document any mapping or normalization if needed.
25
+
26
+ ## Modeling guidelines
27
+
28
+ - **Query params**
29
+ ```ruby
30
+ params do |base:, symbols: nil|
31
+ { base: base, symbols: symbols }.compact
32
+ end
33
+ ```
34
+
35
+ - **Request bodies**
36
+ ```ruby
37
+ params do |currency:, amount:|
38
+ { currency: currency, amount: amount }
39
+ end
40
+ ```
41
+ The hash returned by `params` becomes the JSON body for POST/PUT/PATCH. Use `body(...)` to describe **response** schemas.
42
+
43
+ - **Multiple responses**
44
+ ```ruby
45
+ response :ok do
46
+ body(success: Purple::Boolean)
47
+ end
48
+
49
+ response :bad_request do
50
+ body(error: { code: Integer, info: { type: String, allow_blank: true } })
51
+ end
52
+ ```
53
+
54
+ - **Optional / nullable fields**
55
+ ```ruby
56
+ body(
57
+ message: { type: String, allow_blank: true },
58
+ provider_id: { type: String, optional: true }
59
+ )
60
+ ```
61
+
62
+ - **Booleans**
63
+ ```ruby
64
+ body(success: Purple::Boolean)
65
+ ```
66
+
67
+ - **Arrays**
68
+ ```ruby
69
+ body(:array_of, id: Integer, name: String)
70
+ ```
71
+
72
+ - **Response transformation**
73
+ ```ruby
74
+ body(result: Float) { |res| res.result }
75
+ ```
76
+
77
+
78
+ ## Organizing wrappers with `draw`
79
+
80
+ Use `draw` to keep large wrappers readable by splitting DSL declarations into
81
+ smaller files.
82
+
83
+ ```ruby
84
+ # clients/payments/client.rb
85
+ class Payments::Client < Purple::Client
86
+ domain 'https://api.example.com'
87
+
88
+ draw 'paths/invoices'
89
+ draw 'paths/refunds.rb'
90
+ end
91
+
92
+ # clients/payments/paths/invoices.rb
93
+ path :invoices do
94
+ response :ok do
95
+ body :default
96
+ end
97
+
98
+ root_method :invoices
99
+ end
100
+ ```
101
+
102
+ ## DO / DON'T (for LLMs)
103
+
104
+ **DO**
105
+ - Use the DSL (`domain`, `path`, `params`, `response`, `body`, `root_method`).
106
+ - Describe response schemas explicitly, even if partial.
107
+ - Keep the wrapper thin; add only small response transformations.
108
+ - Validate responses with `body(...)` so downstream callers get consistent types.
109
+
110
+ **DON'T**
111
+ - Invent a generic HTTP client or bypass the DSL.
112
+ - Add business logic, persistence, or retries inside the wrapper.
113
+ - Skip response validation or return raw JSON when a schema is expected.
114
+
115
+ ## Skeleton wrapper template (copy/paste)
116
+
117
+ ```ruby
118
+ # frozen_string_literal: true
119
+
120
+ require "purple/client"
121
+
122
+ module ProviderName
123
+ class Client < Purple::Client
124
+ domain "https://api.provider.example"
125
+
126
+ path :resource do
127
+ params do |id:, verbose: nil|
128
+ { id: id, verbose: verbose }.compact
129
+ end
130
+
131
+ response :ok do
132
+ body(
133
+ id: Integer,
134
+ name: String,
135
+ active: Purple::Boolean,
136
+ metadata: {
137
+ created_at: String,
138
+ tags: { type: Array, optional: true }
139
+ }
140
+ )
141
+ end
142
+
143
+ response :bad_request do
144
+ body(
145
+ error: String,
146
+ message: { type: String, allow_blank: true }
147
+ )
148
+ end
149
+
150
+ root_method :fetch_resource
151
+ end
152
+ end
153
+ end
154
+ ```
data/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Purple::Client is a small DSL that helps you describe HTTP APIs. You define a domain, paths, and response structures, and the library generates handy methods for interacting with your service.
4
4
 
5
+ ## For AI / agents
6
+
7
+ See [AGENTS.md](AGENTS.md) for an LLM-focused guide, naming conventions, and a copy-paste wrapper skeleton.
8
+
5
9
  ## Installation
6
10
 
7
11
  Add the gem to your project:
@@ -347,6 +351,64 @@ end
347
351
  MessagesClient.send_message
348
352
  ```
349
353
 
354
+ ### Splitting large wrappers with `draw`
355
+
356
+ If a wrapper gets too long, you can move parts of the DSL into separate files
357
+ and load them with `draw`.
358
+
359
+ `draw` resolves paths relative to the file where it is called. If you omit the
360
+ extension, `.rb` is added automatically.
361
+
362
+ ```ruby
363
+ # clients/payments/client.rb
364
+ class Payments::Client < Purple::Client
365
+ domain 'https://api.example.com'
366
+
367
+ draw 'paths/invoices'
368
+ draw 'paths/refunds.rb'
369
+ end
370
+
371
+ # clients/payments/paths/invoices.rb
372
+ path :invoices do
373
+ response :ok do
374
+ body :default
375
+ end
376
+ root_method :invoices
377
+ end
378
+ ```
379
+
380
+ Use `draw` only to organize DSL definitions (`path`, `params`, `response`,
381
+ `root_method`) into smaller files. Keep wrapper behavior declarative and avoid
382
+ adding non-DSL business logic in drawn files.
383
+
384
+ ## How to build a wrapper client (5 steps)
385
+
386
+ 1. **Define a `domain`** for the API host.
387
+ 2. **Build the `path` tree** to reflect the API routes.
388
+ 3. **Describe inputs** with `params` and request body fields as needed.
389
+ 4. **Describe responses** with `response` blocks and `body` schemas.
390
+ 5. **Set `root_method`** and add call examples in docs or specs.
391
+
392
+ Helpful references:
393
+ - [AGENTS.md](AGENTS.md) (LLM wrapper guide + skeleton)
394
+ - [OpenAPI mapping](docs/openapi_mapping.md)
395
+ - [Testing wrappers](docs/testing_wrappers.md)
396
+ - [Real-world example: exchangerate.host](examples/real_world/exchangerate_host/README.md)
397
+
398
+ ## Scaffold a wrapper client
399
+
400
+ Generate a new wrapper skeleton with the built-in scaffold command:
401
+
402
+ ```bash
403
+ bin/purple-client scaffold PROVIDER_NAME
404
+ ```
405
+
406
+ Use `--force` to overwrite existing files:
407
+
408
+ ```bash
409
+ bin/purple-client scaffold PROVIDER_NAME --force
410
+ ```
411
+
350
412
  ## Development
351
413
 
352
414
  After checking out the repo, run `bin/setup` to install dependencies. Then run
@@ -367,4 +429,3 @@ to the [code of conduct](https://github.com/[USERNAME]/purple-client/blob/main/C
367
429
 
368
430
  The gem is available as open source under the terms of the
369
431
  [MIT License](https://opensource.org/licenses/MIT).
370
-
@@ -0,0 +1,94 @@
1
+ # OpenAPI → Purple::Client DSL Mapping
2
+
3
+ This guide shows how to translate common OpenAPI concepts into Purple::Client DSL constructs.
4
+
5
+ ## Mapping overview
6
+
7
+ | OpenAPI concept | Purple::Client DSL |
8
+ | --- | --- |
9
+ | Path + HTTP method | `path :resource, method: :get` (nested paths reflect URL segments) |
10
+ | Query parameters | `params do |...| ... end` |
11
+ | Request body schema | Use `params` to build the request payload (for POST/PUT/PATCH, the hash returned by `params` becomes the JSON body). Use `body(...)` only for response schemas. |
12
+ | Response codes | `response :ok`, `response :bad_request`, `response :unprocessable_entity`, etc. |
13
+ | JSON object schema | `body(field: Type, nested: { ... })` |
14
+ | Arrays | `body(:array_of, id: Integer, name: String)` |
15
+ | Optional fields | `field: { type: String, optional: true }` |
16
+ | Nullable / blank | `field: { type: String, allow_blank: true }` |
17
+ | Boolean | `Purple::Boolean` |
18
+
19
+ ## Worked example
20
+
21
+ ### OpenAPI snippet
22
+
23
+ ```yaml
24
+ paths:
25
+ /convert:
26
+ get:
27
+ parameters:
28
+ - in: query
29
+ name: from
30
+ schema: { type: string }
31
+ required: true
32
+ - in: query
33
+ name: to
34
+ schema: { type: string }
35
+ required: true
36
+ - in: query
37
+ name: amount
38
+ schema: { type: number }
39
+ required: true
40
+ responses:
41
+ "200":
42
+ description: Conversion result
43
+ content:
44
+ application/json:
45
+ schema:
46
+ type: object
47
+ properties:
48
+ success: { type: boolean }
49
+ query:
50
+ type: object
51
+ properties:
52
+ from: { type: string }
53
+ to: { type: string }
54
+ amount: { type: number }
55
+ result: { type: number }
56
+ "400":
57
+ description: Bad request
58
+ ```
59
+
60
+ ### Purple::Client DSL
61
+
62
+ ```ruby
63
+ class ExampleClient < Purple::Client
64
+ domain "https://api.example.com"
65
+
66
+ path :convert do
67
+ params do |from:, to:, amount:|
68
+ { from: from, to: to, amount: amount }
69
+ end
70
+
71
+ response :ok do
72
+ body(
73
+ success: Purple::Boolean,
74
+ query: {
75
+ from: String,
76
+ to: String,
77
+ amount: Float
78
+ },
79
+ result: Float
80
+ )
81
+ end
82
+
83
+ response :bad_request
84
+
85
+ root_method :convert
86
+ end
87
+ end
88
+ ```
89
+
90
+ ### Notes
91
+
92
+ - Query params are produced by `params` (even for GET requests).
93
+ - Use `Purple::Boolean` for booleans, and `allow_blank` for fields that can be `null` or empty.
94
+ - For arrays, use `body(:array_of, ...)` with the element structure.
@@ -0,0 +1,80 @@
1
+ # Testing Wrapper Clients with RSpec + WebMock
2
+
3
+ This guide shows how to test Purple::Client wrappers using **RSpec** and **WebMock** (no VCR).
4
+
5
+ ## Setup
6
+
7
+ Add WebMock to your development/test dependencies (already included in this repo's Gemfile):
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem "webmock", "~> 3.0"
12
+ ```
13
+
14
+ In your spec file, require WebMock and stub HTTP calls:
15
+
16
+ ```ruby
17
+ require "spec_helper"
18
+ require "webmock/rspec"
19
+ ```
20
+
21
+ ## Example spec
22
+
23
+ ```ruby
24
+ # spec/exchangerate_host/client_spec.rb
25
+ require "spec_helper"
26
+ require "webmock/rspec"
27
+ require "examples/real_world/exchangerate_host/client"
28
+
29
+ RSpec.describe ExchangerateHost::Client do
30
+ it "calls the live rates endpoint with query params" do
31
+ stub_request(:get, "https://api.exchangerate.host/latest")
32
+ .with(query: { "base" => "USD", "symbols" => "EUR" })
33
+ .to_return(
34
+ status: 200,
35
+ body: {
36
+ success: true,
37
+ base: "USD",
38
+ date: "2024-01-01",
39
+ rates: { EUR: 0.92 }
40
+ }.to_json,
41
+ headers: { "Content-Type" => "application/json" }
42
+ )
43
+
44
+ response = described_class.live(base: "USD", symbols: "EUR")
45
+
46
+ expect(response.base).to eq("USD")
47
+ expect(response.rates[:"EUR"]).to eq(0.92)
48
+ end
49
+
50
+ it "validates non-:ok responses" do
51
+ stub_request(:get, "https://api.exchangerate.host/latest")
52
+ .with(query: { "base" => "USD" })
53
+ .to_return(
54
+ status: 400,
55
+ body: {
56
+ success: false,
57
+ error: { code: 400, type: "invalid_base", info: nil }
58
+ }.to_json,
59
+ headers: { "Content-Type" => "application/json" }
60
+ )
61
+
62
+ response = described_class.live(base: "USD")
63
+
64
+ expect(response.error[:type]).to eq("invalid_base")
65
+ end
66
+ end
67
+ ```
68
+
69
+ ## What to assert
70
+
71
+ - **HTTP method + URL + query params** via `stub_request(...).with(...)`.
72
+ - **Response parsing and validation** by asserting fields defined in `body(...)`.
73
+ - **Non-:ok responses** by stubbing other status codes (`:bad_request`, `:unprocessable_entity`).
74
+ - **Optional/blank fields** by stubbing missing or `null` values and ensuring no validation errors.
75
+
76
+ ## Running the tests
77
+
78
+ ```bash
79
+ bundle exec rspec
80
+ ```
@@ -0,0 +1,51 @@
1
+ # Exchangerate Host Wrapper Example
2
+
3
+ This example shows a minimal, realistic wrapper client for [exchangerate.host](https://exchangerate.host) using `Purple::Client`.
4
+
5
+ It demonstrates:
6
+ - Multiple endpoints (`live`, `historical`, `convert`, `timeframe`)
7
+ - Required and optional params
8
+ - Nested response structures
9
+ - `optional` and `allow_blank`
10
+ - `Purple::Boolean`
11
+ - A small response transformation for `convert`
12
+
13
+ ## Setup
14
+
15
+ From the repo root:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ ## Run locally (optional live calls)
22
+
23
+ These examples perform live HTTP requests. If you are offline, skip them and rely on the specs.
24
+
25
+ ```bash
26
+ ruby -r "./examples/real_world/exchangerate_host/client" -e "puts ExchangerateHost::Client.live(base: 'USD', symbols: 'EUR').rates[:eur]"
27
+
28
+ ruby -r "./examples/real_world/exchangerate_host/client" -e "puts ExchangerateHost::Client.historical('2020-01-01', base: 'USD', symbols: 'EUR').rates[:eur]"
29
+
30
+ ruby -r "./examples/real_world/exchangerate_host/client" -e "puts ExchangerateHost::Client.convert(from: 'USD', to: 'EUR', amount: 10)"
31
+
32
+ ruby -r "./examples/real_world/exchangerate_host/client" -e "puts ExchangerateHost::Client.timeframe(start_date: '2020-01-01', end_date: '2020-01-05', base: 'USD').rates.keys"
33
+ ```
34
+
35
+ ## Expected response shapes
36
+
37
+ ```ruby
38
+ ExchangerateHost::Client.live(base: "USD", symbols: "EUR")
39
+ # => #<Purple::Responses::Object ...>
40
+ # response.base #=> "USD"
41
+ # response.rates[:"EUR"] #=> 0.92
42
+
43
+ ExchangerateHost::Client.historical("2020-01-01", base: "USD", symbols: "EUR")
44
+ # => #<Purple::Responses::Object ...>
45
+
46
+ ExchangerateHost::Client.convert(from: "USD", to: "EUR", amount: 10)
47
+ # => 9.2 (Float)
48
+
49
+ ExchangerateHost::Client.timeframe(start_date: "2020-01-01", end_date: "2020-01-05", base: "USD")
50
+ # => #<Purple::Responses::Object ...>
51
+ ```
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "purple/client"
4
+
5
+ module ExchangerateHost
6
+ class Client < Purple::Client
7
+ domain "https://api.exchangerate.host"
8
+
9
+ path :latest do
10
+ params do |base: "USD", symbols: nil|
11
+ { base: base, symbols: symbols }.compact
12
+ end
13
+
14
+ response :ok do
15
+ body(
16
+ success: Purple::Boolean,
17
+ base: String,
18
+ date: String,
19
+ rates: { type: Hash, allow_blank: true }
20
+ )
21
+ end
22
+
23
+ response :bad_request do
24
+ body(
25
+ success: Purple::Boolean,
26
+ error: {
27
+ code: Integer,
28
+ type: String,
29
+ info: { type: String, allow_blank: true }
30
+ }
31
+ )
32
+ end
33
+
34
+ root_method :live
35
+ end
36
+
37
+ path :historical do
38
+ path :date, is_param: true do
39
+ params do |base: nil, symbols: nil|
40
+ { base: base, symbols: symbols }.compact
41
+ end
42
+
43
+ response :ok do
44
+ body(
45
+ success: Purple::Boolean,
46
+ base: String,
47
+ date: String,
48
+ rates: { type: Hash, allow_blank: true }
49
+ )
50
+ end
51
+
52
+ response :bad_request do
53
+ body(
54
+ success: Purple::Boolean,
55
+ error: {
56
+ code: Integer,
57
+ type: String,
58
+ info: { type: String, allow_blank: true }
59
+ }
60
+ )
61
+ end
62
+
63
+ root_method :historical
64
+ end
65
+ end
66
+
67
+ path :convert do
68
+ params do |from:, to:, amount:|
69
+ { from: from, to: to, amount: amount }
70
+ end
71
+
72
+ response :ok do
73
+ body(
74
+ success: Purple::Boolean,
75
+ query: {
76
+ from: String,
77
+ to: String,
78
+ amount: Float
79
+ },
80
+ info: {
81
+ rate: Float,
82
+ timestamp: { type: Integer, optional: true }
83
+ },
84
+ result: Float,
85
+ date: String
86
+ ) { |res| res.result }
87
+ end
88
+
89
+ response :unprocessable_entity do
90
+ body(
91
+ success: Purple::Boolean,
92
+ error: {
93
+ code: Integer,
94
+ type: String,
95
+ info: { type: String, allow_blank: true }
96
+ }
97
+ )
98
+ end
99
+
100
+ root_method :convert
101
+ end
102
+
103
+ path :timeframe do
104
+ params do |start_date:, end_date:, base: nil, symbols: nil|
105
+ {
106
+ start_date: start_date,
107
+ end_date: end_date,
108
+ base: base,
109
+ symbols: symbols
110
+ }.compact
111
+ end
112
+
113
+ response :ok do
114
+ body(
115
+ success: Purple::Boolean,
116
+ timeseries: Purple::Boolean,
117
+ start_date: String,
118
+ end_date: String,
119
+ base: String,
120
+ rates: { type: Hash, allow_blank: true }
121
+ )
122
+ end
123
+
124
+ response :bad_request do
125
+ body(
126
+ success: Purple::Boolean,
127
+ error: {
128
+ code: Integer,
129
+ type: String,
130
+ info: { type: String, allow_blank: true }
131
+ }
132
+ )
133
+ end
134
+
135
+ root_method :timeframe
136
+ end
137
+ end
138
+ end
data/lib/purple/client.rb CHANGED
@@ -69,6 +69,19 @@ module Purple
69
69
  @parent_path = path.parent
70
70
  end
71
71
 
72
+ def draw(file_path)
73
+ caller_path = caller_locations(1, 1).first.absolute_path
74
+ base_dir = File.dirname(caller_path)
75
+
76
+ full_path = if File.extname(file_path).empty?
77
+ File.expand_path("#{file_path}.rb", base_dir)
78
+ else
79
+ File.expand_path(file_path, base_dir)
80
+ end
81
+
82
+ class_eval(File.read(full_path), full_path)
83
+ end
84
+
72
85
  def root_method(method_name)
73
86
  current_path = @parent_path
74
87
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Purple
4
- VERSION = "0.1.9.3"
4
+ VERSION = "0.2"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: purple-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9.3
4
+ version: '0.2'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pavel Kalashnikov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-09-17 00:00:00.000000000 Z
11
+ date: 2026-02-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-initializer
@@ -61,11 +61,16 @@ extra_rdoc_files: []
61
61
  files:
62
62
  - ".rspec"
63
63
  - ".rubocop.yml"
64
+ - AGENTS.md
64
65
  - CHANGELOG.md
65
66
  - CODE_OF_CONDUCT.md
66
67
  - LICENSE.txt
67
68
  - README.md
68
69
  - Rakefile
70
+ - docs/openapi_mapping.md
71
+ - docs/testing_wrappers.md
72
+ - examples/real_world/exchangerate_host/README.md
73
+ - examples/real_world/exchangerate_host/client.rb
69
74
  - lib/purple/boolean.rb
70
75
  - lib/purple/client.rb
71
76
  - lib/purple/client/version.rb