postio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE +21 -0
- data/README.md +123 -0
- data/lib/postio/client.rb +210 -0
- data/lib/postio/errors.rb +59 -0
- data/lib/postio/models.rb +204 -0
- data/lib/postio/version.rb +5 -0
- data/lib/postio.rb +13 -0
- data/postio.gemspec +33 -0
- metadata +116 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ee5a074b8751e84901306fd99658973e1635f98761b1a6925a9b3c83d6a00454
|
|
4
|
+
data.tar.gz: d72f7607c25f2437d0f7436c9b503968a7c3d20f0584ba427365346fb8348649
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e5bf796095c34ef9815140482ee8347d79aa747f39e173ca0ea5296d3d16c1cd7a25ad039fe947ddad598236b7be9f070ec6f648d455164be93342f12a9a8b25
|
|
7
|
+
data.tar.gz: 99e2f1d7221a697535bc165b636cbe683ca4ae41448757f0f43f3af821f8dea7bbf6e2aff3b3694c66c441d566ee4a54512265b443f9d02b69277c0e7cfb7028
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `postio` are documented here. Format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning
|
|
5
|
+
follows [SemVer](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] — 2026-05-02
|
|
10
|
+
|
|
11
|
+
Initial release. First Postio Ruby SDK on RubyGems.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `Postio::Client` (synchronous; Ruby's async story is fragmented and
|
|
16
|
+
SDK customers want blocking calls).
|
|
17
|
+
- Address: `client.address.search/postcode/udprn`.
|
|
18
|
+
- Email: `client.email.validate`.
|
|
19
|
+
- Phone: `client.phone.validate`.
|
|
20
|
+
- Health probe: `client.connect`.
|
|
21
|
+
- Immutable `Data` value classes (Ruby 3.2+) for every response.
|
|
22
|
+
- Typed error hierarchy: `Postio::Error` base + 9 subclasses
|
|
23
|
+
(`InvalidKeyError`, `OutOfCreditError`, `ForbiddenError`,
|
|
24
|
+
`NotFoundError`, `ValidationError`, `RateLimitError`, `ServerError`,
|
|
25
|
+
`TimeoutError`, `ConnectionError`). Each carries `status`,
|
|
26
|
+
`error_code`, `details`, `request_id`, `envelope`.
|
|
27
|
+
- Default retry policy (2 retries, exp backoff + full jitter on
|
|
28
|
+
408/409/429/5xx + network/timeout). Mirrors `@postio/node`.
|
|
29
|
+
- Stdlib `net/http` only — no runtime dependencies.
|
|
30
|
+
- `POSTIO_API_KEY` env var fallback when `api_key:` is not passed.
|
|
31
|
+
|
|
32
|
+
### Notes
|
|
33
|
+
|
|
34
|
+
- `PhoneResult#is_reachable` is plain `Object` (untyped) because the
|
|
35
|
+
live API returns booleans there even though the spec says
|
|
36
|
+
string-only. Aligned once postio-api ships a spec/runtime fix.
|
|
37
|
+
|
|
38
|
+
[Unreleased]: https://github.com/postio-uk/postio-ruby/compare/v0.1.0...HEAD
|
|
39
|
+
[0.1.0]: https://github.com/postio-uk/postio-ruby/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Onno Group Limited (trading as Postio)
|
|
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,123 @@
|
|
|
1
|
+
# Postio Ruby SDK
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/postio)
|
|
4
|
+
[](https://rubygems.org/gems/postio)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
Ruby SDK for the [Postio API](https://postio.co.uk) — UK address, email, and
|
|
8
|
+
phone validation. Backed by Royal Mail PAF and Ordnance Survey. Stdlib
|
|
9
|
+
`net/http` only, zero runtime dependencies.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
gem install postio
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or in your `Gemfile`:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "postio", "~> 0.1"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Ruby 3.1+.
|
|
24
|
+
|
|
25
|
+
## 30-second example
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "postio"
|
|
29
|
+
|
|
30
|
+
client = Postio::Client.new(api_key: "pk_live_...") # or set POSTIO_API_KEY
|
|
31
|
+
|
|
32
|
+
result = client.address.search("downing street")
|
|
33
|
+
result.results.each do |hit|
|
|
34
|
+
puts "#{hit.udprn}: #{hit.suggestion}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
puts "request id: #{result.meta.request_id}"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## API
|
|
41
|
+
|
|
42
|
+
| Method | Returns |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `client.address.search(q, max_results:)` | `AddressSearchEnvelope` |
|
|
45
|
+
| `client.address.postcode(postcode, max_results:)` | `AddressPostcodeEnvelope` |
|
|
46
|
+
| `client.address.udprn(udprn)` | `AddressUdprnEnvelope` |
|
|
47
|
+
| `client.email.validate(address)` | `EmailEnvelope` |
|
|
48
|
+
| `client.phone.validate(number)` | `PhoneEnvelope` |
|
|
49
|
+
| `client.connect` | `ConnectSuccess` |
|
|
50
|
+
|
|
51
|
+
All response objects are immutable `Data` value classes (Ruby 3.2+).
|
|
52
|
+
Field names are snake_case in Ruby; the API uses camelCase JSON.
|
|
53
|
+
|
|
54
|
+
## Errors
|
|
55
|
+
|
|
56
|
+
Every non-2xx response raises a typed error. `Postio::Error` is the base.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
begin
|
|
60
|
+
client.address.postcode("not-a-postcode")
|
|
61
|
+
rescue Postio::ValidationError => e
|
|
62
|
+
puts "#{e.status} #{e.error_code}: #{e.message} (request_id: #{e.request_id})"
|
|
63
|
+
rescue Postio::RateLimitError => e
|
|
64
|
+
puts "rate limited; retry in #{e.retry_after} seconds"
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| Class | HTTP |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `Postio::ValidationError` | 400 / 422 |
|
|
71
|
+
| `Postio::InvalidKeyError` | 401 |
|
|
72
|
+
| `Postio::OutOfCreditError` | 402 |
|
|
73
|
+
| `Postio::ForbiddenError` | 403 |
|
|
74
|
+
| `Postio::NotFoundError` | 404 |
|
|
75
|
+
| `Postio::RateLimitError` | 429 (`#retry_after`) |
|
|
76
|
+
| `Postio::ServerError` | 5xx |
|
|
77
|
+
| `Postio::TimeoutError` | local request timeout |
|
|
78
|
+
| `Postio::ConnectionError` | transport error |
|
|
79
|
+
|
|
80
|
+
Every error carries `status`, `error_code`, `details`, `request_id`, and
|
|
81
|
+
the raw `envelope`.
|
|
82
|
+
|
|
83
|
+
## Configuration
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
client = Postio::Client.new(
|
|
87
|
+
api_key: "pk_live_...",
|
|
88
|
+
base_url: "https://api.postio.co.uk/v1", # default
|
|
89
|
+
timeout: 10, # seconds
|
|
90
|
+
retries: 2, # 0 to disable
|
|
91
|
+
headers: { "x-tracking-id" => "..." }
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Default retry policy: 2 retries on 408/409/429/5xx + network/timeout,
|
|
96
|
+
exponential backoff with full jitter (0.5s → 8s cap).
|
|
97
|
+
|
|
98
|
+
## Frameworks
|
|
99
|
+
|
|
100
|
+
The SDK is framework-agnostic. Cache one `Postio::Client` per process —
|
|
101
|
+
it's safe for concurrent use under MRI / YJIT / Truffle.
|
|
102
|
+
|
|
103
|
+
**Rails** — initialiser:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# config/initializers/postio.rb
|
|
107
|
+
POSTIO = Postio::Client.new(api_key: Rails.application.credentials.postio_api_key)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Links
|
|
111
|
+
|
|
112
|
+
- [Docs](https://postio.co.uk/docs)
|
|
113
|
+
- [API reference (OpenAPI)](https://postio.co.uk/openapi.json)
|
|
114
|
+
- [Changelog](./CHANGELOG.md)
|
|
115
|
+
- [Issues](https://github.com/postio-uk/postio-ruby/issues)
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT — see [LICENSE](./LICENSE).
|
|
120
|
+
|
|
121
|
+
> *Postio is a trading name of Onno Group Limited, registered in
|
|
122
|
+
> England & Wales (company no. 08622799). Registered office:
|
|
123
|
+
> Suite 22 Trym Lodge, 1 Henbury Road, Westbury-On-Trym, Bristol BS9 3HQ.*
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "version"
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
require_relative "models"
|
|
10
|
+
|
|
11
|
+
module Postio
|
|
12
|
+
# Postio::Client is the synchronous Postio API client.
|
|
13
|
+
#
|
|
14
|
+
# Example:
|
|
15
|
+
#
|
|
16
|
+
# client = Postio::Client.new(api_key: "pk_live_...")
|
|
17
|
+
# r = client.address.search("downing street")
|
|
18
|
+
# r.results.each { |hit| puts "#{hit.udprn}: #{hit.suggestion}" }
|
|
19
|
+
#
|
|
20
|
+
# The API key may also come from the POSTIO_API_KEY environment
|
|
21
|
+
# variable.
|
|
22
|
+
class Client
|
|
23
|
+
DEFAULT_BASE_URL = "https://api.postio.co.uk/v1"
|
|
24
|
+
DEFAULT_TIMEOUT = 10
|
|
25
|
+
RETRYABLE_STATUSES = [408, 409, 429, 500, 502, 503, 504].freeze
|
|
26
|
+
|
|
27
|
+
attr_reader :address, :email, :phone
|
|
28
|
+
|
|
29
|
+
def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
|
|
30
|
+
retries: 2, headers: {})
|
|
31
|
+
@api_key = api_key || ENV["POSTIO_API_KEY"]
|
|
32
|
+
raise ArgumentError, "Postio: api_key is required (pass api_key: ... or set POSTIO_API_KEY)" if @api_key.nil? || @api_key.empty?
|
|
33
|
+
|
|
34
|
+
@base_url = base_url.chomp("/")
|
|
35
|
+
@timeout = timeout
|
|
36
|
+
@retries = retries
|
|
37
|
+
@extra_headers = headers
|
|
38
|
+
|
|
39
|
+
@address = AddressResource.new(self)
|
|
40
|
+
@email = EmailResource.new(self)
|
|
41
|
+
@phone = PhoneResource.new(self)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Health probe — confirms the API is reachable and the key is valid.
|
|
45
|
+
def connect
|
|
46
|
+
Models::ConnectSuccess.from_hash(request("/connect"))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @api private — used by resource classes.
|
|
50
|
+
def request(path, query: {})
|
|
51
|
+
uri = URI(@base_url + path)
|
|
52
|
+
params = query.compact.transform_values(&:to_s)
|
|
53
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
54
|
+
|
|
55
|
+
max_attempts = @retries + 1
|
|
56
|
+
last_error = nil
|
|
57
|
+
|
|
58
|
+
max_attempts.times do |attempt|
|
|
59
|
+
begin
|
|
60
|
+
response = perform_http(uri)
|
|
61
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
62
|
+
last_error = TimeoutError.new("Request timed out.", error_code: "request_timeout", cause: e)
|
|
63
|
+
raise last_error if attempt == max_attempts - 1
|
|
64
|
+
|
|
65
|
+
sleep(backoff(attempt))
|
|
66
|
+
next
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
last_error = ConnectionError.new("Network error: #{e.message}", error_code: "network_error", cause: e)
|
|
69
|
+
raise last_error if attempt == max_attempts - 1
|
|
70
|
+
|
|
71
|
+
sleep(backoff(attempt))
|
|
72
|
+
next
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
body = parse_body(response)
|
|
76
|
+
|
|
77
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
78
|
+
return body
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Non-2xx — retryable status?
|
|
82
|
+
if RETRYABLE_STATUSES.include?(response.code.to_i) && attempt < max_attempts - 1
|
|
83
|
+
last_error = build_error(response, body)
|
|
84
|
+
sleep(backoff(attempt))
|
|
85
|
+
next
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
raise build_error(response, body)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Unreachable — loop above either returns or raises.
|
|
92
|
+
raise(last_error || Error.new("Postio: retry loop exhausted unexpectedly."))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def perform_http(uri)
|
|
98
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
99
|
+
req["x-api-key"] = @api_key
|
|
100
|
+
req["Accept"] = "application/json"
|
|
101
|
+
req["User-Agent"] = "postio-ruby/#{VERSION}"
|
|
102
|
+
req["x-postio-client"] = "postio-ruby/#{VERSION}"
|
|
103
|
+
@extra_headers.each { |k, v| req[k] = v }
|
|
104
|
+
|
|
105
|
+
Net::HTTP.start(uri.hostname, uri.port,
|
|
106
|
+
use_ssl: uri.scheme == "https",
|
|
107
|
+
open_timeout: @timeout,
|
|
108
|
+
read_timeout: @timeout) do |http|
|
|
109
|
+
http.request(req)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def parse_body(response)
|
|
114
|
+
content_type = (response["content-type"] || "").to_s
|
|
115
|
+
unless content_type.include?("application/json")
|
|
116
|
+
raise Error.new(
|
|
117
|
+
"Unexpected response content-type: #{content_type.inspect}",
|
|
118
|
+
status: response.code.to_i,
|
|
119
|
+
error_code: "unexpected_content_type",
|
|
120
|
+
details: response.body[0, 500]
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
JSON.parse(response.body)
|
|
125
|
+
rescue JSON::ParserError => e
|
|
126
|
+
raise Error.new("Failed to parse response body as JSON.",
|
|
127
|
+
status: response.code.to_i,
|
|
128
|
+
error_code: "parse_error",
|
|
129
|
+
cause: e)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def build_error(response, envelope)
|
|
133
|
+
status = response.code.to_i
|
|
134
|
+
error = envelope.is_a?(Hash) ? envelope["error"] : nil
|
|
135
|
+
details = envelope.is_a?(Hash) ? envelope["details"] : nil
|
|
136
|
+
request_id = envelope.dig("meta", "requestId") if envelope.is_a?(Hash)
|
|
137
|
+
message = (error || "HTTP #{status}").to_s
|
|
138
|
+
|
|
139
|
+
klass = Postio.error_class_for(status)
|
|
140
|
+
kwargs = {
|
|
141
|
+
status: status,
|
|
142
|
+
error_code: error,
|
|
143
|
+
details: details,
|
|
144
|
+
request_id: request_id,
|
|
145
|
+
envelope: envelope.is_a?(Hash) ? envelope : nil
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if klass == RateLimitError
|
|
149
|
+
retry_after_header = response["retry-after"]
|
|
150
|
+
retry_after = retry_after_header && retry_after_header.match?(/\A\d+(\.\d+)?\z/) ? retry_after_header.to_f : nil
|
|
151
|
+
return RateLimitError.new(message, retry_after: retry_after, **kwargs)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
klass.new(message, **kwargs)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def backoff(attempt)
|
|
158
|
+
base = 0.5
|
|
159
|
+
cap = 8.0
|
|
160
|
+
exp = [cap, base * (2**attempt)].min
|
|
161
|
+
rand * exp
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Resource: /address/*
|
|
166
|
+
class AddressResource
|
|
167
|
+
def initialize(client) = (@client = client)
|
|
168
|
+
|
|
169
|
+
def search(q, max_results: nil)
|
|
170
|
+
Models::AddressSearchEnvelope.from_hash(
|
|
171
|
+
@client.request("/address/search", query: { "q" => q, "max_results" => max_results })
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def postcode(postcode, max_results: nil)
|
|
176
|
+
Models::AddressPostcodeEnvelope.from_hash(
|
|
177
|
+
@client.request("/address/postcode/#{URI.encode_www_form_component(postcode)}",
|
|
178
|
+
query: { "max_results" => max_results })
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def udprn(udprn)
|
|
183
|
+
Models::AddressUdprnEnvelope.from_hash(
|
|
184
|
+
@client.request("/address/udprn/#{URI.encode_www_form_component(udprn.to_s)}")
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Resource: /email/*
|
|
190
|
+
class EmailResource
|
|
191
|
+
def initialize(client) = (@client = client)
|
|
192
|
+
|
|
193
|
+
def validate(address)
|
|
194
|
+
Models::EmailEnvelope.from_hash(
|
|
195
|
+
@client.request("/email/#{URI.encode_www_form_component(address)}")
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Resource: /phone/*
|
|
201
|
+
class PhoneResource
|
|
202
|
+
def initialize(client) = (@client = client)
|
|
203
|
+
|
|
204
|
+
def validate(number)
|
|
205
|
+
Models::PhoneEnvelope.from_hash(
|
|
206
|
+
@client.request("/phone/#{URI.encode_www_form_component(number)}")
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postio
|
|
4
|
+
# Base class for every Postio API failure.
|
|
5
|
+
#
|
|
6
|
+
# All instances carry: status (HTTP code, 0 for transport errors),
|
|
7
|
+
# error_code (the API's error string), details, request_id, and the
|
|
8
|
+
# raw envelope hash for any field this class doesn't expose.
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
attr_reader :status, :error_code, :details, :request_id, :envelope, :cause_error
|
|
11
|
+
|
|
12
|
+
def initialize(message, status: 0, error_code: nil, details: nil, request_id: nil, envelope: nil, cause: nil)
|
|
13
|
+
super(message)
|
|
14
|
+
@status = status
|
|
15
|
+
@error_code = error_code
|
|
16
|
+
@details = details
|
|
17
|
+
@request_id = request_id
|
|
18
|
+
@envelope = envelope
|
|
19
|
+
@cause_error = cause
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class ValidationError < Error; end # 400 / 422
|
|
24
|
+
class InvalidKeyError < Error; end # 401
|
|
25
|
+
class OutOfCreditError < Error; end # 402
|
|
26
|
+
class ForbiddenError < Error; end # 403
|
|
27
|
+
class NotFoundError < Error; end # 404
|
|
28
|
+
class ServerError < Error; end # 5xx
|
|
29
|
+
class TimeoutError < Error; end # local request timeout
|
|
30
|
+
class ConnectionError < Error; end # transport-level error
|
|
31
|
+
|
|
32
|
+
# 429 — rate limited. {#retry_after} is the API's suggested wait in seconds.
|
|
33
|
+
class RateLimitError < Error
|
|
34
|
+
attr_reader :retry_after
|
|
35
|
+
|
|
36
|
+
def initialize(message, retry_after: nil, **kwargs)
|
|
37
|
+
super(message, **kwargs)
|
|
38
|
+
@retry_after = retry_after
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Map an HTTP status to the typed error class.
|
|
43
|
+
STATUS_ERRORS = {
|
|
44
|
+
400 => ValidationError,
|
|
45
|
+
401 => InvalidKeyError,
|
|
46
|
+
402 => OutOfCreditError,
|
|
47
|
+
403 => ForbiddenError,
|
|
48
|
+
404 => NotFoundError,
|
|
49
|
+
422 => ValidationError,
|
|
50
|
+
429 => RateLimitError
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
def self.error_class_for(status)
|
|
54
|
+
return STATUS_ERRORS[status] if STATUS_ERRORS.key?(status)
|
|
55
|
+
return ServerError if status >= 500
|
|
56
|
+
|
|
57
|
+
Error
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postio
|
|
4
|
+
module Models
|
|
5
|
+
# Performance — per-request timing breakdown.
|
|
6
|
+
Performance = Data.define(:worker_ms, :lookup_ms) do
|
|
7
|
+
def self.from_hash(h)
|
|
8
|
+
new(worker_ms: h["workerMs"].to_i, lookup_ms: h["lookupMs"].to_i)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Meta — response envelope metadata for every endpoint except /connect.
|
|
13
|
+
Meta = Data.define(:count_results, :request_id, :performance) do
|
|
14
|
+
def self.from_hash(h)
|
|
15
|
+
new(
|
|
16
|
+
count_results: h["countResults"].to_i,
|
|
17
|
+
request_id: h["requestId"].to_s,
|
|
18
|
+
performance: Performance.from_hash(h["performance"])
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# MetaConnect — meta block for /connect (no count).
|
|
24
|
+
MetaConnect = Data.define(:request_id, :performance) do
|
|
25
|
+
def self.from_hash(h)
|
|
26
|
+
new(
|
|
27
|
+
request_id: h["requestId"].to_s,
|
|
28
|
+
performance: Performance.from_hash(h["performance"])
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# AddressSearchResult — a single typeahead hit.
|
|
34
|
+
AddressSearchResult = Data.define(:udprn, :suggestion) do
|
|
35
|
+
def self.from_hash(h)
|
|
36
|
+
new(udprn: h["udprn"].to_i, suggestion: h["suggestion"].to_s)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Address — full PAF + OS record. Many fields are optional.
|
|
41
|
+
Address = Data.define(
|
|
42
|
+
:udprn, :postcode, :postcode_outward, :postcode_inward, :postcode_type,
|
|
43
|
+
:address_line_1, :address_line_2, :address_line_3, :post_town,
|
|
44
|
+
:organisation_name, :department_name, :building_name, :building_number,
|
|
45
|
+
:sub_building_name, :po_box, :thoroughfare, :dependent_thoroughfare,
|
|
46
|
+
:dependent_locality, :double_dependent_locality, :delivery_point_suffix,
|
|
47
|
+
:country, :county, :district, :ward,
|
|
48
|
+
:latitude, :longitude, :eastings, :northings
|
|
49
|
+
) do
|
|
50
|
+
def self.from_hash(h)
|
|
51
|
+
new(
|
|
52
|
+
udprn: h["udprn"].to_i,
|
|
53
|
+
postcode: h["postcode"].to_s,
|
|
54
|
+
postcode_outward: h["postcode_outward"],
|
|
55
|
+
postcode_inward: h["postcode_inward"],
|
|
56
|
+
postcode_type: h["postcode_type"],
|
|
57
|
+
address_line_1: h["address_line_1"],
|
|
58
|
+
address_line_2: h["address_line_2"],
|
|
59
|
+
address_line_3: h["address_line_3"],
|
|
60
|
+
post_town: h["post_town"],
|
|
61
|
+
organisation_name: h["organisation_name"],
|
|
62
|
+
department_name: h["department_name"],
|
|
63
|
+
building_name: h["building_name"],
|
|
64
|
+
building_number: h["building_number"],
|
|
65
|
+
sub_building_name: h["sub_building_name"],
|
|
66
|
+
po_box: h["po_box"],
|
|
67
|
+
thoroughfare: h["thoroughfare"],
|
|
68
|
+
dependent_thoroughfare: h["dependent_thoroughfare"],
|
|
69
|
+
dependent_locality: h["dependent_locality"],
|
|
70
|
+
double_dependent_locality: h["double_dependent_locality"],
|
|
71
|
+
delivery_point_suffix: h["delivery_point_suffix"],
|
|
72
|
+
country: h["country"],
|
|
73
|
+
county: h["county"],
|
|
74
|
+
district: h["district"],
|
|
75
|
+
ward: h["ward"],
|
|
76
|
+
latitude: h["latitude"]&.to_f,
|
|
77
|
+
longitude: h["longitude"]&.to_f,
|
|
78
|
+
eastings: h["eastings"]&.to_i,
|
|
79
|
+
northings: h["northings"]&.to_i
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# EmailResult — validation verdict for one email address.
|
|
85
|
+
EmailResult = Data.define(
|
|
86
|
+
:email, :is_valid_syntax, :did_you_mean, :is_disposable, :is_free_provider,
|
|
87
|
+
:is_role_account, :mx_found, :smtp_check, :is_catch_all, :deliverability
|
|
88
|
+
) do
|
|
89
|
+
DELIVERABILITY_DELIVERABLE = "deliverable"
|
|
90
|
+
DELIVERABILITY_UNDELIVERABLE = "undeliverable"
|
|
91
|
+
DELIVERABILITY_RISKY = "risky"
|
|
92
|
+
DELIVERABILITY_UNKNOWN = "unknown"
|
|
93
|
+
DELIVERABILITY_INVALID = "invalid"
|
|
94
|
+
|
|
95
|
+
def self.from_hash(h)
|
|
96
|
+
new(
|
|
97
|
+
email: h["email"].to_s,
|
|
98
|
+
is_valid_syntax: h["isValidSyntax"] == true,
|
|
99
|
+
did_you_mean: h["didYouMean"],
|
|
100
|
+
is_disposable: h["isDisposable"] == true,
|
|
101
|
+
is_free_provider: h["isFreeProvider"] == true,
|
|
102
|
+
is_role_account: h["isRoleAccount"] == true,
|
|
103
|
+
mx_found: h["mxFound"] == true,
|
|
104
|
+
smtp_check: h["smtpCheck"],
|
|
105
|
+
is_catch_all: h["isCatchAll"],
|
|
106
|
+
deliverability: h["deliverability"].to_s
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# PhoneResult — validation verdict for one phone number.
|
|
112
|
+
#
|
|
113
|
+
# SPEC DRIFT (2026-05-02): the OpenAPI spec marks every nullable
|
|
114
|
+
# field as required, but on invalid input the live API drops them
|
|
115
|
+
# entirely. The .from_hash fetcher uses h["..."] which returns nil
|
|
116
|
+
# for missing keys, papering over the drift. Also: spec says
|
|
117
|
+
# is_reachable is string|null, but the live API returns bool — we
|
|
118
|
+
# accept either.
|
|
119
|
+
PhoneResult = Data.define(
|
|
120
|
+
:number, :is_valid, :is_possible, :type, :country_code, :country_name,
|
|
121
|
+
:national_format, :international_format, :e164_format, :original_carrier,
|
|
122
|
+
:current_carrier, :is_ported, :is_reachable, :mcc, :mnc, :level, :lookup_error
|
|
123
|
+
) do
|
|
124
|
+
def self.from_hash(h)
|
|
125
|
+
new(
|
|
126
|
+
number: h["number"].to_s,
|
|
127
|
+
is_valid: h["isValid"] == true,
|
|
128
|
+
is_possible: h["isPossible"] == true,
|
|
129
|
+
type: h["type"],
|
|
130
|
+
country_code: h["countryCode"],
|
|
131
|
+
country_name: h["countryName"],
|
|
132
|
+
national_format: h["nationalFormat"],
|
|
133
|
+
international_format: h["internationalFormat"],
|
|
134
|
+
e164_format: h["e164Format"],
|
|
135
|
+
original_carrier: h["originalCarrier"],
|
|
136
|
+
current_carrier: h["currentCarrier"],
|
|
137
|
+
is_ported: h["isPorted"],
|
|
138
|
+
is_reachable: h["isReachable"],
|
|
139
|
+
mcc: h["mcc"],
|
|
140
|
+
mnc: h["mnc"],
|
|
141
|
+
level: h["level"],
|
|
142
|
+
lookup_error: h["lookupError"]
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Envelopes — one per endpoint.
|
|
148
|
+
AddressSearchEnvelope = Data.define(:success, :results, :meta) do
|
|
149
|
+
def self.from_hash(h)
|
|
150
|
+
new(
|
|
151
|
+
success: h["success"] == true,
|
|
152
|
+
results: (h["results"] || []).map { |r| AddressSearchResult.from_hash(r) },
|
|
153
|
+
meta: Meta.from_hash(h["meta"])
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
AddressPostcodeEnvelope = Data.define(:success, :results, :meta) do
|
|
159
|
+
def self.from_hash(h)
|
|
160
|
+
new(
|
|
161
|
+
success: h["success"] == true,
|
|
162
|
+
results: (h["results"] || []).map { |r| Address.from_hash(r) },
|
|
163
|
+
meta: Meta.from_hash(h["meta"])
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
AddressUdprnEnvelope = Data.define(:success, :results, :meta) do
|
|
169
|
+
def self.from_hash(h)
|
|
170
|
+
new(
|
|
171
|
+
success: h["success"] == true,
|
|
172
|
+
results: (h["results"] || []).map { |r| Address.from_hash(r) },
|
|
173
|
+
meta: Meta.from_hash(h["meta"])
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
EmailEnvelope = Data.define(:success, :results, :meta) do
|
|
179
|
+
def self.from_hash(h)
|
|
180
|
+
new(
|
|
181
|
+
success: h["success"] == true,
|
|
182
|
+
results: (h["results"] || []).map { |r| EmailResult.from_hash(r) },
|
|
183
|
+
meta: Meta.from_hash(h["meta"])
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
PhoneEnvelope = Data.define(:success, :results, :meta) do
|
|
189
|
+
def self.from_hash(h)
|
|
190
|
+
new(
|
|
191
|
+
success: h["success"] == true,
|
|
192
|
+
results: (h["results"] || []).map { |r| PhoneResult.from_hash(r) },
|
|
193
|
+
meta: Meta.from_hash(h["meta"])
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
ConnectSuccess = Data.define(:success, :meta) do
|
|
199
|
+
def self.from_hash(h)
|
|
200
|
+
new(success: h["success"] == true, meta: MetaConnect.from_hash(h["meta"]))
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
data/lib/postio.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "postio/version"
|
|
4
|
+
require_relative "postio/errors"
|
|
5
|
+
require_relative "postio/models"
|
|
6
|
+
require_relative "postio/client"
|
|
7
|
+
|
|
8
|
+
# Top-level Postio module. Use Postio::Client to construct the API client.
|
|
9
|
+
#
|
|
10
|
+
# client = Postio::Client.new(api_key: "pk_live_...")
|
|
11
|
+
# client.address.search("downing street")
|
|
12
|
+
module Postio
|
|
13
|
+
end
|
data/postio.gemspec
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/postio/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "postio"
|
|
7
|
+
spec.version = Postio::VERSION
|
|
8
|
+
spec.authors = ["Postio"]
|
|
9
|
+
spec.email = ["admin@postio.co.uk"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Ruby SDK for the Postio API — UK address, email, and phone validation."
|
|
12
|
+
spec.description = "Ruby client for the Postio API. UK address, email, and phone validation backed by Royal Mail PAF and Ordnance Survey. Stdlib net/http, no external runtime dependencies."
|
|
13
|
+
spec.homepage = "https://postio.co.uk"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 3.2"
|
|
16
|
+
|
|
17
|
+
spec.metadata = {
|
|
18
|
+
"homepage_uri" => "https://postio.co.uk",
|
|
19
|
+
"documentation_uri" => "https://postio.co.uk/docs",
|
|
20
|
+
"source_code_uri" => "https://github.com/postio-uk/postio-ruby",
|
|
21
|
+
"bug_tracker_uri" => "https://github.com/postio-uk/postio-ruby/issues",
|
|
22
|
+
"changelog_uri" => "https://github.com/postio-uk/postio-ruby/blob/master/CHANGELOG.md",
|
|
23
|
+
"rubygems_mfa_required" => "true"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE", "CHANGELOG.md", "postio.gemspec"]
|
|
27
|
+
spec.require_paths = ["lib"]
|
|
28
|
+
|
|
29
|
+
spec.add_development_dependency "rspec", "~> 3.13"
|
|
30
|
+
spec.add_development_dependency "webmock", "~> 3.23"
|
|
31
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
32
|
+
spec.add_development_dependency "rubocop", "~> 1.65"
|
|
33
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: postio
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Postio
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-02 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rspec
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.13'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3.13'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: webmock
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.23'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.23'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rubocop
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.65'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.65'
|
|
69
|
+
description: Ruby client for the Postio API. UK address, email, and phone validation
|
|
70
|
+
backed by Royal Mail PAF and Ordnance Survey. Stdlib net/http, no external runtime
|
|
71
|
+
dependencies.
|
|
72
|
+
email:
|
|
73
|
+
- admin@postio.co.uk
|
|
74
|
+
executables: []
|
|
75
|
+
extensions: []
|
|
76
|
+
extra_rdoc_files: []
|
|
77
|
+
files:
|
|
78
|
+
- CHANGELOG.md
|
|
79
|
+
- LICENSE
|
|
80
|
+
- README.md
|
|
81
|
+
- lib/postio.rb
|
|
82
|
+
- lib/postio/client.rb
|
|
83
|
+
- lib/postio/errors.rb
|
|
84
|
+
- lib/postio/models.rb
|
|
85
|
+
- lib/postio/version.rb
|
|
86
|
+
- postio.gemspec
|
|
87
|
+
homepage: https://postio.co.uk
|
|
88
|
+
licenses:
|
|
89
|
+
- MIT
|
|
90
|
+
metadata:
|
|
91
|
+
homepage_uri: https://postio.co.uk
|
|
92
|
+
documentation_uri: https://postio.co.uk/docs
|
|
93
|
+
source_code_uri: https://github.com/postio-uk/postio-ruby
|
|
94
|
+
bug_tracker_uri: https://github.com/postio-uk/postio-ruby/issues
|
|
95
|
+
changelog_uri: https://github.com/postio-uk/postio-ruby/blob/master/CHANGELOG.md
|
|
96
|
+
rubygems_mfa_required: 'true'
|
|
97
|
+
post_install_message:
|
|
98
|
+
rdoc_options: []
|
|
99
|
+
require_paths:
|
|
100
|
+
- lib
|
|
101
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '3.2'
|
|
106
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0'
|
|
111
|
+
requirements: []
|
|
112
|
+
rubygems_version: 3.5.22
|
|
113
|
+
signing_key:
|
|
114
|
+
specification_version: 4
|
|
115
|
+
summary: Ruby SDK for the Postio API — UK address, email, and phone validation.
|
|
116
|
+
test_files: []
|