Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/.bundle/
/coverage/
/pkg/
/.openapi-generator.*
/.generation-check.*
*.gem
*.log

6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
source "https://rubygems.org"

gemspec

gem "minitest"
gem "rake"
44 changes: 44 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
PATH
remote: .
specs:
replynodes (0.1.0)
faraday (>= 2.0, < 3.0)
faraday-multipart (>= 1.0)
marcel (>= 1.0)

GEM
remote: https://rubygems.org/
specs:
drb (2.2.3)
faraday (2.14.4)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
net-http (~> 0.5)
json (3.0.2)
logger (1.7.0)
marcel (2.1.0)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
multipart-post (2.4.1)
net-http (0.9.1)
uri (>= 0.11.1)
prism (1.9.0)
rake (13.4.2)
uri (1.1.1)

PLATFORMS
ruby
x86_64-linux

DEPENDENCIES
minitest
rake
replynodes!

BUNDLED WITH
2.5.22
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
# ReplyNodes Ruby SDK
# ReplyNodes Ruby SDK (legacy)

Ruby client for the authenticated, read-only ReplyNodes API.
This repository contains a generated REST SDK whose public contract is pending audit. It is retained for historical reference only and is not a current installation or endpoint guide.

This repository is the source for the `replynodes` gem. The generated HTTP
client is isolated under `lib/replynodes/generated/`; the public `ReplyNodes::Client`
API is maintained separately so generated files can be regenerated safely.
Do not use the retired REST default from older examples. The canonical current agent-facing surface is the ReplyNodes MCP:

Implementation is being developed from the canonical ReplyNodes Fetcher
OpenAPI contract.
- Repository: https://github.com/replynodes/replynodes-mcp
- Endpoint: `https://mcp.replynodes.com/mcp`

The MCP is not a drop-in REST replacement for this generated Ruby client. Contract and release decisions for the SDK are tracked separately; this documentation intentionally does not provide a replacement REST base URL.

## Development

Generated sources remain under `lib/replynodes/generated/` and are produced from the vendored OpenAPI contract. Do not treat the generated surface as current production guidance until the SDK contract audit is complete.
11 changes: 11 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

require "rake/testtask"

Rake::TestTask.new do |task|
task.libs << "lib"
task.libs << "test"
task.pattern = "test/**/*_test.rb"
end

task default: :test
8 changes: 8 additions & 0 deletions config/.openapi-generator-ignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# The public gem owns its gemspec, README, and tests outside the generated tree.
*
!lib/
!lib/**
!lib/replynodes/
!lib/replynodes/**
!lib/replynodes/generated/
!lib/replynodes/generated/**
23 changes: 23 additions & 0 deletions config/openapi-generator.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"allowUnicodeIdentifiers": false,
"disallowAdditionalPropertiesIfNotPresent": false,
"ensureUniqueParams": true,
"enumUnknownDefaultCase": false,
"gemAuthor": "ReplyNodes",
"gemAuthorEmail": "support@replynodes.com",
"gemDescription": "Ruby client for the ReplyNodes read-only data API",
"gemHomepage": "https://replynodes.com",
"gemLicense": "MIT",
"gemName": "replynodes",
"gemRequiredRubyVersion": ">= 3.0",
"gemSummary": "ReplyNodes Ruby SDK",
"gemVersion": "0.1.0",
"hideGenerationTimestamp": true,
"legacyDiscriminatorBehavior": true,
"library": "faraday",
"moduleName": "ReplyNodesGenerated",
"prependFormOrBodyParameters": false,
"sortModelPropertiesByRequiredFlag": true,
"sortParamsByRequiredFlag": true,
"useAutoload": false
}
17 changes: 17 additions & 0 deletions lib/replynodes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

require "set"

generated_path = File.expand_path("replynodes/generated", __dir__)
$LOAD_PATH.unshift(generated_path) unless $LOAD_PATH.include?(generated_path)

# Bundler evaluates the gemspec before loading the library, which loads the
# public version file under the same require feature as the generated version.
# Load the generated namespace's version by path so ApiClient always sees it.
require_relative "replynodes/generated/replynodes/version"
require "replynodes/generated"
require_relative "replynodes/version"
require_relative "replynodes/operation_registry"
require_relative "replynodes/error"
require_relative "replynodes/response"
require_relative "replynodes/client"
130 changes: 130 additions & 0 deletions lib/replynodes/client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# frozen_string_literal: true

require "uri"

module ReplyNodes
class Client
DEFAULT_BASE_URL = "https://api.replynodes.com"
DEFAULT_TIMEOUT = 60

attr_reader :base_url, :timeout

def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, user_agent: nil)
raise ArgumentError, "api_key is required" if api_key.nil? || api_key.to_s.empty?

@base_url = normalize_base_url(base_url)
@timeout = Float(timeout)
raise ArgumentError, "timeout must be greater than zero" unless @timeout.positive?

configuration = ReplyNodesGenerated::Configuration.new
configure_base_url(configuration, @base_url)
configuration.access_token = api_key.to_s
configuration.timeout = @timeout
# The generated Faraday client has no retry middleware. Keep this
# explicit: every SDK operation performs exactly one HTTP GET attempt.
configuration.configure_faraday_connection { |_connection| }

@api_client = ReplyNodesGenerated::ApiClient.new(configuration)
@api_client.user_agent = user_agent if user_agent
@apis = {}
end

def call(operation, *positional, **keyword)
name = operation.to_sym
specification = OPERATIONS.fetch(name) do
raise ArgumentError, "unknown ReplyNodes operation: #{operation}"
end
params = normalize_params(positional, keyword)
required_values, optional_values = split_params(specification, params, positional)
api = api_for(specification[:api_class])
method = "#{specification[:generated_method]}_with_http_info"
generated, status, headers = api.public_send(method, *required_values, optional_values)
Response.new(generated, status, headers)
rescue ReplyNodesGenerated::ApiError => e
raise Error.from_exception(e), cause: e
end

OPERATIONS.each_key do |operation|
define_method(operation) do |*positional, **keyword|
call(operation, *positional, **keyword)
end
end

private

def normalize_base_url(value)
uri = URI.parse(value.to_s)
unless %w[http https].include?(uri.scheme) && uri.host
raise ArgumentError, "base_url must be an absolute HTTP(S) URL"
end
if uri.userinfo || uri.query || uri.fragment
raise ArgumentError, "base_url must not contain credentials, query, or fragment"
end

uri.to_s.sub(%r{/\z}, "")
rescue URI::InvalidURIError
raise ArgumentError, "base_url must be an absolute HTTP(S) URL"
end

def configure_base_url(configuration, value)
uri = URI.parse(value)
configuration.ignore_operation_servers = true
configuration.scheme = uri.scheme
host = uri.host
default_port = uri.scheme == "https" ? 443 : 80
host = "#{host}:#{uri.port}" if uri.port && uri.port != default_port
configuration.host = host
configuration.base_path = uri.path
end

def normalize_params(positional, keyword)
if positional.last.is_a?(Hash) && keyword.empty?
positional.pop.merge
else
keyword
end
end

def split_params(specification, params, positional)
params = params.transform_keys(&:to_sym)
required = specification[:parameters].select { |parameter| parameter[:required] }
optional = specification[:parameters] - required
if positional.length > required.length
raise ArgumentError, "too many positional arguments for #{specification[:operation_id]}"
end

required_values = required.each_with_index.map do |parameter, index|
if index < positional.length
positional[index]
else
value, = take_param(params, parameter, required: true)
value
end
end

optional_values = {}
optional.each do |parameter|
value, present = take_param(params, parameter, required: false)
optional_values[parameter[:name]] = value if present
end

unknown = params.keys - specification[:parameters].flat_map { |parameter| [parameter[:name], parameter[:wire_name].to_sym] }
raise ArgumentError, "unknown parameter(s) for #{specification[:operation_id]}: #{unknown.join(", ")}" unless unknown.empty?

[required_values, optional_values]
end

def take_param(params, parameter, required:)
keys = [parameter[:name], parameter[:wire_name].to_sym]
key = keys.find { |candidate| params.key?(candidate) }
return [params.delete(key), true] if key
return [nil, false] unless required

raise ArgumentError, "missing required parameter: #{parameter[:name]}"
end

def api_for(class_name)
@apis[class_name] ||= ReplyNodesGenerated.const_get(class_name).new(@api_client)
end
end
end
73 changes: 73 additions & 0 deletions lib/replynodes/error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# frozen_string_literal: true

require "json"

module ReplyNodes
class Error < StandardError
attr_reader :status, :code, :request_id, :response_headers, :response_body, :cause

def initialize(status:, code:, message:, request_id: nil, response_headers: {}, response_body: nil, cause: nil)
@status = status
@code = code
@request_id = request_id
@response_headers = response_headers || {}
@response_body = response_body
@cause = cause
@detail_message = message
super(message)
end

def message
"ReplyNodes request failed (status=#{status || "n/a"}, code=#{code || "n/a"}, " \
"message=#{@detail_message}, request_id=#{request_id || "n/a"})"
end

def to_s
message
end

def self.from_exception(exception)
body = exception.respond_to?(:response_body) ? exception.response_body : nil
headers = exception.respond_to?(:response_headers) ? exception.response_headers : {}
status = exception.respond_to?(:code) && exception.code.to_i.positive? ? exception.code.to_i : nil
payload = parse_body(body)
detail = payload.is_a?(Hash) ? (payload["error"] || payload[:error] || {}) : {}
detail = {} unless detail.is_a?(Hash)
request_id = detail["request_id"] || detail[:request_id] || header_value(headers, "x-request-id")
code = detail["code"] || detail[:code] || (status ? "http_#{status}" : "network_error")
message = detail["message"] || detail[:message] || exception.message.to_s

if exception.is_a?(ReplyNodesGenerated::ApiError) && exception.message.to_s.match?(/timed out/i)
code = "timeout"
message = "Request timed out"
end

new(
status: status,
code: code,
message: message,
request_id: request_id,
response_headers: headers,
response_body: body,
cause: exception
)
end

def self.parse_body(body)
return {} unless body.is_a?(String) && !body.empty?

JSON.parse(body)
rescue JSON::ParserError
{}
end
private_class_method :parse_body

def self.header_value(headers, wanted)
headers.to_h.each do |key, value|
return value if key.to_s.downcase == wanted
end
nil
end
private_class_method :header_value
end
end
Loading