trueform 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: e8c1befd1e0c8f22c56730d0b668a30df78f406bc43dbdde1512a439bf961c10
4
+ data.tar.gz: 6ee4f568e35bf1d11630c31fb3629287c8d78984bbffb419fa4c9a126b9e7bbc
5
+ SHA512:
6
+ metadata.gz: 40bbe46e945ab6d664cc2fe9f6d4acd277bbed27cecdf7dc2d7f3394537ca2b09a67152fa385abe89ed6e75ea5b2b38575ab49ec1a5a8be74fa3185119f44c54
7
+ data.tar.gz: e97329fe0109144397e5a223d4c1f60ff0b0cb79168be59a5027dd0d8f9262ac95e09a4080f41b6e720c7cec0b92ae4990b30172021479c9e4149105a834eaa1
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ All notable changes to the Trueform Ruby SDK are documented here.
4
+
5
+ ## [0.1.0] - 2026-08-13
6
+
7
+ - Added the `Trueform::Client` and `validations.create` resource interface.
8
+ - Added immutable validation results with idiomatic predicate methods.
9
+ - Added configurable retries, timeouts, and namespaced errors.
10
+ - Added validation of successful API response shapes.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Trueform
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,116 @@
1
+ # Trueform Ruby SDK
2
+
3
+ The official Ruby client for the [Trueform email validation API](https://trueform.cloud/docs/).
4
+
5
+ [Ruby SDK documentation](https://trueform.cloud/docs/ruby/) | [API reference](https://trueform.cloud/docs/api-reference/)
6
+
7
+ ## Install
8
+
9
+ Add the gem to your bundle:
10
+
11
+ ```ruby
12
+ gem "trueform"
13
+ ```
14
+
15
+ Then run `bundle install`. You can also install it directly with `gem install trueform`.
16
+
17
+ The SDK supports Ruby 3.1 and newer. It has no runtime dependencies and does not require an API key.
18
+
19
+ ## Quickstart
20
+
21
+ ```ruby
22
+ require "trueform"
23
+
24
+ trueform = Trueform::Client.new
25
+ validation = trueform.validations.create(email: "user@example.com")
26
+
27
+ puts "Email looks good" if validation.deliverable?
28
+ ```
29
+
30
+ ## Handle validation results
31
+
32
+ Validation results are immutable Ruby objects with predicate methods:
33
+
34
+ ```ruby
35
+ if validation.did_you_mean
36
+ puts "Did you mean #{validation.did_you_mean}?"
37
+ end
38
+
39
+ puts "Ask for a permanent email address" if validation.disposable?
40
+ ```
41
+
42
+ Available values and predicates:
43
+
44
+ - `email`
45
+ - `valid_format?`
46
+ - `freemail?`
47
+ - `disposable?`
48
+ - `mx_records?`
49
+ - `did_you_mean`
50
+ - `deliverable?`
51
+ - `to_h`
52
+
53
+ `deliverable?` is a domain-level verdict. It does not prove that a specific mailbox exists.
54
+
55
+ ## Configure the client
56
+
57
+ Timeout values and retry delays use seconds:
58
+
59
+ ```ruby
60
+ trueform = Trueform::Client.new(
61
+ timeout: 5,
62
+ max_retries: 2
63
+ )
64
+ ```
65
+
66
+ Override options for one request:
67
+
68
+ ```ruby
69
+ validation = trueform.validations.create(
70
+ email: "user@example.com",
71
+ timeout: 2,
72
+ max_retries: 0
73
+ )
74
+ ```
75
+
76
+ The client retries connection failures, timeouts, rate limits, and server errors. A `Retry-After`
77
+ response header controls the delay when present.
78
+
79
+ Use `base_url:` to point the client at a different Trueform-compatible endpoint.
80
+
81
+ ## Errors
82
+
83
+ ```ruby
84
+ begin
85
+ validation = trueform.validations.create(email: "user@example.com")
86
+ rescue Trueform::RateLimitError => error
87
+ warn "Retry after #{error.retry_after} seconds"
88
+ rescue Trueform::InvalidRequestError => error
89
+ warn error.message
90
+ end
91
+ ```
92
+
93
+ Exported errors:
94
+
95
+ - `Trueform::Error`
96
+ - `Trueform::APIError`
97
+ - `Trueform::InvalidRequestError`
98
+ - `Trueform::RateLimitError`
99
+ - `Trueform::ConnectionError`
100
+ - `Trueform::TimeoutError`
101
+
102
+ Errors expose `code`, `status`, `request_id`, and `retry_after` when available.
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ bundle install
108
+ bundle exec rake
109
+ ```
110
+
111
+ Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Report vulnerabilities
112
+ through the process in [SECURITY.md](SECURITY.md).
113
+
114
+ ## License
115
+
116
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "time"
6
+ require "timeout"
7
+ require "uri"
8
+
9
+ module Trueform
10
+ class Client
11
+ DEFAULT_BASE_URL = "https://api.trueform.cloud"
12
+ DEFAULT_TIMEOUT = 10
13
+ DEFAULT_MAX_RETRIES = 2
14
+ MAX_RETRY_DELAY = 60
15
+
16
+ attr_reader :validations
17
+
18
+ def initialize(
19
+ base_url: DEFAULT_BASE_URL,
20
+ timeout: DEFAULT_TIMEOUT,
21
+ max_retries: DEFAULT_MAX_RETRIES,
22
+ transport: Transport::NetHTTP.new
23
+ )
24
+ @base_url = normalize_base_url(base_url)
25
+ @timeout = positive_number(timeout, :timeout)
26
+ @max_retries = non_negative_integer(max_retries, :max_retries)
27
+ @transport = transport
28
+ @validations = Resources::Validations.new do |email, timeout: nil, max_retries: nil|
29
+ request(
30
+ method: :post,
31
+ path: "/v1/validations",
32
+ body: {email: email},
33
+ timeout: timeout,
34
+ max_retries: max_retries
35
+ )
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def request(method:, path:, body:, timeout:, max_retries:)
42
+ request_timeout = positive_number(timeout.nil? ? @timeout : timeout, :timeout)
43
+ retries = non_negative_integer(max_retries.nil? ? @max_retries : max_retries, :max_retries)
44
+ attempt = 0
45
+
46
+ begin
47
+ response = perform_request(
48
+ method: method,
49
+ path: path,
50
+ body: body,
51
+ timeout: request_timeout
52
+ )
53
+ parse_response(response)
54
+ rescue Error => error
55
+ raise unless retryable?(error) && attempt < retries
56
+
57
+ sleep(retry_delay(error, attempt))
58
+ attempt += 1
59
+ retry
60
+ end
61
+ end
62
+
63
+ def perform_request(method:, path:, body:, timeout:)
64
+ @transport.call(
65
+ method: method,
66
+ uri: URI.parse("#{@base_url}#{path}"),
67
+ headers: {
68
+ "Accept" => "application/json",
69
+ "Content-Type" => "application/json",
70
+ "User-Agent" => "trueform-ruby/#{VERSION}"
71
+ },
72
+ body: JSON.generate(body),
73
+ timeout: timeout
74
+ )
75
+ rescue Timeout::Error => timeout_error
76
+ raise TimeoutError.new(
77
+ "Request timed out after #{timeout} seconds.",
78
+ code: "request_timeout"
79
+ ), cause: timeout_error
80
+ rescue OpenSSL::SSL::SSLError, Net::ProtocolError, SocketError, IOError, SystemCallError => connection_error
81
+ raise ConnectionError.new(
82
+ "Unable to connect to the Trueform API.",
83
+ code: "connection_error"
84
+ ), cause: connection_error
85
+ end
86
+
87
+ def parse_response(response)
88
+ status = Integer(response.code)
89
+ request_id = response["x-request-id"] || response["cf-ray"]
90
+ body = parse_body(response.body, status: status, request_id: request_id)
91
+
92
+ if status.between?(200, 299)
93
+ unless body.is_a?(Hash)
94
+ raise APIError.new(
95
+ "The API returned an invalid JSON response.",
96
+ code: "invalid_response",
97
+ status: status,
98
+ request_id: request_id
99
+ )
100
+ end
101
+
102
+ return body
103
+ end
104
+
105
+ raise error_from_response(status, body, response, request_id)
106
+ end
107
+
108
+ def parse_body(value, status:, request_id:)
109
+ return nil if value.nil? || value.empty?
110
+
111
+ JSON.parse(value)
112
+ rescue JSON::ParserError => error
113
+ return nil unless status.between?(200, 299)
114
+
115
+ raise APIError.new(
116
+ "The API returned an invalid JSON response.",
117
+ code: "invalid_response",
118
+ status: status,
119
+ request_id: request_id
120
+ ), cause: error
121
+ end
122
+
123
+ def error_from_response(status, body, response, request_id)
124
+ message = if body.is_a?(Hash) && body["error"].is_a?(String)
125
+ body["error"]
126
+ else
127
+ "Request failed with status #{status}."
128
+ end
129
+
130
+ case status
131
+ when 400
132
+ InvalidRequestError.new(
133
+ message,
134
+ code: "invalid_request",
135
+ status: status,
136
+ request_id: request_id
137
+ )
138
+ when 429
139
+ RateLimitError.new(
140
+ message,
141
+ code: "rate_limit",
142
+ status: status,
143
+ request_id: request_id,
144
+ retry_after: parse_retry_after(response["retry-after"])
145
+ )
146
+ else
147
+ APIError.new(
148
+ message,
149
+ code: "api_error",
150
+ status: status,
151
+ request_id: request_id
152
+ )
153
+ end
154
+ end
155
+
156
+ def parse_retry_after(value)
157
+ return if value.nil? || value.empty?
158
+
159
+ seconds = Float(value, exception: false)
160
+ return [seconds, 0].max if seconds&.finite?
161
+
162
+ [Time.httpdate(value) - Time.now, 0].max
163
+ rescue ArgumentError
164
+ nil
165
+ end
166
+
167
+ def retryable?(error)
168
+ return true if error.is_a?(RateLimitError)
169
+ return true if error.is_a?(TimeoutError)
170
+ return true if error.is_a?(ConnectionError) && error.code == "connection_error"
171
+
172
+ error.is_a?(APIError) && (error.status == 408 || error.status.to_i >= 500)
173
+ end
174
+
175
+ def retry_delay(error, attempt)
176
+ return [error.retry_after, MAX_RETRY_DELAY].min if error.retry_after
177
+
178
+ [0.25 * (2**attempt), 2].min
179
+ end
180
+
181
+ def normalize_base_url(value)
182
+ unless value.is_a?(String)
183
+ raise ArgumentError, "base_url must be an HTTP or HTTPS URL"
184
+ end
185
+
186
+ uri = URI.parse(value)
187
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.query && !uri.fragment
188
+ raise ArgumentError, "base_url must be an HTTP or HTTPS URL"
189
+ end
190
+
191
+ value.sub(%r{/+\z}, "")
192
+ rescue URI::InvalidURIError
193
+ raise ArgumentError, "base_url must be an HTTP or HTTPS URL"
194
+ end
195
+
196
+ def positive_number(value, name)
197
+ valid = value.is_a?(Numeric) && value.positive? && (!value.respond_to?(:finite?) || value.finite?)
198
+ raise ArgumentError, "#{name} must be a positive number" unless valid
199
+
200
+ value
201
+ end
202
+
203
+ def non_negative_integer(value, name)
204
+ unless value.is_a?(Integer) && value >= 0
205
+ raise ArgumentError, "#{name} must be a non-negative integer"
206
+ end
207
+
208
+ value
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trueform
4
+ class Error < StandardError
5
+ attr_reader :code, :status, :request_id, :retry_after
6
+
7
+ def initialize(message, code:, status: nil, request_id: nil, retry_after: nil)
8
+ super(message)
9
+ @code = code
10
+ @status = status
11
+ @request_id = request_id
12
+ @retry_after = retry_after
13
+ end
14
+ end
15
+
16
+ class APIError < Error; end
17
+ class InvalidRequestError < APIError; end
18
+ class RateLimitError < APIError; end
19
+ class ConnectionError < Error; end
20
+ class TimeoutError < ConnectionError; end
21
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trueform
4
+ module Resources
5
+ class Validations
6
+ def initialize(&request)
7
+ @request = request
8
+ end
9
+
10
+ def create(email:, **options)
11
+ unless email.is_a?(String) && !email.strip.empty?
12
+ raise InvalidRequestError.new(
13
+ "The email parameter must be a non-empty string.",
14
+ code: "invalid_email"
15
+ )
16
+ end
17
+
18
+ response = @request.call(email, **options)
19
+ Validation.from_api_response(response)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module Trueform
6
+ module Transport
7
+ class NetHTTP
8
+ def call(method:, uri:, headers:, body:, timeout:)
9
+ request = request_class(method).new(uri)
10
+ headers.each { |name, value| request[name] = value }
11
+ request.body = body
12
+
13
+ http = Net::HTTP.new(uri.host, uri.port)
14
+ http.use_ssl = uri.scheme == "https"
15
+ http.open_timeout = timeout
16
+ http.read_timeout = timeout
17
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
18
+ http.request(request)
19
+ end
20
+
21
+ private
22
+
23
+ def request_class(method)
24
+ return Net::HTTP::Post if method == :post
25
+
26
+ raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trueform
4
+ class Validation
5
+ BOOLEAN_FIELDS = %w[
6
+ is_valid_format
7
+ is_freemail
8
+ is_disposable
9
+ has_mx_records
10
+ is_deliverable
11
+ ].freeze
12
+
13
+ attr_reader :email, :did_you_mean
14
+
15
+ def self.from_api_response(value)
16
+ valid = value.is_a?(Hash) &&
17
+ value["email"].is_a?(String) &&
18
+ BOOLEAN_FIELDS.all? { |field| value[field] == true || value[field] == false } &&
19
+ (value["did_you_mean"].nil? || value["did_you_mean"].is_a?(String))
20
+
21
+ unless valid
22
+ raise APIError.new(
23
+ "The API returned an invalid validation response.",
24
+ code: "invalid_response"
25
+ )
26
+ end
27
+
28
+ new(
29
+ email: value["email"],
30
+ valid_format: value["is_valid_format"],
31
+ freemail: value["is_freemail"],
32
+ disposable: value["is_disposable"],
33
+ mx_records: value["has_mx_records"],
34
+ did_you_mean: value["did_you_mean"],
35
+ deliverable: value["is_deliverable"]
36
+ )
37
+ end
38
+
39
+ def initialize(email:, valid_format:, freemail:, disposable:, mx_records:, did_you_mean:, deliverable:)
40
+ @email = email.dup.freeze
41
+ @valid_format = valid_format
42
+ @freemail = freemail
43
+ @disposable = disposable
44
+ @mx_records = mx_records
45
+ @did_you_mean = did_you_mean&.dup&.freeze
46
+ @deliverable = deliverable
47
+ freeze
48
+ end
49
+
50
+ def valid_format?
51
+ @valid_format
52
+ end
53
+
54
+ def freemail?
55
+ @freemail
56
+ end
57
+
58
+ def disposable?
59
+ @disposable
60
+ end
61
+
62
+ def mx_records?
63
+ @mx_records
64
+ end
65
+
66
+ def deliverable?
67
+ @deliverable
68
+ end
69
+
70
+ def to_h
71
+ {
72
+ email: @email,
73
+ is_valid_format: @valid_format,
74
+ is_freemail: @freemail,
75
+ is_disposable: @disposable,
76
+ has_mx_records: @mx_records,
77
+ did_you_mean: @did_you_mean,
78
+ is_deliverable: @deliverable
79
+ }
80
+ end
81
+
82
+ def ==(other)
83
+ other.is_a?(Validation) && to_h == other.to_h
84
+ end
85
+ alias_method :eql?, :==
86
+
87
+ def hash
88
+ to_h.hash
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trueform
4
+ VERSION = "0.1.0"
5
+ end
data/lib/trueform.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "trueform/version"
4
+ require_relative "trueform/error"
5
+ require_relative "trueform/validation"
6
+ require_relative "trueform/resources/validations"
7
+ require_relative "trueform/transport/net_http"
8
+ require_relative "trueform/client"
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: trueform
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Trueform
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A dependency-free Ruby client for validating email addresses with Trueform.
13
+ executables: []
14
+ extensions: []
15
+ extra_rdoc_files: []
16
+ files:
17
+ - CHANGELOG.md
18
+ - LICENSE
19
+ - README.md
20
+ - lib/trueform.rb
21
+ - lib/trueform/client.rb
22
+ - lib/trueform/error.rb
23
+ - lib/trueform/resources/validations.rb
24
+ - lib/trueform/transport/net_http.rb
25
+ - lib/trueform/validation.rb
26
+ - lib/trueform/version.rb
27
+ homepage: https://trueform.cloud/docs/ruby/
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ allowed_push_host: https://rubygems.org
32
+ bug_tracker_uri: https://github.com/True-Form-Cloud/trueform-ruby-sdk/issues
33
+ changelog_uri: https://github.com/True-Form-Cloud/trueform-ruby-sdk/blob/main/CHANGELOG.md
34
+ documentation_uri: https://trueform.cloud/docs/ruby/
35
+ homepage_uri: https://trueform.cloud/docs/ruby/
36
+ rubygems_mfa_required: 'true'
37
+ source_code_uri: https://github.com/True-Form-Cloud/trueform-ruby-sdk
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '3.1'
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 4.0.16
53
+ specification_version: 4
54
+ summary: Official Ruby SDK for the Trueform email validation API
55
+ test_files: []