diff --git a/lib/recurly.rb b/lib/recurly.rb index 6d653ebac..10d361356 100644 --- a/lib/recurly.rb +++ b/lib/recurly.rb @@ -10,6 +10,8 @@ require "recurly/connection_pool" require "recurly/client" require "recurly/webhooks" +require "recurly/config" +require "recurly/models" module Recurly STRICT_MODE = ENV["RECURLY_STRICT_MODE"] && ENV["RECURLY_STRICT_MODE"].downcase == "true" diff --git a/lib/recurly/config.rb b/lib/recurly/config.rb new file mode 100644 index 000000000..d15ac9be8 --- /dev/null +++ b/lib/recurly/config.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Recurly + # Configuration class for Recurly API client. + class Config + @@client = nil + + def self.client=(val) + @@client = val + end + + def self.api_key + ENV["RECURLY_API_KEY"] + end + + def self.host + ENV["RECURLY_HOST"] || "v3.recurly.com" + end + + def self.port + ENV["RECURLY_PORT"] || "443" + end + + def self.region + ENV["RECURLY_REGION"]&.to_sym || :us + end + + def self.debug? + ENV["RECURLY_DEBUG"].to_s.upcase == "TRUE" + end + + def self.base_url + "https://#{host}:#{port}" + end + + def self.ca_file + return unless host.end_with?(".recurly.dev") + + @ca_file ||= begin + path = File.join(File.dirname(__FILE__), "../../../../", "certs/ca_root.crt") + File.exist?(path) ? path : (raise "CA file does not exist: #{path}") + end + end + + def self.client + return @@client if @@client + + opt = { + api_key: api_key, + logger: Logger.new(STDOUT).tap { |l| l.level = debug? ? Logger::DEBUG : Logger::INFO }, + region: region, + base_url: base_url, + } + opt[:ca_file] = ca_file if ca_file + @client ||= Recurly::Client.new(**opt) + end + end +end diff --git a/lib/recurly/model.rb b/lib/recurly/model.rb new file mode 100644 index 000000000..231e03363 --- /dev/null +++ b/lib/recurly/model.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Recurly + # Base model class for Recurly ORM Interface. + class Model + API_KEYS = %i[ids limit sort order begin_time end_time].freeze + attr_accessor :resource + + def initialize(res) + @resource = res + end + + def self.client + cli = Config.client + raise "API client is not configured" unless cli + + cli + end + + def self.api_keys + API_KEYS + end + + # Lookup methods which should be implemented in subclasses. + def self.list(params) + raise NotImplementedError, "The list method should be implemented in the subclass." + end + + def self.get(id:) + raise NotImplementedError, "The get method should be implemented in the subclass." + end + + # Lookup methods + def self.all + where + end + + def self.where(query = query_params, *args) + if query.is_a?(String) + conditions = QueryParser.parse(query, *args) + api_params = {} + filter_params = [] + conditions.each do |cond| + val = cond[:value].respond_to?(:iso8601) ? cond[:value].iso8601 : cond[:value] + if api_keys.include?(cond[:key].to_sym) + api_params[cond[:key].to_sym] = val + else + filter_params << cond.merge(value: val) + end + end + pager = list(params: api_params) + ModelPager.new(model_class: self, pager: pager, filters: filter_params) + else + api_params = (query || {}).select { |k, _| api_keys.include?(k) } + pager = list(params: api_params) + ModelPager.new(model_class: self, pager: pager) + end + end + + def self.find_by(args = {}) + args[:id] ? new(get(id: args[:id])) : nil + rescue Recurly::Errors::NotFoundError + nil + end + + # Default query parameters for listing or searching models. + def self.query_params(args = nil) + params = { limit: 200 } + if args + params[:limit] = args[:limit] if args[:limit] + params[:sort] = args[:sort] if args[:sort] + params[:order] = args[:order] if args[:order] + params[:begin_time] = args[:begin_time].iso8601 if args[:begin_time] + params[:end_time] = args[:end_time].iso8601 if args[:end_time] + end + params + end + + # Method delegation to the resource for all methods not defined in this or child classes. + def method_missing(method, *args, &block) + if @resource.respond_to?(method) + @resource.public_send(method, *args, &block) + else + super + end + end + + def respond_to_missing?(method, include_private = false) + @resource.respond_to?(method, include_private) || super + end + end +end diff --git a/lib/recurly/model_filter.rb b/lib/recurly/model_filter.rb new file mode 100644 index 000000000..6fdbc9595 --- /dev/null +++ b/lib/recurly/model_filter.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Recurly + # Handles filtering logic for ModelPager + class ModelFilter + def initialize(filters) + @filters = filters + end + + def include?(model) + return true if @filters.nil? || @filters.empty? + + @filters.all? do |cond| + val = cond[:key].match(/\./) ? dig_value(model, cond[:key]) : model.public_send(cond[:key]) + cmp_val = cond[:value] + + val_parsed = try_parse(val) + cmp_parsed = try_parse(cmp_val) + + case cond[:op] + when "=" + val_parsed == cmp_parsed + when "!=" + val_parsed != cmp_parsed + when ">", ">=", "<", "<=" + return false if val_parsed.nil? || cmp_parsed.nil? + + case cond[:op] + when ">" + val_parsed > cmp_parsed + when ">=" + val_parsed >= cmp_parsed + when "<" + val_parsed < cmp_parsed + when "<=" + val_parsed <= cmp_parsed + end + else + false + end + end + end + + private + + # Recursively dig through nested attributes and arrays + def dig_value(obj, key_chain) + keys = key_chain.to_s.split(".") + current = obj + keys.each do |key| + if current.is_a?(Array) + current = current.map { |el| dig_value(el, key) }.flatten.compact + elsif current.respond_to?(key) + current = current.public_send(key) + else + return nil + end + end + current.is_a?(Array) && current.size == 1 ? current.first : current + end + + def try_parse(value) + begin + return DateTime.parse(value) if value.is_a?(String) + rescue ArgumentError; end + begin + return Integer(value) if value.to_s.match(/\A-?\d+\z/) + rescue ArgumentError, TypeError; end + begin + return Float(value) if value.to_s.match(/\A-?\d+(\.\d+)?\z/) + rescue ArgumentError, TypeError; end + value + end + end +end diff --git a/lib/recurly/model_pager.rb b/lib/recurly/model_pager.rb new file mode 100644 index 000000000..104c6344f --- /dev/null +++ b/lib/recurly/model_pager.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Recurly + # Pager extension for Recurly ORM Interface models, allowing iteration over model instances. + class ModelPager < Pager + include Enumerable + + def initialize(model_class:, pager:, filters: []) + @model_class = model_class + @filter = ModelFilter.new(filters) + super(client: pager.client, path: pager.next, options: pager.instance_variable_get(:@options)) + end + + def each(&block) + super do |item| + model = @model_class.new(item) + block.call(model) if @filter.include?(model) + end + end + + def each_page(&block) + super do |page| + transformed_page = page.each_with_object([]) do |item, result| + model = @model_class.new(item) + result << model if @filter.include?(model) + end + block.call(transformed_page) if block_given? + end + end + end +end diff --git a/lib/recurly/models.rb b/lib/recurly/models.rb new file mode 100644 index 000000000..47dbee21f --- /dev/null +++ b/lib/recurly/models.rb @@ -0,0 +1,7 @@ +require_relative "query_parser" +require_relative "model_pager" +require_relative "model_filter" +require_relative "model" +Dir[File.join(__dir__, "models", "*.rb")].each do |file| + require file unless file.end_with?("model.rb") +end diff --git a/lib/recurly/models/account.rb b/lib/recurly/models/account.rb new file mode 100644 index 000000000..2838d4d6c --- /dev/null +++ b/lib/recurly/models/account.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Recurly + # Represents an Account in Recurly + class Account < Recurly::Model + def self.list(params:) + p "Listing accounts with params: #{params.inspect}" + client.list_accounts(params: params) + end + + def self.get(id:) + client.get_account(account_id: id) + end + end +end diff --git a/lib/recurly/models/add_on.rb b/lib/recurly/models/add_on.rb new file mode 100644 index 000000000..a1dee2eff --- /dev/null +++ b/lib/recurly/models/add_on.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a AddOn in Recurly + class AddOn < Recurly::Model + def self.list(params:) + client.list_add_ons(params: params) + end + + def self.get(id:) + client.get_add_on(add_on_id: id) + end + end +end diff --git a/lib/recurly/models/business_entity.rb b/lib/recurly/models/business_entity.rb new file mode 100644 index 000000000..5f9a7562e --- /dev/null +++ b/lib/recurly/models/business_entity.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a BusinessEntity in Recurly + class BusinessEntity < Recurly::Model + def self.list(params:) + client.list_business_entities(params: params) + end + + def self.get(id:) + client.get_business_entity(business_entity_id: id) + end + end +end diff --git a/lib/recurly/models/coupon.rb b/lib/recurly/models/coupon.rb new file mode 100644 index 000000000..5228bb12a --- /dev/null +++ b/lib/recurly/models/coupon.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a Coupon in Recurly + class Coupon < Recurly::Model + def self.list(params:) + client.list_coupons(params: params) + end + + def self.get(id:) + client.get_coupon(coupon_id: id) + end + end +end diff --git a/lib/recurly/models/dunning_campaign.rb b/lib/recurly/models/dunning_campaign.rb new file mode 100644 index 000000000..cc0bc14fe --- /dev/null +++ b/lib/recurly/models/dunning_campaign.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a DunningCampaign in Recurly + class DunningCampaign < Recurly::Model + def self.list(params:) + client.list_dunning_campaigns(params: params) + end + + def self.get(id:) + client.get_dunning_campaign(dunning_campaign_id: id) + end + end +end diff --git a/lib/recurly/models/external_invoice.rb b/lib/recurly/models/external_invoice.rb new file mode 100644 index 000000000..b6410a0fe --- /dev/null +++ b/lib/recurly/models/external_invoice.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a ExternalInvoice in Recurly + class ExternalInvoice < Recurly::Model + def self.list(params:) + client.list_external_invoices(params: params) + end + + def self.get(id:) + client.show_external_invoice(external_invoice_id: id) + end + end +end diff --git a/lib/recurly/models/external_product.rb b/lib/recurly/models/external_product.rb new file mode 100644 index 000000000..cb73802c7 --- /dev/null +++ b/lib/recurly/models/external_product.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a ExternalProduct in Recurly + class ExternalProduct < Recurly::Model + def self.list(params:) + client.list_external_products(params: params) + end + + def self.get(id:) + client.get_external_product(external_product_id: id) + end + end +end diff --git a/lib/recurly/models/external_subscription.rb b/lib/recurly/models/external_subscription.rb new file mode 100644 index 000000000..12e6577b4 --- /dev/null +++ b/lib/recurly/models/external_subscription.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a ExternalSubscription in Recurly + class ExternalSubscription < Recurly::Model + def self.list(params:) + client.list_external_subscriptions(params: params) + end + + def self.get(id:) + client.get_external_subscription(external_subscription_id: id) + end + end +end diff --git a/lib/recurly/models/general_ledger_account.rb b/lib/recurly/models/general_ledger_account.rb new file mode 100644 index 000000000..37e3452fc --- /dev/null +++ b/lib/recurly/models/general_ledger_account.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a GeneralLedgerAccount in Recurly + class GeneralLedgerAccount < Recurly::Model + def self.list(params:) + client.list_general_ledger_accounts(params: params) + end + + def self.get(id:) + client.get_general_ledger_account(general_ledger_account_id: id) + end + end +end diff --git a/lib/recurly/models/gift_card.rb b/lib/recurly/models/gift_card.rb new file mode 100644 index 000000000..abb5002ee --- /dev/null +++ b/lib/recurly/models/gift_card.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a GiftCard in Recurly + class GiftCard < Recurly::Model + def self.list(params:) + client.list_gift_cards(params: params) + end + + def self.get(id:) + client.get_gift_card(gift_card_id: id) + end + end +end diff --git a/lib/recurly/models/invoice.rb b/lib/recurly/models/invoice.rb new file mode 100644 index 000000000..19cd39737 --- /dev/null +++ b/lib/recurly/models/invoice.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a Invoice in Recurly + class Invoice < Recurly::Model + def self.list(params:) + client.list_invoices(params: params) + end + + def self.get(id:) + client.get_invoice(invoice_id: id) + end + end +end diff --git a/lib/recurly/models/item.rb b/lib/recurly/models/item.rb new file mode 100644 index 000000000..85133bb67 --- /dev/null +++ b/lib/recurly/models/item.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a Item in Recurly + class Item < Recurly::Model + def self.list(params:) + client.list_items(params: params) + end + + def self.get(id:) + client.get_item(item_id: id) + end + end +end diff --git a/lib/recurly/models/line_item.rb b/lib/recurly/models/line_item.rb new file mode 100644 index 000000000..ba5e396fe --- /dev/null +++ b/lib/recurly/models/line_item.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a LineItem in Recurly + class LineItem < Recurly::Model + def self.list(params:) + client.list_line_items(params: params) + end + + def self.get(id:) + client.get_line_item(line_item_id: id) + end + end +end diff --git a/lib/recurly/models/measured_unit.rb b/lib/recurly/models/measured_unit.rb new file mode 100644 index 000000000..45140647c --- /dev/null +++ b/lib/recurly/models/measured_unit.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a MeasuredUnit in Recurly + class MeasuredUnit < Recurly::Model + def self.list(params:) + client.list_measured_unit(params: params) + end + + def self.get(id:) + client.get_measured_unit(measured_unit_id: id) + end + end +end diff --git a/lib/recurly/models/performance_obligation.rb b/lib/recurly/models/performance_obligation.rb new file mode 100644 index 000000000..9efd26833 --- /dev/null +++ b/lib/recurly/models/performance_obligation.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a PerformanceObligation in Recurly + class PerformanceObligation < Recurly::Model + def self.list(params:) + client.get_performance_obligations(params: params) + end + + def self.get(id:) + client.get_performance_obligation(performance_obligation_id: id) + end + end +end diff --git a/lib/recurly/models/plan.rb b/lib/recurly/models/plan.rb new file mode 100644 index 000000000..bb3a07f21 --- /dev/null +++ b/lib/recurly/models/plan.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a Plan in Recurly + class Plan < Recurly::Model + def self.list(params:) + client.list_plans(params: params) + end + + def self.get(id:) + client.get_plan(plan_id: id) + end + end +end diff --git a/lib/recurly/models/shipping_method.rb b/lib/recurly/models/shipping_method.rb new file mode 100644 index 000000000..24d5a4ec5 --- /dev/null +++ b/lib/recurly/models/shipping_method.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a ShippingMethod in Recurly + class ShippingMethod < Recurly::Model + def self.list(params:) + client.list_shipping_methods(params: params) + end + + def self.get(id:) + client.get_shipping_method(shipping_method_id: id) + end + end +end diff --git a/lib/recurly/models/site.rb b/lib/recurly/models/site.rb new file mode 100644 index 000000000..863244c8b --- /dev/null +++ b/lib/recurly/models/site.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a Site in Recurly + class Site < Recurly::Model + def self.list(params:) + client.list_sites(params: params) + end + + def self.get(id:) + client.get_site(site_id: id) + end + end +end diff --git a/lib/recurly/models/subscription.rb b/lib/recurly/models/subscription.rb new file mode 100644 index 000000000..06af0ff15 --- /dev/null +++ b/lib/recurly/models/subscription.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Recurly + # Represents a Subscription in Recurly + class Subscription < Recurly::Model + def self.api_keys + API_KEYS + %i[account_id] + end + + def self.list(params:) + return client.list_account_subscriptions(account_id: params[:account_id]) if params.key?(:account_id) + + client.list_subscriptions(params: params) + end + + def self.get(id:) + client.get_subscription(subscription_id: id) + end + end +end diff --git a/lib/recurly/models/transaction.rb b/lib/recurly/models/transaction.rb new file mode 100644 index 000000000..423a57d85 --- /dev/null +++ b/lib/recurly/models/transaction.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Recurly + # Represents a Transaction in Recurly + class Transaction < Recurly::Model + def self.list(params:) + client.list_transactions(params: params) + end + + def self.get(id:) + client.get_transaction(transaction_id: id) + end + end +end diff --git a/lib/recurly/query_parser.rb b/lib/recurly/query_parser.rb new file mode 100644 index 000000000..819c0af71 --- /dev/null +++ b/lib/recurly/query_parser.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Recurly + class QueryParser + # Parses a SQL-like query string and arguments into a hash of conditions. + # Example: "order = ? AND sort = ? AND state != ?", 'desc', 'updated_at', 'inactive' + def self.parse(query, *args) + conditions = [] + arg_index = 0 + raise ArgumentError, "Input too long" if query.length > 1000 + + reg = /([a-zA-Z_][\w\.]*)\s*(=|!=|>=|<=|>|<)\s*(\?|'(?:[^']*)'|"(?:[^"]*)"|-?\d+(?:\.\d+)?|\w+)/i + query.scan(reg).each do |key, op, val, _| + if val == "?" + value = args[arg_index] + arg_index += 1 + else + # Remove quotes if present + value = val.gsub(/\A['"]|['"]\z/, "") + # Convert numeric strings to numbers + if value.match(/\A-?\d+\z/) + value = value.to_i + elsif value.match(/\A-?\d+\.\d+\z/) + value = value.to_f + end + end + conditions << { key: key, op: op, value: value } + end + + conditions + end + end +end