clickfunnels-ruby-sdk 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: 5bf38ce3e85e117aee729468402ed494872b514f2c140f2af82c5726631e8c2e
4
+ data.tar.gz: cfed4a99267fc46fd30bafa227854a7b92ba9dc3d0f943be4dfa6cb1925bb144
5
+ SHA512:
6
+ metadata.gz: 22170fb6c932e6498371dcc7e2cc9881a9119418d6aadcbfa1b3edee5db89f20acc0c05e19d367939686feaf0777cf2a7be174ae477123d368462b423f975146
7
+ data.tar.gz: 790f7605f1ea571582ef9e9ff5fd2311dab374c954dc5784d73186239988128a60035b0827f83e60d0f5902180313509cd868ffcca7c09ccf9d6b10ba4d7156f
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2025-06-21
9
+
10
+ ### Added
11
+ - Initial implementation of ClickFunnels Ruby SDK
12
+ - Complete Ruby SDK implementation based on OpenAPI spec
13
+ - HTTP client with configurable adapters (Net::HTTP default)
14
+ - Resource-based API with method mapping for all ClickFunnels endpoints
15
+ - Comprehensive test suite with WebMock integration
16
+ - Debug logging with configurable file logger
17
+ - Error handling with structured error responses
18
+ - Bearer token authentication
19
+ - Support for workspace-scoped and team-scoped resources
20
+ - Dynamic resource path building with parent ID support
21
+ - MIT License
22
+
23
+ ### Features
24
+ - 138+ auto-generated resource classes covering all ClickFunnels API endpoints
25
+ - Configurable subdomain, API token, workspace ID, and team ID
26
+ - Graceful error handling returning structured error objects instead of exceptions
27
+ - Query parameter support for filtering, pagination, and search
28
+ - Full CRUD operations (GET, POST, PUT, PATCH, DELETE) for all resources
29
+ - Comprehensive documentation and interactive testing environment
30
+
31
+ [0.1.0]: https://github.com/RichStone/clickfunnels-ruby-sdk/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Rich Steinmetz
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,190 @@
1
+ # 🚧 ClickFunnels Ruby SDK (unofficial) - WIP 🚧
2
+
3
+ A simple, dynamic Ruby SDK for the ClickFunnels API with support for all endpoints and operations mostly generated by Claude Code.
4
+
5
+ 🚧 Before using in prod, this client will need additional fixing and testing. Let the authors know if you would like to use it to accelerate. 🚧
6
+
7
+ ## Quick Start Testing
8
+
9
+ ```bash
10
+ # Or jump straight to IRB with pre-configured environment
11
+ bundle exec irb -r ./irb_test.rb
12
+ ```
13
+
14
+ ## Installation
15
+
16
+ Add this line to your application's Gemfile:
17
+
18
+ ```ruby
19
+ gem "clickfunnels-ruby-sdk"
20
+ ```
21
+
22
+ And then execute:
23
+
24
+ $ bundle install
25
+
26
+ Or install it yourself as:
27
+
28
+ $ gem install clickfunnels-ruby-sdk
29
+
30
+ ## Configuration
31
+
32
+ Configure the SDK with your ClickFunnels subdomain and API token:
33
+
34
+ ```ruby
35
+ CF.configure do |config|
36
+ config.subdomain = "myaccount" # Your ClickFunnels subdomain
37
+ config.api_token = "your_bearer_token" # Your API token
38
+ config.workspace_id = "ws_123" # Default workspace ID (optional)
39
+ config.team_id = "team_456" # Default team ID (optional)
40
+ config.debug = true # Enable debug logging (optional)
41
+ config.timeout = 60 # Request timeout in seconds (optional)
42
+ end
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ The SDK provides dynamic access to all ClickFunnels API endpoints following the same structure as documented in the API.
48
+
49
+ ### Basic CRUD Operations
50
+
51
+ ```ruby
52
+ # List resources
53
+ CF::Orders::Invoice.list
54
+
55
+ # Get a specific resource
56
+ CF::Orders::Invoice.get(123)
57
+
58
+ # Create a new resource
59
+ CF::Orders::Invoice.create(amount: 100, currency: "USD")
60
+
61
+ # Update a resource
62
+ CF::Orders::Invoice.update(123, status: "paid")
63
+
64
+ # Delete a resource
65
+ CF::Orders::Invoice.delete(123)
66
+ ```
67
+
68
+ ### 🆕 Workspace-Nested Resources
69
+
70
+ ```ruby
71
+ # Using default workspace_id from configuration
72
+ CF::Workspaces::Contact.list
73
+ CF::Workspaces::Contact.get(123)
74
+ CF::Workspaces::Contact.create(email: "test@example.com", name: "John Doe")
75
+
76
+ # Override workspace_id for specific requests
77
+ CF::Workspaces::Contact.list(workspace_id: "different_workspace")
78
+ CF::Workspaces::Contact.create(
79
+ { email: "test@example.com" },
80
+ { workspace_id: "custom_workspace" }
81
+ )
82
+ ```
83
+
84
+ ### Pagination
85
+
86
+ Use cursor-based pagination with `after` and `before` parameters:
87
+
88
+ ```ruby
89
+ # Get invoices after a specific ID
90
+ CF::Orders::Invoice.list(after: { id: 123 })
91
+
92
+ # Get invoices before a specific ID
93
+ CF::Orders::Invoice.list(before: { id: 456 })
94
+ ```
95
+
96
+ ### Filtering
97
+
98
+ Apply filters to list operations:
99
+
100
+ ```ruby
101
+ # Filter by status
102
+ CF::Orders::Invoice.list(filter: { status: "paid" })
103
+
104
+ # Filter by multiple values
105
+ CF::Orders::Invoice.list(filter: { id: [1, 2, 3] })
106
+
107
+ # Multiple filters
108
+ CF::Orders::Invoice.list(filter: { status: "paid", currency: "USD" })
109
+ ```
110
+
111
+ ### Sorting
112
+
113
+ Sort results using `sort_property` and `sort_order`:
114
+
115
+ ```ruby
116
+ CF::Orders::Invoice.list(
117
+ sort_property: "created_at",
118
+ sort_order: "desc"
119
+ )
120
+ ```
121
+
122
+ ## Debug Logging
123
+
124
+ Enable debug logging to see all HTTP requests and responses:
125
+
126
+ ```ruby
127
+ CF.configure do |config|
128
+ config.debug = true
129
+ # Logs will be written to cf_sdk.log by default
130
+ end
131
+
132
+ # Or provide your own logger
133
+ CF.configure do |config|
134
+ config.debug = true
135
+ config.logger = Logger.new(STDOUT)
136
+ end
137
+ ```
138
+
139
+ ## Error Handling
140
+
141
+ The SDK returns error objects instead of raising exceptions.
142
+
143
+ ## Testing
144
+
145
+ ### Run Test Suite
146
+ ```bash
147
+ bundle exec rake test
148
+ ```
149
+
150
+ ### Interactive Testing
151
+
152
+ **Default IRB Test Environment (Recommended):**
153
+
154
+ ```bash
155
+ bundle exec irb -r ./irb_test.rb
156
+ ```
157
+
158
+ This loads a pre-configured test environment with:
159
+ - ✅ All new features enabled
160
+ - ✅ Sample configuration with default workspace/team IDs
161
+ - ✅ Resource examples and usage tips
162
+ - ✅ Path generation demonstrations
163
+
164
+ **Manual IRB Setup:**
165
+ ```bash
166
+ bundle exec irb -r ./lib/cf
167
+ ```
168
+
169
+ Then configure manually:
170
+ ```ruby
171
+ CF.configure do |config|
172
+ config.subdomain = "your_subdomain"
173
+ config.api_token = "your_token"
174
+ config.workspace_id = "your_workspace_id" # Optional default
175
+ config.debug = true
176
+ end
177
+
178
+ # Test the new features
179
+ CF::Workspaces::Contact.list
180
+ CF::Orders::Invoice.list
181
+ CF::Users.list
182
+ ```
183
+
184
+ ## Contributing
185
+
186
+ Bug reports and pull requests are welcome on GitHub.
187
+
188
+ ## License
189
+
190
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/lib/cf/auth.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CF
4
+ module Auth
5
+ def self.bearer_token_headers(token)
6
+ {
7
+ "Authorization" => "Bearer #{token}",
8
+ "User-Agent" => "CF Ruby SDK #{CF::VERSION}"
9
+ }
10
+ end
11
+
12
+ def self.validate_token!(token)
13
+ raise CF::AuthenticationError, "API token is required" if token.nil? || token.empty?
14
+ end
15
+ end
16
+ end
data/lib/cf/client.rb ADDED
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "cgi"
5
+
6
+ module CF
7
+ class Client
8
+ def initialize(configuration)
9
+ @configuration = configuration
10
+ @http_client = CF::HTTP::Client.new
11
+ end
12
+
13
+ def get(path, params = {})
14
+ request(:get, path, nil, params)
15
+ end
16
+
17
+ def post(path, body = nil, params = {})
18
+ request(:post, path, body, params)
19
+ end
20
+
21
+ def put(path, body = nil, params = {})
22
+ request(:put, path, body, params)
23
+ end
24
+
25
+ def patch(path, body = nil, params = {})
26
+ request(:patch, path, body, params)
27
+ end
28
+
29
+ def delete(path, params = {})
30
+ request(:delete, path, nil, params)
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :configuration, :http_client
36
+
37
+ def request(method, path, body = nil, params = {})
38
+ validate_configuration!
39
+
40
+ url = build_url(path, params)
41
+ headers = build_headers
42
+
43
+ log_request(method, url, body, headers) if configuration.log_requests?
44
+
45
+ case method
46
+ when :get, :delete
47
+ response = http_client.send(method, url, headers)
48
+ else
49
+ response = http_client.send(method, url, body, headers)
50
+ end
51
+
52
+ log_response(response) if configuration.log_requests?
53
+
54
+ handle_response(response)
55
+ rescue => e
56
+ log_error(e) if configuration.log_requests?
57
+ raise
58
+ end
59
+
60
+ def validate_configuration!
61
+ CF::Auth.validate_token!(configuration.api_token)
62
+ raise CF::ConfigurationError, "Configuration is invalid" unless configuration.valid?
63
+ end
64
+
65
+ def build_url(path, params = {})
66
+ url = "#{configuration.base_url}#{path}"
67
+ return url if params.empty?
68
+
69
+ query_string = build_query_string(params)
70
+ "#{url}?#{query_string}"
71
+ end
72
+
73
+ def build_headers
74
+ headers = CF::Auth.bearer_token_headers(configuration.api_token)
75
+ headers["Accept"] = "application/json"
76
+ headers["Content-Type"] = "application/json"
77
+ headers
78
+ end
79
+
80
+ def build_query_string(params)
81
+ params.map do |key, value|
82
+ if value.is_a?(Array)
83
+ value.map { |v| "#{key}[]=#{CGI.escape(v.to_s)}" }.join("&")
84
+ elsif value.is_a?(Hash)
85
+ value.map { |k, v| "#{key}[#{k}]=#{CGI.escape(v.to_s)}" }.join("&")
86
+ else
87
+ "#{key}=#{CGI.escape(value.to_s)}"
88
+ end
89
+ end.join("&")
90
+ end
91
+
92
+ def handle_response(response)
93
+ case response[:status]
94
+ when 200..299
95
+ parse_response_body(response[:body])
96
+ when 400
97
+ create_error_response("Bad Request", CF::BadRequestError, response)
98
+ when 401
99
+ create_error_response("Unauthorized", CF::UnauthorizedError, response)
100
+ when 403
101
+ create_error_response("Forbidden", CF::ForbiddenError, response)
102
+ when 404
103
+ create_error_response("Not Found", CF::NotFoundError, response)
104
+ when 422
105
+ create_error_response("Unprocessable Entity", CF::UnprocessableEntityError, response)
106
+ when 429
107
+ create_error_response("Too Many Requests", CF::TooManyRequestsError, response)
108
+ when 500
109
+ create_error_response("Internal Server Error", CF::InternalServerError, response)
110
+ when 502
111
+ create_error_response("Bad Gateway", CF::BadGatewayError, response)
112
+ when 503
113
+ create_error_response("Service Unavailable", CF::ServiceUnavailableError, response)
114
+ when 504
115
+ create_error_response("Gateway Timeout", CF::GatewayTimeoutError, response)
116
+ else
117
+ create_error_response("HTTP #{response[:status]}", CF::APIError, response)
118
+ end
119
+ end
120
+
121
+ def parse_response_body(body)
122
+ return nil if body.nil? || body.empty?
123
+
124
+ JSON.parse(body, symbolize_names: true)
125
+ rescue JSON::ParserError
126
+ body
127
+ end
128
+
129
+ def create_error_response(message, error_class, response)
130
+ {
131
+ error: true,
132
+ error_type: error_class.name,
133
+ message: message,
134
+ status: response[:status],
135
+ response_body: response[:body],
136
+ # response_headers: response[:headers]
137
+ }
138
+ end
139
+
140
+ def log_request(method, url, body, headers)
141
+ return unless configuration.logger
142
+
143
+ configuration.logger.info("[CF SDK] Request: #{method.upcase} #{url}")
144
+ # configuration.logger.debug("[CF SDK] Headers: #{headers.inspect}")
145
+ configuration.logger.debug("[CF SDK] Body: #{body}") if body
146
+ end
147
+
148
+ def log_response(response)
149
+ return unless configuration.logger
150
+
151
+ configuration.logger.info("[CF SDK] Response: #{response[:status]}")
152
+ # configuration.logger.debug("[CF SDK] Response Headers: #{response[:headers]}")
153
+ configuration.logger.debug("[CF SDK] Response Body: #{response[:body]}")
154
+ end
155
+
156
+ def log_error(error)
157
+ return unless configuration.logger
158
+
159
+ configuration.logger.error("[CF SDK] Error: #{error.class.name} - #{error.message}")
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CF
4
+ class Configuration
5
+ attr_accessor :subdomain, :api_token, :api_version, :debug, :timeout, :workspace_id, :team_id
6
+
7
+ def initialize
8
+ @subdomain = nil
9
+ @api_token = nil
10
+ @api_version = "v2"
11
+ @debug = false
12
+ @timeout = 30
13
+ @logger = nil
14
+ @workspace_id = nil
15
+ @team_id = nil
16
+ end
17
+
18
+ def base_url
19
+ raise CF::ConfigurationError, "Subdomain is required" unless subdomain
20
+
21
+ "https://#{subdomain}.myclickfunnels.com/api/#{api_version}"
22
+ end
23
+
24
+ def valid?
25
+ !subdomain.nil? && !api_token.nil?
26
+ end
27
+
28
+ def debug?
29
+ debug
30
+ end
31
+
32
+ def log_requests?
33
+ debug?
34
+ end
35
+
36
+ def logger
37
+ @logger ||= debug? ? CF::FileLogger.new : nil
38
+ end
39
+
40
+ def logger=(custom_logger)
41
+ @logger = custom_logger
42
+ end
43
+ end
44
+ end
data/lib/cf/errors.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CF
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class AuthenticationError < Error; end
9
+
10
+ class APIError < Error
11
+ attr_reader :status, :response_body, :response_headers
12
+
13
+ def initialize(message, status: nil, response_body: nil, response_headers: nil)
14
+ super(message)
15
+ @status = status
16
+ @response_body = response_body
17
+ @response_headers = response_headers
18
+ end
19
+ end
20
+
21
+ class BadRequestError < APIError; end
22
+ class UnauthorizedError < APIError; end
23
+ class ForbiddenError < APIError; end
24
+ class NotFoundError < APIError; end
25
+ class UnprocessableEntityError < APIError; end
26
+ class TooManyRequestsError < APIError; end
27
+ class InternalServerError < APIError; end
28
+ class BadGatewayError < APIError; end
29
+ class ServiceUnavailableError < APIError; end
30
+ class GatewayTimeoutError < APIError; end
31
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CF
4
+ module HTTP
5
+ class Client
6
+ def initialize(adapter = nil)
7
+ @adapter = adapter || NetHTTPAdapter.new
8
+ end
9
+
10
+ def get(url, headers = {})
11
+ @adapter.request(:get, url, nil, headers)
12
+ end
13
+
14
+ def post(url, body = nil, headers = {})
15
+ @adapter.request(:post, url, body, headers)
16
+ end
17
+
18
+ def put(url, body = nil, headers = {})
19
+ @adapter.request(:put, url, body, headers)
20
+ end
21
+
22
+ def patch(url, body = nil, headers = {})
23
+ @adapter.request(:patch, url, body, headers)
24
+ end
25
+
26
+ def delete(url, headers = {})
27
+ @adapter.request(:delete, url, nil, headers)
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :adapter
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module CF
8
+ module HTTP
9
+ class NetHTTPAdapter
10
+ def initialize(options = {})
11
+ @options = {
12
+ timeout: 30,
13
+ open_timeout: 10,
14
+ use_ssl: true,
15
+ verify_mode: OpenSSL::SSL::VERIFY_PEER
16
+ }.merge(options)
17
+ end
18
+
19
+ def request(method, url, body = nil, headers = {})
20
+ uri = URI(url)
21
+
22
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
23
+ configure_http(http)
24
+
25
+ request = build_request(method, uri, body, headers)
26
+ response = http.request(request)
27
+
28
+ handle_response(response)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def configure_http(http)
35
+ http.read_timeout = @options[:timeout]
36
+ http.open_timeout = @options[:open_timeout]
37
+
38
+ if @options[:use_ssl]
39
+ http.use_ssl = true
40
+ http.verify_mode = @options[:verify_mode]
41
+ end
42
+ end
43
+
44
+ def build_request(method, uri, body, headers)
45
+ request_class = case method
46
+ when :get then Net::HTTP::Get
47
+ when :post then Net::HTTP::Post
48
+ when :put then Net::HTTP::Put # FIXME: Remove PUT.
49
+ when :patch then Net::HTTP::Patch
50
+ when :delete then Net::HTTP::Delete
51
+ else
52
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
53
+ end
54
+
55
+ request = request_class.new(uri)
56
+
57
+ # Set headers
58
+ headers.each { |key, value| request[key] = value }
59
+
60
+ # Set body for POST/PUT/PATCH requests
61
+ if body && [:post, :put, :patch].include?(method)
62
+ request.body = body.is_a?(String) ? body : JSON.generate(body)
63
+ request["Content-Type"] = "application/json" unless request["Content-Type"]
64
+ end
65
+
66
+ request
67
+ end
68
+
69
+ def handle_response(response)
70
+ {
71
+ status: response.code.to_i,
72
+ headers: response.to_hash,
73
+ body: response.body
74
+ }
75
+ end
76
+ end
77
+ end
78
+ end
data/lib/cf/logger.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module CF
6
+ class FileLogger
7
+ def initialize(log_file = "cf_sdk.log")
8
+ @logger = Logger.new(log_file)
9
+ @logger.level = Logger::DEBUG
10
+ @logger.formatter = proc do |severity, datetime, progname, msg|
11
+ "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n"
12
+ end
13
+ end
14
+
15
+ def info(message)
16
+ @logger.info(message)
17
+ end
18
+
19
+ def debug(message)
20
+ @logger.debug(message)
21
+ end
22
+
23
+ def error(message)
24
+ @logger.error(message)
25
+ end
26
+
27
+ def warn(message)
28
+ @logger.warn(message)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,530 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CF
4
+ module Resources
5
+ class Base
6
+ class << self
7
+ def client
8
+ CF.client
9
+ end
10
+
11
+ def list(params = {})
12
+ path = build_path("list", nil, params)
13
+ query_params = params.reject { |k, _v| parent_id_param?(k) }
14
+ client.get(path, query_params)
15
+ end
16
+
17
+ def get(id, params = {})
18
+ path = build_path("get", id, params)
19
+ query_params = params.reject { |k, _v| parent_id_param?(k) }
20
+ client.get(path, query_params)
21
+ end
22
+
23
+ def create(attributes = {}, params = {})
24
+ # Extract parent IDs from attributes based on path convention
25
+ extracted_params = extract_parent_ids_from_attributes(attributes)
26
+ attributes = extracted_params[:attributes]
27
+ params = params.merge(extracted_params[:params])
28
+
29
+ path = build_path("create", nil, params)
30
+ query_params = params.reject { |k, _v| parent_id_param?(k) }
31
+ wrapped_attributes = wrap_attributes_for_request(attributes)
32
+ client.post(path, wrapped_attributes, query_params)
33
+ end
34
+
35
+ def update(id, attributes = {}, params = {})
36
+ # Extract parent IDs from attributes based on path convention
37
+ extracted_params = extract_parent_ids_from_attributes(attributes)
38
+ attributes = extracted_params[:attributes]
39
+ params = params.merge(extracted_params[:params])
40
+
41
+ path = build_path("update", id, params)
42
+ query_params = params.reject { |k, _v| parent_id_param?(k) }
43
+ wrapped_attributes = wrap_attributes_for_request(attributes)
44
+ client.patch(path, wrapped_attributes, query_params)
45
+ end
46
+
47
+ def delete(id, params = {})
48
+ path = build_path("delete", id, params)
49
+ query_params = params.reject { |k, _v| parent_id_param?(k) }
50
+ client.delete(path, query_params)
51
+ end
52
+
53
+ private
54
+
55
+ def build_path(action, id = nil, params = {})
56
+ segments = resource_path_segments(params)
57
+
58
+ case action
59
+ when "list"
60
+ "/#{segments.join("/")}"
61
+ when "create"
62
+ build_create_path(segments, params)
63
+ when "get", "update", "delete"
64
+ "/#{segments.join("/")}/#{id}"
65
+ else
66
+ raise ArgumentError, "Unknown action: #{action}"
67
+ end
68
+ end
69
+
70
+ def resource_path_segments(params = {})
71
+ # Convert class name to path segments
72
+ # CF::Orders::Invoice -> ["orders", "invoices"]
73
+ # CF::Workspaces::Contact -> ["workspaces", "123", "contacts"] (with workspace_id: 123)
74
+ # CF::Contacts::AppliedTag -> ["contacts", "123", "applied_tags"] (with contact_id: 123)
75
+ # CF::Contacts::Tag -> ["workspaces", "123", "contacts", "tags"] (special case)
76
+
77
+ parts = name.split("::")
78
+ parts.shift # Remove "CF"
79
+
80
+ # Check for special workspace-nested patterns
81
+ special_pattern = special_workspace_pattern(parts)
82
+ if special_pattern
83
+ return build_special_workspace_path(special_pattern, params)
84
+ end
85
+
86
+ # Special case for Orders::Invoice - can be workspace OR order nested
87
+ if parts == ["Orders", "Invoice"] && !params.key?(:order_id)
88
+ return build_special_workspace_path({ path_suffix: "orders/invoices" }, params)
89
+ end
90
+
91
+ segments = []
92
+ parts.each_with_index do |part, index|
93
+ segment = part.gsub(/([A-Z])/, "_\\1").downcase.gsub(/^_/, "")
94
+
95
+ # For the final resource (last part), make it plural for the URL
96
+ if index == parts.length - 1
97
+ # Last segment - this is the resource, make it plural
98
+ segments << pluralize(segment)
99
+ else
100
+ # Namespace - use as-is (should already be plural in new convention)
101
+ segments << segment
102
+
103
+ # Check if this specific namespace needs a parent ID
104
+ parent_id_key = "#{segment.chomp("s")}_id".to_sym
105
+
106
+ # Always check if the parent ID is provided in params
107
+ if params.key?(parent_id_key)
108
+ # If parent ID is explicitly provided, use it (enables nested access)
109
+ segments << params[parent_id_key].to_s
110
+ elsif needs_parent_id_for_namespace?(segment, parts)
111
+ # If this namespace requires a parent ID by default, get it from config
112
+ parent_id = get_default_parent_id(parent_id_key)
113
+
114
+ if parent_id
115
+ segments << parent_id.to_s
116
+ else
117
+ raise CF::ConfigurationError, "#{parent_id_key} is required. Provide it as a parameter or set it in configuration."
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ segments
124
+ end
125
+
126
+ def needs_parent_id_for_namespace?(segment, parts)
127
+ # Determine which namespaces actually need parent IDs based on API structure
128
+ # This is more specific than just "all multi-part resources"
129
+
130
+ case segment.downcase
131
+ when "workspaces"
132
+ # CF::Workspaces::Contact -> needs workspace_id
133
+ true
134
+ when "teams"
135
+ # CF::Teams::Workspace -> needs team_id
136
+ true
137
+ when "contacts"
138
+ # CF::Contacts::AppliedTag -> needs contact_id
139
+ true
140
+ when "courses"
141
+ # CF::Courses::Section, CF::Courses::Enrollment -> needs course_id
142
+ true
143
+ when "orders"
144
+ # CF::Orders::Invoice might be top-level collection OR nested
145
+ # We'll check if order_id is provided in params to decide
146
+ false # Default to top-level for now
147
+ when "products"
148
+ # CF::Products::Price -> needs product_id if it's nested
149
+ false # Default to top-level for now
150
+ when "forms"
151
+ # CF::Forms::FieldSet -> might need form_id
152
+ false # Default to top-level for now
153
+ else
154
+ # For other cases, check if it's a known nested pattern
155
+ false
156
+ end
157
+ end
158
+
159
+ def get_default_parent_id(parent_id_key)
160
+ case parent_id_key
161
+ when :workspace_id
162
+ CF.configuration.workspace_id
163
+ when :team_id
164
+ CF.configuration.team_id
165
+ when :contact_id, :course_id, :order_id, :product_id, :form_id
166
+ # These don't have defaults - must be provided
167
+ nil
168
+ else
169
+ nil
170
+ end
171
+ end
172
+
173
+ def parent_id_param?(key)
174
+ # Any parameter ending with "_id" is considered a parent ID parameter
175
+ key.to_s.end_with?("_id")
176
+ end
177
+
178
+ def extract_parent_ids_from_attributes(attributes)
179
+ # Extract parent IDs that match the path convention from attributes
180
+ # For CF::Contacts::AppliedTag, look for contact_id in attributes
181
+ # For CF::Products::Price, look for product_id in attributes
182
+
183
+ expected_parent_ids = get_expected_parent_ids_for_class
184
+ params = {}
185
+ remaining_attributes = {}
186
+
187
+ attributes.each do |key, value|
188
+ if expected_parent_ids.include?(key.to_sym)
189
+ params[key] = value
190
+ else
191
+ remaining_attributes[key] = value
192
+ end
193
+ end
194
+
195
+ { attributes: remaining_attributes, params: params }
196
+ end
197
+
198
+ def get_expected_parent_ids_for_class
199
+ # Based on the class name, determine what parent IDs are expected
200
+ # CF::Contacts::AppliedTag -> [:contact_id]
201
+ # CF::Products::Price -> [:product_id]
202
+ # CF::Workspaces::Contact -> [:workspace_id]
203
+
204
+ parts = name.split("::")[1..-1] # Remove "CF"
205
+ return [] if parts.length <= 1
206
+
207
+ parent_ids = []
208
+
209
+ # Check each namespace part for expected parent IDs
210
+ parts[0..-2].each do |part|
211
+ segment = part.gsub(/([A-Z])/, "_\\1").downcase.gsub(/^_/, "")
212
+ parent_id_key = "#{segment.chomp('s')}_id".to_sym
213
+ parent_ids << parent_id_key
214
+ end
215
+
216
+ parent_ids
217
+ end
218
+
219
+ def build_create_path(segments, params)
220
+ # For create operations, check if we need parent ID in the path
221
+ # This handles cases like CF::Products::Price.create(product_id: 123)
222
+ # which should POST to /products/123/prices instead of /products/prices
223
+
224
+ return "/#{segments.join("/")}" if segments.length <= 1
225
+
226
+ # Check if we have a parent resource that needs ID in the path
227
+ parent_resource = segments[-2] # Second to last segment
228
+ resource = segments[-1] # Last segment
229
+
230
+ # Build parent ID parameter name from parent resource
231
+ parent_id_key = "#{parent_resource.chomp('s')}_id".to_sym
232
+
233
+ if params.key?(parent_id_key)
234
+ # Replace the parent collection with parent/id pattern
235
+ path_segments = segments.dup
236
+ path_segments[-2] = "#{parent_resource}/#{params[parent_id_key]}"
237
+ path_segments.pop # Remove the resource name
238
+ path_segments << resource # Add it back
239
+ "/#{path_segments.join("/")}"
240
+ else
241
+ # No parent ID provided, use collection path
242
+ "/#{segments.join("/")}"
243
+ end
244
+ end
245
+
246
+ def wrap_attributes_for_request(attributes)
247
+ # Wrap attributes with the proper resource key for API requests
248
+ # Example: CF::Contacts::Tag -> { contacts_tag: attributes }
249
+ resource_key = generate_resource_key
250
+ { resource_key => attributes }
251
+ end
252
+
253
+ def generate_resource_key
254
+ # Generate the resource key from class name
255
+ # CF::Contact -> :contact
256
+ # CF::Contacts::Tag -> :contacts_tag
257
+ # CF::Courses::LessonCompletion -> :courses_lesson_completion
258
+
259
+ parts = name.split("::")[1..-1] # Remove "CF"
260
+
261
+ # Convert each part to snake_case
262
+ # For namespaces (all but last), keep plural
263
+ # For resource (last), make singular
264
+ key_parts = parts.map.with_index do |part, index|
265
+ snake_case = part.gsub(/([A-Z])/, "_\\1").downcase.gsub(/^_/, "")
266
+
267
+ if index == parts.length - 1
268
+ # Last part is the resource - make it singular
269
+ singularize(snake_case)
270
+ else
271
+ # Namespace parts - keep as-is (should already be plural)
272
+ snake_case
273
+ end
274
+ end
275
+
276
+ key_parts.join("_").to_sym
277
+ end
278
+
279
+ def singularize(word)
280
+ # Simple singularization rules
281
+ return word unless word.end_with?('s')
282
+
283
+ case word
284
+ when /ies$/
285
+ word.sub(/ies$/, "y")
286
+ when /ses$/, /ches$/, /xes$/, /zes$/
287
+ word.sub(/es$/, "")
288
+ when /s$/
289
+ word.sub(/s$/, "")
290
+ else
291
+ word
292
+ end
293
+ end
294
+
295
+ def special_workspace_pattern(parts)
296
+ # Map special cases where resources are under /workspaces but have different namespace
297
+ # Returns the pattern info if it matches, nil otherwise
298
+ return nil if parts.length != 2
299
+
300
+ namespace = parts[0].downcase
301
+ resource = parts[1].downcase
302
+
303
+ # These patterns require workspace_id despite their namespace
304
+ workspace_nested_patterns = {
305
+ ["contacts", "tag"] => "contacts/tags",
306
+ ["orders", "tag"] => "orders/tags",
307
+ ["funnels", "tag"] => "funnels/tags",
308
+ ["products", "tag"] => "products/tags",
309
+ ["products", "collection"] => "products/collections"
310
+ }
311
+
312
+ # Special handling for Orders::Invoice - it can be both workspace and order nested
313
+ # We'll treat it as workspace-nested ONLY when no order_id is provided
314
+ if namespace == "orders" && resource == "invoice"
315
+ return nil # Let normal logic handle it
316
+ end
317
+
318
+ pattern_key = [namespace, resource]
319
+ if workspace_nested_patterns.key?(pattern_key)
320
+ { path_suffix: workspace_nested_patterns[pattern_key] }
321
+ else
322
+ nil
323
+ end
324
+ end
325
+
326
+ def build_special_workspace_path(pattern_info, params)
327
+ # Build path for special workspace-nested resources
328
+ segments = ["workspaces"]
329
+
330
+ # Get workspace_id from params or config
331
+ workspace_id = params[:workspace_id] || get_default_parent_id(:workspace_id)
332
+
333
+ if workspace_id
334
+ segments << workspace_id.to_s
335
+ else
336
+ raise CF::ConfigurationError, "workspace_id is required. Provide it as a parameter or set it in configuration."
337
+ end
338
+
339
+ # Add the specific path suffix (e.g., "contacts/tags")
340
+ segments << pattern_info[:path_suffix]
341
+
342
+ segments
343
+ end
344
+
345
+ def pluralize(word)
346
+ # Simple pluralization rules
347
+ # Don't pluralize words that are already plural
348
+ return word if word.end_with?('s') && !word.end_with?('ss')
349
+
350
+ case word
351
+ when /y$/
352
+ word.sub(/y$/, "ies")
353
+ when /sh$/, /ch$/, /x$/, /z$/
354
+ "#{word}es"
355
+ else
356
+ "#{word}s"
357
+ end
358
+ end
359
+ end
360
+ end
361
+
362
+ # Dynamic class generation for all endpoints
363
+ # TODO: This can be more "dynamic" by using the naming conventions.
364
+ def self.generate_resource_classes!
365
+ # Define all the resource classes based on ENDPOINTS.md
366
+ # Following convention: CF::NamespaceS(plural)::Resource(singular)
367
+
368
+ # Top-level resources
369
+ define_resource_class("CF::Teams") # Collection
370
+ define_resource_class("CF::Team") # Individual resource
371
+ define_resource_class("CF::Users") # Collection
372
+ define_resource_class("CF::User") # Individual resource
373
+
374
+ # Workspace nested resources
375
+ define_resource_class("CF::Teams::Workspace") # Nested under teams
376
+ define_resource_class("CF::Workspaces") # Collection
377
+ define_resource_class("CF::Workspace") # Individual resource
378
+ define_resource_class("CF::Workspaces::Contact") # Nested under workspaces
379
+ define_resource_class("CF::Contact") # Individual resource
380
+ # FIXME: Instead of this, these need to be actions.
381
+ define_resource_class("CF::Contacts::GdprDestroy")
382
+ define_resource_class("CF::Workspaces::Contacts::Upsert")
383
+
384
+ # Contact Tags
385
+ define_resource_class("CF::Contacts::AppliedTag")
386
+ define_resource_class("CF::Contacts::Tag") # This maps to /workspaces/{id}/contacts/tags
387
+
388
+ # Courses
389
+ define_resource_class("CF::Workspaces::Course")
390
+ define_resource_class("CF::Courses")
391
+ define_resource_class("CF::Course")
392
+ define_resource_class("CF::Courses::Enrollment")
393
+ define_resource_class("CF::Courses::Section")
394
+ define_resource_class("CF::Courses::Sections::Lesson")
395
+ define_resource_class("CF::Courses::Lesson")
396
+ define_resource_class("CF::Courses::LessonCompletion")
397
+
398
+ # Forms
399
+ define_resource_class("CF::Workspaces::Form")
400
+ define_resource_class("CF::Forms")
401
+ define_resource_class("CF::Form")
402
+ define_resource_class("CF::Forms::FieldSet")
403
+ define_resource_class("CF::Forms::FieldSets::Field")
404
+ define_resource_class("CF::Forms::Field")
405
+ define_resource_class("CF::Forms::FieldSets::Fields::Reorder")
406
+ define_resource_class("CF::Forms::Fields::Option")
407
+ define_resource_class("CF::Forms::Submission")
408
+ define_resource_class("CF::Workspaces::FormSubmission")
409
+ define_resource_class("CF::Forms::Submissions::Answer")
410
+
411
+ # Orders
412
+ define_resource_class("CF::Workspaces::Order")
413
+ define_resource_class("CF::Orders")
414
+ define_resource_class("CF::Order")
415
+ define_resource_class("CF::Orders::AppliedTag")
416
+ define_resource_class("CF::Workspaces::Orders::Tag")
417
+ define_resource_class("CF::Orders::Tag")
418
+ define_resource_class("CF::Orders::Invoice")
419
+ define_resource_class("CF::Workspaces::Orders::Invoice")
420
+ define_resource_class("CF::Orders::Transaction")
421
+ define_resource_class("CF::Workspaces::Orders::Invoices::Restock")
422
+ define_resource_class("CF::Orders::Invoices::Restock")
423
+
424
+ # Products
425
+ define_resource_class("CF::Workspaces::Product")
426
+ define_resource_class("CF::Products")
427
+ define_resource_class("CF::Product")
428
+ define_resource_class("CF::Products::Archive")
429
+ define_resource_class("CF::Products::Unarchive")
430
+ define_resource_class("CF::Workspaces::Products::Collection")
431
+ define_resource_class("CF::Products::Collection")
432
+ define_resource_class("CF::Products::Price")
433
+ define_resource_class("CF::Products::Variant")
434
+ define_resource_class("CF::Workspaces::Products::Tag")
435
+ define_resource_class("CF::Products::Tag")
436
+
437
+ # Fulfillments
438
+ define_resource_class("CF::Workspaces::Fulfillment")
439
+ define_resource_class("CF::Fulfillments")
440
+ define_resource_class("CF::Fulfillment")
441
+ define_resource_class("CF::Fulfillments::Cancel")
442
+ define_resource_class("CF::Workspaces::Fulfillments::Location")
443
+ define_resource_class("CF::Fulfillments::Location")
444
+
445
+ # Funnels
446
+ define_resource_class("CF::Workspaces::Funnel")
447
+ define_resource_class("CF::Funnels")
448
+ define_resource_class("CF::Funnel")
449
+ define_resource_class("CF::Workspaces::Funnels::Tag")
450
+ define_resource_class("CF::Funnels::Tag")
451
+
452
+ # Pages
453
+ define_resource_class("CF::Workspaces::Page")
454
+ define_resource_class("CF::Pages")
455
+ define_resource_class("CF::Page")
456
+
457
+ # Images
458
+ define_resource_class("CF::Workspaces::Image")
459
+ define_resource_class("CF::Images")
460
+ define_resource_class("CF::Image")
461
+
462
+ # Sales
463
+ define_resource_class("CF::Workspaces::Sales::Pipeline")
464
+ define_resource_class("CF::Sales::Pipelines")
465
+ define_resource_class("CF::Sales::Pipeline")
466
+ define_resource_class("CF::Sales::Pipelines::Stage")
467
+ define_resource_class("CF::Workspaces::Sales::Opportunity")
468
+ define_resource_class("CF::Sales::Opportunities")
469
+ define_resource_class("CF::Sales::Opportunity")
470
+ define_resource_class("CF::Sales::Opportunities::Note")
471
+
472
+ # Shipping
473
+ define_resource_class("CF::Workspaces::Shipping::Profile")
474
+ define_resource_class("CF::Shipping::Profiles")
475
+ define_resource_class("CF::Shipping::Profile")
476
+ define_resource_class("CF::Shipping::Profiles::LocationGroup")
477
+ define_resource_class("CF::Shipping::LocationGroups::Zone")
478
+ define_resource_class("CF::Shipping::Zones::Rate")
479
+ define_resource_class("CF::Workspaces::Shipping::Rates::Name")
480
+ define_resource_class("CF::Shipping::Rates::Name")
481
+ define_resource_class("CF::Workspaces::Shipping::Package")
482
+ define_resource_class("CF::Shipping::Packages")
483
+ define_resource_class("CF::Shipping::Package")
484
+
485
+ # Stores
486
+ define_resource_class("CF::Workspaces::Store")
487
+ define_resource_class("CF::Stores")
488
+ define_resource_class("CF::Store")
489
+
490
+ # Themes & Styles
491
+ define_resource_class("CF::Workspaces::Theme")
492
+ define_resource_class("CF::Themes")
493
+ define_resource_class("CF::Theme")
494
+ define_resource_class("CF::Workspaces::Style")
495
+ define_resource_class("CF::Styles")
496
+
497
+ # Webhooks
498
+ define_resource_class("CF::Workspaces::Webhooks::Outgoing::Endpoint")
499
+ define_resource_class("CF::Webhooks::Outgoing::Endpoints")
500
+ define_resource_class("CF::Webhooks::Outgoing::Endpoint")
501
+ define_resource_class("CF::Workspaces::Webhooks::Outgoing::Event")
502
+ define_resource_class("CF::Webhooks::Outgoing::Events")
503
+ define_resource_class("CF::Webhooks::Outgoing::Event")
504
+ end
505
+
506
+ private
507
+
508
+ def self.define_resource_class(class_name)
509
+ # Create nested modules if they don't exist
510
+ parts = class_name.split("::")
511
+ current_module = Object
512
+
513
+ parts[0..-2].each do |part|
514
+ unless current_module.const_defined?(part)
515
+ current_module.const_set(part, Module.new)
516
+ end
517
+ current_module = current_module.const_get(part)
518
+ end
519
+
520
+ # Create the final class
521
+ class_name_part = parts.last
522
+ unless current_module.const_defined?(class_name_part)
523
+ current_module.const_set(class_name_part, Class.new(Base))
524
+ end
525
+ end
526
+ end
527
+ end
528
+
529
+ # Generate all resource classes when this file is loaded
530
+ CF::Resources.generate_resource_classes!
data/lib/cf/version.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CF
4
+ VERSION = "0.1.0"
5
+ end
data/lib/cf.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cf/version"
4
+ require_relative "cf/configuration"
5
+ require_relative "cf/client"
6
+ require_relative "cf/errors"
7
+ require_relative "cf/auth"
8
+ require_relative "cf/logger"
9
+ require_relative "cf/http/client"
10
+ require_relative "cf/http/net_http_adapter"
11
+ require_relative "cf/resources/base"
12
+
13
+ module CF
14
+ class << self
15
+ def configure
16
+ yield(configuration)
17
+ end
18
+
19
+ def configuration
20
+ @configuration ||= Configuration.new
21
+ end
22
+
23
+ def client
24
+ @client ||= Client.new(configuration)
25
+ end
26
+
27
+ def reset!
28
+ @configuration = nil
29
+ @client = nil
30
+ end
31
+ end
32
+ end
metadata ADDED
@@ -0,0 +1,142 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: clickfunnels-ruby-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rich Steinmetz
8
+ - Claude Code
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-06-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
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: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '5.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '5.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: minitest-reporters
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: webmock
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.0'
97
+ description: A simple, dynamic Ruby SDK for the ClickFunnels API with support for
98
+ all endpoints and operations.
99
+ email:
100
+ - hey@richsteinmetz.com
101
+ executables: []
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - CHANGELOG.md
106
+ - LICENSE
107
+ - README.md
108
+ - lib/cf.rb
109
+ - lib/cf/auth.rb
110
+ - lib/cf/client.rb
111
+ - lib/cf/configuration.rb
112
+ - lib/cf/errors.rb
113
+ - lib/cf/http/client.rb
114
+ - lib/cf/http/net_http_adapter.rb
115
+ - lib/cf/logger.rb
116
+ - lib/cf/resources/base.rb
117
+ - lib/cf/version.rb
118
+ homepage: https://github.com/RichStone/clickfunnels-ruby-sdk
119
+ licenses:
120
+ - MIT
121
+ metadata:
122
+ homepage_uri: https://github.com/RichStone/clickfunnels-ruby-sdk
123
+ source_code_uri: https://github.com/RichStone/clickfunnels-ruby-sdk
124
+ changelog_uri: https://github.com/RichStone/clickfunnels-ruby-sdk/blob/main/CHANGELOG.md
125
+ rdoc_options: []
126
+ require_paths:
127
+ - lib
128
+ required_ruby_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: 3.1.6
133
+ required_rubygems_version: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ requirements: []
139
+ rubygems_version: 3.6.5
140
+ specification_version: 4
141
+ summary: Ruby SDK for ClickFunnels API
142
+ test_files: []