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 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
+ [![Gem Version](https://img.shields.io/gem/v/postio.svg)](https://rubygems.org/gems/postio)
4
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-red)](https://rubygems.org/gems/postio)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postio
4
+ VERSION = "0.1.0"
5
+ 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: []