client-api-builder 0.5.6 → 0.6.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 +4 -4
- data/.github/workflows/ci.yml +53 -0
- data/.rubocop.yml +79 -0
- data/ARCHITECTURE.md +161 -86
- data/CLAUDE.md +92 -0
- data/Gemfile +3 -2
- data/Gemfile.lock +46 -39
- data/README.md +427 -92
- data/client-api-builder.gemspec +20 -4
- data/examples/basic_auth_example_client.rb +6 -5
- data/examples/imdb_datasets_client.rb +2 -0
- data/examples/lorem_ipsum_client.rb +3 -1
- data/lib/client-api-builder.rb +1 -0
- data/lib/client_api_builder/active_support_log_subscriber.rb +1 -0
- data/lib/client_api_builder/active_support_notifications.rb +8 -6
- data/lib/client_api_builder/nested_router.rb +3 -3
- data/lib/client_api_builder/net_http_request.rb +37 -5
- data/lib/client_api_builder/query_params.rb +9 -3
- data/lib/client_api_builder/router.rb +210 -125
- data/lib/client_api_builder/section.rb +11 -11
- data/script/console +1 -1
- metadata +20 -10
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
class LoremIpsumClient
|
|
2
4
|
include ClientApiBuilder::Router
|
|
3
5
|
|
|
@@ -12,5 +14,5 @@ class LoremIpsumClient
|
|
|
12
14
|
header 'Accept', 'application/json'
|
|
13
15
|
|
|
14
16
|
# this creates a method called create_lorem_ipsum with 2 named arguments amont and what
|
|
15
|
-
route :create_lorem_ipsum, '/feed/json', body: {amount: :amount, what: :what, start: 'yes', generate: 'Generate Lorem Ipsum'}
|
|
17
|
+
route :create_lorem_ipsum, '/feed/json', body: { amount: :amount, what: :what, start: 'yes', generate: 'Generate Lorem Ipsum' }
|
|
16
18
|
end
|
data/lib/client-api-builder.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require 'active_support'
|
|
3
4
|
|
|
4
5
|
# Purpose is to change the instrument_request to use ActiveSupport::Notifications.instrument
|
|
@@ -9,14 +10,15 @@ module ClientApiBuilder
|
|
|
9
10
|
error = nil
|
|
10
11
|
result = nil
|
|
11
12
|
ActiveSupport::Notifications.instrument('client_api_builder.request', client: self) do
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
end
|
|
13
|
+
result = yield
|
|
14
|
+
rescue StandardError => e
|
|
15
|
+
# Use StandardError instead of Exception to allow SystemExit, Interrupt, etc. to propagate
|
|
16
|
+
error = e
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
raise
|
|
19
|
+
# Re-raise with original backtrace preserved
|
|
20
|
+
raise(error, error.message, error.backtrace) if error
|
|
21
|
+
|
|
20
22
|
result
|
|
21
23
|
ensure
|
|
22
24
|
@total_request_time = Time.now - start_time
|
|
@@ -17,15 +17,15 @@ module ClientApiBuilder
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def self.get_instance_method(var)
|
|
20
|
-
"\#{root_router.#{var}
|
|
20
|
+
"\#{root_router.#{var}}"
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def base_url
|
|
24
24
|
self.class.base_url || root_router.base_url
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def handle_response(response, options, &
|
|
28
|
-
root_router.handle_response(response, options, &
|
|
27
|
+
def handle_response(response, options, &)
|
|
28
|
+
root_router.handle_response(response, options, &)
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
end
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require 'net/http'
|
|
4
|
+
require 'openssl'
|
|
3
5
|
|
|
4
6
|
module ClientApiBuilder
|
|
5
7
|
module NetHTTP
|
|
6
8
|
module Request
|
|
9
|
+
# Allowed file modes for stream_to_file to prevent arbitrary mode injection
|
|
10
|
+
ALLOWED_FILE_MODES = %w[w wb a ab w+ wb+ a+ ab+].freeze
|
|
11
|
+
|
|
12
|
+
# Default connection options with secure SSL settings
|
|
13
|
+
DEFAULT_SECURE_OPTIONS = {
|
|
14
|
+
verify_mode: OpenSSL::SSL::VERIFY_PEER,
|
|
15
|
+
open_timeout: 30,
|
|
16
|
+
read_timeout: 60
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
7
19
|
# Copied from https://ruby-doc.org/stdlib-2.7.1/libdoc/net/http/rdoc/Net/HTTP.html
|
|
8
20
|
METHOD_TO_NET_HTTP_CLASS = {
|
|
9
21
|
copy: Net::HTTP::Copy,
|
|
@@ -21,13 +33,17 @@ module ClientApiBuilder
|
|
|
21
33
|
put: Net::HTTP::Put,
|
|
22
34
|
trace: Net::HTTP::Trace,
|
|
23
35
|
unlock: Net::HTTP::Unlock
|
|
24
|
-
}
|
|
36
|
+
}.freeze
|
|
25
37
|
|
|
26
38
|
def request(method:, uri:, body:, headers:, connection_options:)
|
|
27
39
|
request = METHOD_TO_NET_HTTP_CLASS[method].new(uri.request_uri, headers)
|
|
28
40
|
request.body = body if body
|
|
29
41
|
|
|
30
|
-
|
|
42
|
+
# Merge secure defaults, then user options, ensuring SSL verification is enabled for HTTPS
|
|
43
|
+
ssl_options = uri.scheme == 'https' ? DEFAULT_SECURE_OPTIONS.merge(use_ssl: true) : {}
|
|
44
|
+
merged_options = ssl_options.merge(connection_options)
|
|
45
|
+
|
|
46
|
+
Net::HTTP.start(uri.hostname, uri.port, merged_options) do |http|
|
|
31
47
|
http.request(request) do |response|
|
|
32
48
|
yield response if block_given?
|
|
33
49
|
end
|
|
@@ -49,9 +65,25 @@ module ClientApiBuilder
|
|
|
49
65
|
end
|
|
50
66
|
|
|
51
67
|
def stream_to_file(method:, uri:, body:, headers:, connection_options:, file:)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
# Use dup to avoid mutating the original hash
|
|
69
|
+
opts = connection_options.dup
|
|
70
|
+
mode = opts.delete(:file_mode)
|
|
71
|
+
|
|
72
|
+
# Validate file mode - use whitelist approach
|
|
73
|
+
mode = if mode.nil?
|
|
74
|
+
'wb'
|
|
75
|
+
elsif ALLOWED_FILE_MODES.include?(mode.to_s)
|
|
76
|
+
mode.to_s
|
|
77
|
+
else
|
|
78
|
+
raise ArgumentError, "Invalid file mode: #{mode.inspect}. Allowed modes: #{ALLOWED_FILE_MODES.join(', ')}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Validate file path - expand to absolute path and check for path traversal
|
|
82
|
+
expanded_path = File.expand_path(file)
|
|
83
|
+
raise ArgumentError, 'Invalid file path: potential path traversal detected' if file.to_s.include?('..') || expanded_path.include?("\0")
|
|
84
|
+
|
|
85
|
+
File.open(expanded_path, mode) do |io|
|
|
86
|
+
stream_to_io(method: method, uri: uri, body: body, headers: headers, connection_options: opts, io: io)
|
|
55
87
|
end
|
|
56
88
|
end
|
|
57
89
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require 'cgi'
|
|
3
4
|
|
|
4
5
|
module ClientApiBuilder
|
|
@@ -38,10 +39,10 @@ module ClientApiBuilder
|
|
|
38
39
|
array_namespace = namespace ? "#{namespace}[#{escape(key.to_s)}][]" : "#{escape(key.to_s)}[]"
|
|
39
40
|
query_params += to_query_from_array(value, array_namespace)
|
|
40
41
|
when Hash
|
|
41
|
-
hash_namespace = namespace ? "#{namespace}[#{escape(key.to_s)}]" :
|
|
42
|
+
hash_namespace = namespace ? "#{namespace}[#{escape(key.to_s)}]" : escape(key.to_s).to_s
|
|
42
43
|
query_params += to_query_from_hash(value, hash_namespace)
|
|
43
44
|
else
|
|
44
|
-
query_name = namespace ? "#{namespace}[#{escape(key.to_s)}]" :
|
|
45
|
+
query_name = namespace ? "#{namespace}[#{escape(key.to_s)}]" : escape(key.to_s).to_s
|
|
45
46
|
query_params << "#{query_name}#{name_value_separator}#{escape(value.to_s)}"
|
|
46
47
|
end
|
|
47
48
|
end
|
|
@@ -67,7 +68,12 @@ module ClientApiBuilder
|
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def escape(str)
|
|
70
|
-
|
|
71
|
+
return CGI.escape(str) unless custom_escape_proc
|
|
72
|
+
|
|
73
|
+
result = custom_escape_proc.call(str)
|
|
74
|
+
raise TypeError, "custom_escape_proc must return a String, got #{result.class}" unless result.is_a?(String)
|
|
75
|
+
|
|
76
|
+
result
|
|
71
77
|
end
|
|
72
78
|
end
|
|
73
79
|
end
|