nghttp3 0.0.1

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.
@@ -0,0 +1,188 @@
1
+ #include "nghttp3.h"
2
+
3
+ VALUE rb_cNghttp3Settings;
4
+
5
+ typedef struct {
6
+ nghttp3_settings settings;
7
+ } SettingsObj;
8
+
9
+ static void settings_free(void *ptr) { xfree(ptr); }
10
+
11
+ static size_t settings_memsize(const void *ptr) { return sizeof(SettingsObj); }
12
+
13
+ const rb_data_type_t settings_data_type = {
14
+ .wrap_struct_name = "nghttp3_settings_rb",
15
+ .function =
16
+ {
17
+ .dmark = NULL,
18
+ .dfree = settings_free,
19
+ .dsize = settings_memsize,
20
+ },
21
+ .flags = RUBY_TYPED_FREE_IMMEDIATELY,
22
+ };
23
+
24
+ static VALUE settings_alloc(VALUE klass) {
25
+ SettingsObj *obj;
26
+ VALUE self =
27
+ TypedData_Make_Struct(klass, SettingsObj, &settings_data_type, obj);
28
+ memset(&obj->settings, 0, sizeof(nghttp3_settings));
29
+ return self;
30
+ }
31
+
32
+ /*
33
+ * Returns a pointer to the underlying nghttp3_settings structure.
34
+ */
35
+ nghttp3_settings *nghttp3_rb_get_settings(VALUE rb_settings) {
36
+ SettingsObj *obj;
37
+ TypedData_Get_Struct(rb_settings, SettingsObj, &settings_data_type, obj);
38
+ return &obj->settings;
39
+ }
40
+
41
+ /*
42
+ * call-seq:
43
+ * Settings.new -> Settings
44
+ *
45
+ * Creates a new Settings object with all values set to zero.
46
+ */
47
+ static VALUE rb_nghttp3_settings_initialize(VALUE self) { return self; }
48
+
49
+ /*
50
+ * call-seq:
51
+ * Settings.default -> Settings
52
+ *
53
+ * Creates a new Settings object with default values from nghttp3.
54
+ */
55
+ static VALUE rb_nghttp3_settings_default(VALUE klass) {
56
+ VALUE self = settings_alloc(klass);
57
+ SettingsObj *obj;
58
+ TypedData_Get_Struct(self, SettingsObj, &settings_data_type, obj);
59
+ nghttp3_settings_default(&obj->settings);
60
+ return self;
61
+ }
62
+
63
+ /* Settings getters */
64
+ static VALUE rb_nghttp3_settings_get_max_field_section_size(VALUE self) {
65
+ SettingsObj *obj;
66
+ TypedData_Get_Struct(self, SettingsObj, &settings_data_type, obj);
67
+ return ULL2NUM(obj->settings.max_field_section_size);
68
+ }
69
+
70
+ static VALUE rb_nghttp3_settings_get_qpack_max_dtable_capacity(VALUE self) {
71
+ SettingsObj *obj;
72
+ TypedData_Get_Struct(self, SettingsObj, &settings_data_type, obj);
73
+ return SIZET2NUM(obj->settings.qpack_max_dtable_capacity);
74
+ }
75
+
76
+ static VALUE
77
+ rb_nghttp3_settings_get_qpack_encoder_max_dtable_capacity(VALUE self) {
78
+ SettingsObj *obj;
79
+ TypedData_Get_Struct(self, SettingsObj, &settings_data_type, obj);
80
+ return SIZET2NUM(obj->settings.qpack_encoder_max_dtable_capacity);
81
+ }
82
+
83
+ static VALUE rb_nghttp3_settings_get_qpack_blocked_streams(VALUE self) {
84
+ SettingsObj *obj;
85
+ TypedData_Get_Struct(self, SettingsObj, &settings_data_type, obj);
86
+ return SIZET2NUM(obj->settings.qpack_blocked_streams);
87
+ }
88
+
89
+ static VALUE rb_nghttp3_settings_get_enable_connect_protocol(VALUE self) {
90
+ SettingsObj *obj;
91
+ TypedData_Get_Struct(self, SettingsObj, &settings_data_type, obj);
92
+ return obj->settings.enable_connect_protocol ? Qtrue : Qfalse;
93
+ }
94
+
95
+ static VALUE rb_nghttp3_settings_get_h3_datagram(VALUE self) {
96
+ SettingsObj *obj;
97
+ TypedData_Get_Struct(self, SettingsObj, &settings_data_type, obj);
98
+ return obj->settings.h3_datagram ? Qtrue : Qfalse;
99
+ }
100
+
101
+ /* Settings setters */
102
+ static VALUE rb_nghttp3_settings_set_max_field_section_size(VALUE self,
103
+ VALUE val) {
104
+ SettingsObj *obj;
105
+ TypedData_Get_Struct(self, SettingsObj, &settings_data_type, obj);
106
+ obj->settings.max_field_section_size = NUM2ULL(val);
107
+ return val;
108
+ }
109
+
110
+ static VALUE rb_nghttp3_settings_set_qpack_max_dtable_capacity(VALUE self,
111
+ VALUE val) {
112
+ SettingsObj *obj;
113
+ TypedData_Get_Struct(self, SettingsObj, &settings_data_type, obj);
114
+ obj->settings.qpack_max_dtable_capacity = NUM2SIZET(val);
115
+ return val;
116
+ }
117
+
118
+ static VALUE
119
+ rb_nghttp3_settings_set_qpack_encoder_max_dtable_capacity(VALUE self,
120
+ VALUE val) {
121
+ SettingsObj *obj;
122
+ TypedData_Get_Struct(self, SettingsObj, &settings_data_type, obj);
123
+ obj->settings.qpack_encoder_max_dtable_capacity = NUM2SIZET(val);
124
+ return val;
125
+ }
126
+
127
+ static VALUE rb_nghttp3_settings_set_qpack_blocked_streams(VALUE self,
128
+ VALUE val) {
129
+ SettingsObj *obj;
130
+ TypedData_Get_Struct(self, SettingsObj, &settings_data_type, obj);
131
+ obj->settings.qpack_blocked_streams = NUM2SIZET(val);
132
+ return val;
133
+ }
134
+
135
+ static VALUE rb_nghttp3_settings_set_enable_connect_protocol(VALUE self,
136
+ VALUE val) {
137
+ SettingsObj *obj;
138
+ TypedData_Get_Struct(self, SettingsObj, &settings_data_type, obj);
139
+ obj->settings.enable_connect_protocol = RTEST(val) ? 1 : 0;
140
+ return val;
141
+ }
142
+
143
+ static VALUE rb_nghttp3_settings_set_h3_datagram(VALUE self, VALUE val) {
144
+ SettingsObj *obj;
145
+ TypedData_Get_Struct(self, SettingsObj, &settings_data_type, obj);
146
+ obj->settings.h3_datagram = RTEST(val) ? 1 : 0;
147
+ return val;
148
+ }
149
+
150
+ void Init_nghttp3_settings(void) {
151
+ rb_cNghttp3Settings =
152
+ rb_define_class_under(rb_mNghttp3, "Settings", rb_cObject);
153
+ rb_define_alloc_func(rb_cNghttp3Settings, settings_alloc);
154
+ rb_define_method(rb_cNghttp3Settings, "initialize",
155
+ rb_nghttp3_settings_initialize, 0);
156
+ rb_define_singleton_method(rb_cNghttp3Settings, "default",
157
+ rb_nghttp3_settings_default, 0);
158
+
159
+ /* Settings getters */
160
+ rb_define_method(rb_cNghttp3Settings, "max_field_section_size",
161
+ rb_nghttp3_settings_get_max_field_section_size, 0);
162
+ rb_define_method(rb_cNghttp3Settings, "qpack_max_dtable_capacity",
163
+ rb_nghttp3_settings_get_qpack_max_dtable_capacity, 0);
164
+ rb_define_method(rb_cNghttp3Settings, "qpack_encoder_max_dtable_capacity",
165
+ rb_nghttp3_settings_get_qpack_encoder_max_dtable_capacity,
166
+ 0);
167
+ rb_define_method(rb_cNghttp3Settings, "qpack_blocked_streams",
168
+ rb_nghttp3_settings_get_qpack_blocked_streams, 0);
169
+ rb_define_method(rb_cNghttp3Settings, "enable_connect_protocol",
170
+ rb_nghttp3_settings_get_enable_connect_protocol, 0);
171
+ rb_define_method(rb_cNghttp3Settings, "h3_datagram",
172
+ rb_nghttp3_settings_get_h3_datagram, 0);
173
+
174
+ /* Settings setters */
175
+ rb_define_method(rb_cNghttp3Settings, "max_field_section_size=",
176
+ rb_nghttp3_settings_set_max_field_section_size, 1);
177
+ rb_define_method(rb_cNghttp3Settings, "qpack_max_dtable_capacity=",
178
+ rb_nghttp3_settings_set_qpack_max_dtable_capacity, 1);
179
+ rb_define_method(rb_cNghttp3Settings, "qpack_encoder_max_dtable_capacity=",
180
+ rb_nghttp3_settings_set_qpack_encoder_max_dtable_capacity,
181
+ 1);
182
+ rb_define_method(rb_cNghttp3Settings, "qpack_blocked_streams=",
183
+ rb_nghttp3_settings_set_qpack_blocked_streams, 1);
184
+ rb_define_method(rb_cNghttp3Settings, "enable_connect_protocol=",
185
+ rb_nghttp3_settings_set_enable_connect_protocol, 1);
186
+ rb_define_method(rb_cNghttp3Settings,
187
+ "h3_datagram=", rb_nghttp3_settings_set_h3_datagram, 1);
188
+ }
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nghttp3
4
+ # High-level HTTP/3 client
5
+ #
6
+ # Wraps the low-level Connection API with automatic stream management,
7
+ # convenient request methods, and response tracking.
8
+ #
9
+ # @example Basic usage
10
+ # client = Nghttp3::Client.new
11
+ # client.bind_streams(control: 2, qpack_encoder: 6, qpack_decoder: 10)
12
+ #
13
+ # stream_id = client.get("https://example.com/path")
14
+ #
15
+ # # Pump data to QUIC layer
16
+ # client.pump_writes do |stream_id, data, fin|
17
+ # quic.write(stream_id, data, fin)
18
+ # end
19
+ #
20
+ # # Feed data from QUIC layer
21
+ # client.read_stream(stream_id, received_data, fin: false)
22
+ #
23
+ # # Get response when ready
24
+ # response = client.responses[stream_id]
25
+ class Client
26
+ # @return [Connection] the underlying low-level connection
27
+ attr_reader :connection
28
+
29
+ # @return [Settings] the settings used for this client
30
+ attr_reader :settings
31
+
32
+ # @return [Hash{Integer => Response}] responses by stream ID
33
+ attr_reader :responses
34
+
35
+ # @return [Hash{Integer => Request}] pending requests by stream ID
36
+ attr_reader :pending_requests
37
+
38
+ # Create a new HTTP/3 client
39
+ # @param settings [Settings, nil] settings to use (defaults to Settings.default)
40
+ def initialize(settings: nil)
41
+ @settings = settings || Settings.default
42
+ @callbacks = setup_callbacks
43
+ @connection = Connection.client_new(@settings, @callbacks)
44
+ @stream_manager = StreamManager.new(is_server: false)
45
+ @pending_requests = {}
46
+ @responses = {}
47
+ @streams_bound = false
48
+ end
49
+
50
+ # Bind control and QPACK streams
51
+ #
52
+ # Must be called before submitting requests. The stream IDs should be
53
+ # unidirectional streams opened by the QUIC layer.
54
+ #
55
+ # @param control [Integer] control stream ID
56
+ # @param qpack_encoder [Integer] QPACK encoder stream ID
57
+ # @param qpack_decoder [Integer] QPACK decoder stream ID
58
+ # @return [self]
59
+ def bind_streams(control:, qpack_encoder:, qpack_decoder:)
60
+ @connection.bind_control_stream(control)
61
+ @connection.bind_qpack_streams(qpack_encoder, qpack_decoder)
62
+ @stream_manager.register_stream(control, type: :uni)
63
+ @stream_manager.register_stream(qpack_encoder, type: :uni)
64
+ @stream_manager.register_stream(qpack_decoder, type: :uni)
65
+ @streams_bound = true
66
+ self
67
+ end
68
+
69
+ # Check if streams are bound
70
+ # @return [Boolean]
71
+ def streams_bound?
72
+ @streams_bound
73
+ end
74
+
75
+ # Submit a request
76
+ # @param request [Request] the request to submit
77
+ # @return [Integer] the stream ID for this request
78
+ def submit(request)
79
+ raise InvalidStateError, "Streams not bound. Call bind_streams first." unless @streams_bound
80
+
81
+ stream_id = @stream_manager.allocate_bidi_stream_id
82
+ @pending_requests[stream_id] = request
83
+ @responses[stream_id] = Response.new(stream_id: stream_id)
84
+
85
+ @connection.submit_request(stream_id, request.to_nv_array, body: request.body)
86
+ stream_id
87
+ end
88
+
89
+ # Convenience method for GET request
90
+ # @param url [String] URL to request
91
+ # @param headers [Hash] additional headers
92
+ # @return [Integer] stream ID
93
+ def get(url, headers: {})
94
+ submit(Request.get(url, headers: headers))
95
+ end
96
+
97
+ # Convenience method for POST request
98
+ # @param url [String] URL to request
99
+ # @param body [String, nil] request body
100
+ # @param headers [Hash] additional headers
101
+ # @return [Integer] stream ID
102
+ def post(url, body: nil, headers: {})
103
+ submit(Request.post(url, body: body, headers: headers))
104
+ end
105
+
106
+ # Convenience method for PUT request
107
+ # @param url [String] URL to request
108
+ # @param body [String, nil] request body
109
+ # @param headers [Hash] additional headers
110
+ # @return [Integer] stream ID
111
+ def put(url, body: nil, headers: {})
112
+ submit(Request.put(url, body: body, headers: headers))
113
+ end
114
+
115
+ # Convenience method for DELETE request
116
+ # @param url [String] URL to request
117
+ # @param headers [Hash] additional headers
118
+ # @return [Integer] stream ID
119
+ def delete(url, headers: {})
120
+ submit(Request.delete(url, headers: headers))
121
+ end
122
+
123
+ # Convenience method for HEAD request
124
+ # @param url [String] URL to request
125
+ # @param headers [Hash] additional headers
126
+ # @return [Integer] stream ID
127
+ def head(url, headers: {})
128
+ submit(Request.head(url, headers: headers))
129
+ end
130
+
131
+ # Pump pending writes to the QUIC layer
132
+ #
133
+ # @yield [stream_id, data, fin] for each pending write
134
+ # @yieldparam stream_id [Integer] the stream ID
135
+ # @yieldparam data [String] the data to write
136
+ # @yieldparam fin [Boolean] true if this is the final data for the stream
137
+ # @yieldreturn [Integer] number of bytes accepted by QUIC layer
138
+ # @return [self]
139
+ def pump_writes
140
+ while (result = @connection.writev_stream)
141
+ stream_id = result[:stream_id]
142
+ data = result[:data]
143
+ fin = result[:fin]
144
+
145
+ bytes_written = yield(stream_id, data, fin) if block_given?
146
+ bytes_written ||= data.bytesize
147
+
148
+ @connection.add_write_offset(stream_id, bytes_written)
149
+ end
150
+ self
151
+ end
152
+
153
+ # Read data from QUIC layer into HTTP/3 connection
154
+ # @param stream_id [Integer] stream ID
155
+ # @param data [String] received data
156
+ # @param fin [Boolean] true if this is the final data for the stream
157
+ # @return [Integer] number of bytes consumed
158
+ def read_stream(stream_id, data, fin: false)
159
+ @connection.read_stream(stream_id, data, fin: fin)
160
+ end
161
+
162
+ # Notify that bytes have been acknowledged by the peer
163
+ # @param stream_id [Integer] stream ID
164
+ # @param n [Integer] number of bytes acknowledged
165
+ # @return [self]
166
+ def add_ack_offset(stream_id, n)
167
+ @connection.add_ack_offset(stream_id, n)
168
+ self
169
+ end
170
+
171
+ # Close the client connection
172
+ # @return [nil]
173
+ def close
174
+ @connection.close
175
+ end
176
+
177
+ # Check if the connection is closed
178
+ # @return [Boolean]
179
+ def closed?
180
+ @connection.closed?
181
+ end
182
+
183
+ private
184
+
185
+ def setup_callbacks
186
+ Callbacks.new
187
+ .on_begin_headers { |stream_id| on_begin_headers(stream_id) }
188
+ .on_recv_header { |stream_id, name, value, flags| on_recv_header(stream_id, name, value, flags) }
189
+ .on_end_headers { |stream_id, fin| on_end_headers(stream_id, fin) }
190
+ .on_recv_data { |stream_id, data| on_recv_data(stream_id, data) }
191
+ .on_end_stream { |stream_id| on_end_stream(stream_id) }
192
+ .on_stream_close { |stream_id, app_error_code| on_stream_close(stream_id, app_error_code) }
193
+ end
194
+
195
+ def on_begin_headers(stream_id)
196
+ # Response headers starting
197
+ @responses[stream_id] ||= Response.new(stream_id: stream_id)
198
+ end
199
+
200
+ def on_recv_header(stream_id, name, value, _flags)
201
+ response = @responses[stream_id]
202
+ return unless response
203
+
204
+ if name == ":status"
205
+ response.status = value.to_i
206
+ else
207
+ response.headers[name] = value
208
+ end
209
+ end
210
+
211
+ def on_end_headers(stream_id, _fin)
212
+ # Headers complete
213
+ response = @responses[stream_id]
214
+ response&.write_headers
215
+ end
216
+
217
+ def on_recv_data(stream_id, data)
218
+ response = @responses[stream_id]
219
+ response&.append_body(data)
220
+ end
221
+
222
+ def on_end_stream(stream_id)
223
+ response = @responses[stream_id]
224
+ response&.finish
225
+ @pending_requests.delete(stream_id)
226
+ @stream_manager.close_stream(stream_id)
227
+ end
228
+
229
+ def on_stream_close(stream_id, _app_error_code)
230
+ response = @responses[stream_id]
231
+ response&.finish
232
+ @pending_requests.delete(stream_id)
233
+ @stream_manager.close_stream(stream_id)
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nghttp3
4
+ # A case-insensitive header collection for HTTP/3
5
+ #
6
+ # Header names are automatically normalized to lowercase.
7
+ # Supports multiple values for the same header name.
8
+ class Headers
9
+ include Enumerable
10
+
11
+ def initialize(hash = {})
12
+ @headers = {}
13
+ hash&.each { |k, v| self[k] = v }
14
+ end
15
+
16
+ # Get header value by name (case-insensitive)
17
+ # @param name [String, Symbol] header name
18
+ # @return [String, nil] header value or nil if not found
19
+ def [](name)
20
+ @headers[normalize_name(name)]
21
+ end
22
+
23
+ # Set header value (case-insensitive)
24
+ # @param name [String, Symbol] header name
25
+ # @param value [String, #to_s] header value
26
+ # @return [String] the value
27
+ def []=(name, value)
28
+ @headers[normalize_name(name)] = value.to_s
29
+ end
30
+
31
+ # Delete a header
32
+ # @param name [String, Symbol] header name
33
+ # @return [String, nil] deleted value or nil
34
+ def delete(name)
35
+ @headers.delete(normalize_name(name))
36
+ end
37
+
38
+ # Check if header exists
39
+ # @param name [String, Symbol] header name
40
+ # @return [Boolean]
41
+ def key?(name)
42
+ @headers.key?(normalize_name(name))
43
+ end
44
+ alias_method :has_key?, :key?
45
+ alias_method :include?, :key?
46
+
47
+ # Iterate over all headers
48
+ # @yield [name, value] each header name-value pair
49
+ def each(&block)
50
+ return enum_for(:each) unless block_given?
51
+ @headers.each(&block)
52
+ end
53
+
54
+ # Merge another hash into this headers collection
55
+ # @param other [Hash, Headers] headers to merge
56
+ # @return [self]
57
+ def merge!(other)
58
+ other.each { |k, v| self[k] = v }
59
+ self
60
+ end
61
+
62
+ # Return a new Headers with merged values
63
+ # @param other [Hash, Headers] headers to merge
64
+ # @return [Headers] new Headers instance
65
+ def merge(other)
66
+ dup.merge!(other)
67
+ end
68
+
69
+ # Convert to Hash
70
+ # @return [Hash{String => String}]
71
+ def to_h
72
+ @headers.dup
73
+ end
74
+ alias_method :to_hash, :to_h
75
+
76
+ # Number of headers
77
+ # @return [Integer]
78
+ def size
79
+ @headers.size
80
+ end
81
+ alias_method :length, :size
82
+
83
+ # Check if empty
84
+ # @return [Boolean]
85
+ def empty?
86
+ @headers.empty?
87
+ end
88
+
89
+ # Remove all headers
90
+ # @return [self]
91
+ def clear
92
+ @headers.clear
93
+ self
94
+ end
95
+
96
+ # Duplicate the headers collection
97
+ # @return [Headers]
98
+ def dup
99
+ self.class.new(@headers)
100
+ end
101
+
102
+ # String representation
103
+ def inspect
104
+ "#<#{self.class} #{@headers.inspect}>"
105
+ end
106
+
107
+ private
108
+
109
+ def normalize_name(name)
110
+ name.to_s.downcase
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Nghttp3
6
+ # An immutable HTTP/3 request object
7
+ #
8
+ # Represents an HTTP request with method, path, headers, and optional body.
9
+ # Can be created directly or via convenience factory methods.
10
+ class Request
11
+ # @return [String] HTTP method (GET, POST, etc.)
12
+ attr_reader :method
13
+
14
+ # @return [String] URL scheme (https, http)
15
+ attr_reader :scheme
16
+
17
+ # @return [String, nil] authority (host:port)
18
+ attr_reader :authority
19
+
20
+ # @return [String] request path
21
+ attr_reader :path
22
+
23
+ # @return [Headers] request headers
24
+ attr_reader :headers
25
+
26
+ # @return [String, nil] request body
27
+ attr_reader :body
28
+
29
+ # Create a new request
30
+ # @param method [String, Symbol] HTTP method
31
+ # @param path [String] request path
32
+ # @param scheme [String] URL scheme (default: "https")
33
+ # @param authority [String, nil] authority (host:port)
34
+ # @param headers [Hash, Headers] additional headers
35
+ # @param body [String, nil] request body
36
+ def initialize(method:, path:, scheme: "https", authority: nil, headers: {}, body: nil)
37
+ @method = method.to_s.upcase.freeze
38
+ @scheme = scheme.to_s.freeze
39
+ @authority = authority&.to_s&.freeze
40
+ @path = path.to_s.freeze
41
+ @headers = headers.is_a?(Headers) ? headers : Headers.new(headers)
42
+ @body = body&.dup&.freeze
43
+ freeze
44
+ end
45
+
46
+ # Convert to NV array for low-level Connection API
47
+ # @return [Array<NV>] array of NV objects for pseudo-headers and headers
48
+ def to_nv_array
49
+ nvs = []
50
+ nvs << NV.new(":method", @method)
51
+ nvs << NV.new(":scheme", @scheme)
52
+ nvs << NV.new(":authority", @authority) if @authority
53
+ nvs << NV.new(":path", @path)
54
+ @headers.each { |name, value| nvs << NV.new(name, value) }
55
+ nvs
56
+ end
57
+
58
+ # Check if request has a body
59
+ # @return [Boolean]
60
+ def body?
61
+ !@body.nil? && !@body.empty?
62
+ end
63
+
64
+ # String representation
65
+ def inspect
66
+ "#<#{self.class} #{@method} #{@scheme}://#{@authority}#{@path}>"
67
+ end
68
+
69
+ class << self
70
+ # Create a GET request
71
+ # @param url [String] full URL
72
+ # @param headers [Hash] additional headers
73
+ # @return [Request]
74
+ def get(url, headers: {})
75
+ build_from_url("GET", url, headers: headers)
76
+ end
77
+
78
+ # Create a POST request
79
+ # @param url [String] full URL
80
+ # @param body [String, nil] request body
81
+ # @param headers [Hash] additional headers
82
+ # @return [Request]
83
+ def post(url, body: nil, headers: {})
84
+ build_from_url("POST", url, body: body, headers: headers)
85
+ end
86
+
87
+ # Create a PUT request
88
+ # @param url [String] full URL
89
+ # @param body [String, nil] request body
90
+ # @param headers [Hash] additional headers
91
+ # @return [Request]
92
+ def put(url, body: nil, headers: {})
93
+ build_from_url("PUT", url, body: body, headers: headers)
94
+ end
95
+
96
+ # Create a DELETE request
97
+ # @param url [String] full URL
98
+ # @param headers [Hash] additional headers
99
+ # @return [Request]
100
+ def delete(url, headers: {})
101
+ build_from_url("DELETE", url, headers: headers)
102
+ end
103
+
104
+ # Create a HEAD request
105
+ # @param url [String] full URL
106
+ # @param headers [Hash] additional headers
107
+ # @return [Request]
108
+ def head(url, headers: {})
109
+ build_from_url("HEAD", url, headers: headers)
110
+ end
111
+
112
+ # Create a PATCH request
113
+ # @param url [String] full URL
114
+ # @param body [String, nil] request body
115
+ # @param headers [Hash] additional headers
116
+ # @return [Request]
117
+ def patch(url, body: nil, headers: {})
118
+ build_from_url("PATCH", url, body: body, headers: headers)
119
+ end
120
+
121
+ # Create an OPTIONS request
122
+ # @param url [String] full URL
123
+ # @param headers [Hash] additional headers
124
+ # @return [Request]
125
+ def options(url, headers: {})
126
+ build_from_url("OPTIONS", url, headers: headers)
127
+ end
128
+
129
+ private
130
+
131
+ def build_from_url(method, url, body: nil, headers: {})
132
+ uri = URI.parse(url)
133
+ authority = (uri.port == uri.default_port) ? uri.host : "#{uri.host}:#{uri.port}"
134
+ path = uri.request_uri.empty? ? "/" : uri.request_uri
135
+
136
+ new(
137
+ method: method,
138
+ scheme: uri.scheme || "https",
139
+ authority: authority,
140
+ path: path,
141
+ headers: headers,
142
+ body: body
143
+ )
144
+ end
145
+ end
146
+ end
147
+ end