wreq 1.2.5 → 1.2.7

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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.cargo/config.toml +4 -0
  3. data/Cargo.lock +135 -119
  4. data/Cargo.toml +11 -3
  5. data/Rakefile +6 -0
  6. data/crates/wreq-util/README.md +1 -1
  7. data/crates/wreq-util/examples/emulate.rs +3 -3
  8. data/crates/wreq-util/src/emulate/macros.rs +13 -11
  9. data/crates/wreq-util/src/emulate/profile/chrome/header.rs +18 -3
  10. data/crates/wreq-util/src/emulate/profile/firefox/header.rs +16 -0
  11. data/crates/wreq-util/src/emulate/profile/opera/header.rs +6 -7
  12. data/crates/wreq-util/tests/client.rs +103 -1
  13. data/docs/windows-gnu-tokio-crash.md +49 -3
  14. data/extconf.rb +4 -0
  15. data/lib/wreq.rb +70 -45
  16. data/lib/wreq_ruby/body.rb +29 -11
  17. data/lib/wreq_ruby/client.rb +88 -55
  18. data/lib/wreq_ruby/cookie.rb +79 -19
  19. data/lib/wreq_ruby/emulate.rb +74 -6
  20. data/lib/wreq_ruby/error.rb +5 -7
  21. data/lib/wreq_ruby/http.rb +74 -0
  22. data/lib/wreq_ruby/response.rb +3 -0
  23. data/script/rust_env.rb +85 -0
  24. data/src/arch.rs +22 -0
  25. data/src/client/body/form.rs +2 -0
  26. data/src/client/body/json.rs +47 -14
  27. data/src/client/body/stream.rs +147 -43
  28. data/src/client/body.rs +11 -15
  29. data/src/client/param.rs +7 -7
  30. data/src/client/req.rs +87 -47
  31. data/src/client/resp.rs +23 -24
  32. data/src/client.rs +310 -230
  33. data/src/cookie.rs +280 -87
  34. data/src/emulate.rs +85 -50
  35. data/src/error.rs +198 -41
  36. data/src/extractor.rs +16 -76
  37. data/src/header.rs +146 -23
  38. data/src/http.rs +62 -33
  39. data/src/lib.rs +26 -20
  40. data/src/macros.rs +71 -46
  41. data/src/options.rs +284 -0
  42. data/src/rt.rs +22 -11
  43. data/src/serde/de/array_deserializer.rs +48 -0
  44. data/src/serde/de/array_enumerator.rs +59 -0
  45. data/src/serde/de/deserializer.rs +307 -0
  46. data/src/serde/de/enum_deserializer.rs +47 -0
  47. data/src/serde/de/hash_deserializer.rs +89 -0
  48. data/src/serde/de/number_deserializer.rs +51 -0
  49. data/src/serde/de/variant_deserializer.rs +101 -0
  50. data/src/serde/de.rs +33 -0
  51. data/src/serde/error.rs +146 -0
  52. data/src/serde/ser/enums.rs +14 -0
  53. data/src/serde/ser/map_serializer.rs +56 -0
  54. data/src/serde/ser/seq_serializer.rs +71 -0
  55. data/src/serde/ser/serializer.rs +194 -0
  56. data/src/serde/ser/struct_serializer.rs +106 -0
  57. data/src/serde/ser/struct_variant_serializer.rs +44 -0
  58. data/src/serde/ser/tuple_variant_serializer.rs +41 -0
  59. data/src/serde/ser.rs +18 -0
  60. data/src/serde/tests.rs +237 -0
  61. data/src/serde.rs +202 -0
  62. data/test/body_sender_test.rb +246 -0
  63. data/test/cookie_test.rb +211 -10
  64. data/test/json_precision_test.rb +209 -0
  65. data/test/option_validation_test.rb +311 -0
  66. data/test/value_semantics_test.rb +238 -0
  67. data/wreq.gemspec +1 -1
  68. metadata +28 -4
  69. data/script/build_windows_gnu.ps1 +0 -264
  70. data/src/header/helper.rs +0 -112
data/src/cookie.rs CHANGED
@@ -1,24 +1,41 @@
1
- use std::{fmt, sync::Arc, time::SystemTime};
1
+ //! Ruby bindings for HTTP cookies and the shared cookie jar.
2
+ //!
3
+ //! Expiration follows [RFC 6265]. The binding accepts Ruby `Time` values and
4
+ //! finite Unix timestamps without converting them through unsigned
5
+ //! `SystemTime`. A non-positive Max-Age expires the cookie immediately.
6
+ //!
7
+ //! [RFC 6265]: https://www.rfc-editor.org/rfc/rfc6265.html
2
8
 
9
+ use std::{fmt, sync::Arc};
10
+
11
+ use ::serde::Deserialize;
3
12
  use bytes::Bytes;
4
- use cookie::{Cookie as RawCookie, Expiration, ParseError, time::Duration};
13
+ use cookie::{Cookie as RawCookie, ParseError, time::Duration};
5
14
  use magnus::{
6
- Error, Module, Object, RHash, RModule, RString, Ruby, TryConvert, Value, function, method,
7
- r_hash::ForEach, typed_data::Obj, value::ReprValue,
15
+ Error, Module, Object, RHash, RModule, RString, Ruby, Time, TryConvert, Value, function,
16
+ method, r_hash::ForEach, typed_data::Obj, value::ReprValue,
8
17
  };
9
18
  use wreq::header::{self, HeaderMap, HeaderValue};
10
19
 
11
- use crate::{error::header_value_error_to_magnus, gvl};
20
+ use crate::{
21
+ error::{header_value_error, type_error},
22
+ gvl,
23
+ options::{NativeOption, Options},
24
+ };
25
+
26
+ use self::helper::{CookieExpiration, to_ruby_time, to_unix_timestamp};
12
27
 
28
+ // Defines constant registration, `into_ffi`/`from_ffi`, and handlers for
29
+ // Ruby's `to_s`, `to_sym`, `==`, `eql?`, and `hash` methods.
13
30
  define_ruby_enum!(
14
31
  /// The Cookie SameSite attribute.
15
- const,
16
32
  SameSite,
17
33
  "Wreq::SameSite",
18
34
  cookie::SameSite,
19
- Strict,
20
- Lax,
21
- None,
35
+ symbols:
36
+ Strict => "strict",
37
+ Lax => "lax",
38
+ None => "none",
22
39
  );
23
40
 
24
41
  /// A single HTTP cookie.
@@ -30,80 +47,103 @@ pub struct Cookie(RawCookie<'static>);
30
47
  #[derive(Default)]
31
48
  pub struct Cookies(pub Vec<HeaderValue>);
32
49
 
33
- /// A good default `CookieStore` implementation.
50
+ /// Keyword options accepted by `Wreq::Cookie.new`.
51
+ #[derive(Deserialize)]
52
+ struct Builder {
53
+ /// The Domain attribute.
54
+ domain: Option<String>,
55
+
56
+ /// The Path attribute.
57
+ path: Option<String>,
58
+
59
+ /// The signed Max-Age lifetime in seconds.
60
+ #[serde(default)]
61
+ max_age: NativeOption<i64>,
62
+
63
+ /// The expiration converted from a Ruby `Time` or Numeric value.
64
+ #[serde(default)]
65
+ expires: NativeOption<CookieExpiration>,
66
+
67
+ /// Whether the cookie is inaccessible to client-side scripts.
68
+ http_only: Option<bool>,
69
+
70
+ /// Whether the cookie is restricted to secure connections.
71
+ secure: Option<bool>,
72
+
73
+ /// The SameSite policy.
74
+ #[serde(default)]
75
+ same_site: NativeOption<Obj<SameSite>>,
76
+ }
77
+
78
+ /// A cookie jar that can be shared with a Ruby `Wreq::Client`.
34
79
  ///
35
- /// This is the implementation used when simply calling `cookie_store(true)`.
36
- /// This type is exposed to allow creating one and filling it with some
37
- /// existing cookies more easily, before creating a `Client`.
80
+ /// Pass a populated jar as the client's `cookie_provider` option.
38
81
  #[derive(Clone, Default)]
39
82
  #[magnus::wrap(class = "Wreq::Jar", free_immediately, size)]
40
83
  pub struct Jar(pub Arc<wreq::cookie::Jar>);
41
84
 
42
- // ===== impl Cookie =====
43
-
44
- impl Cookie {
45
- /// Create a new [`Cookie`].
46
- pub fn new(args: &[Value]) -> Result<Self, Error> {
47
- let args =
48
- magnus::scan_args::scan_args::<(String, String), (), (), (), magnus::RHash, ()>(args)?;
49
- #[allow(clippy::type_complexity)]
50
- let keywords: magnus::scan_args::KwArgs<
51
- (),
52
- (
53
- Option<String>,
54
- Option<String>,
55
- Option<u64>,
56
- Option<f64>,
57
- Option<bool>,
58
- Option<bool>,
59
- Option<Obj<SameSite>>,
60
- ),
61
- (),
62
- > = magnus::scan_args::get_kwargs(
63
- args.keywords,
64
- &[],
65
- &[
66
- "domain",
67
- "path",
68
- "max_age",
69
- "expires",
70
- "http_only",
71
- "secure",
72
- "same_site",
73
- ],
74
- )?;
75
-
76
- let (name, value) = args.required;
85
+ // ===== impl Builder =====
86
+
87
+ impl Builder {
88
+ /// Validates and deserializes a Ruby keyword options hash.
89
+ ///
90
+ /// # Errors
91
+ ///
92
+ /// Returns `ArgumentError` for unknown or duplicate keys. Invalid values
93
+ /// return the Ruby conversion error with the option name attached.
94
+ fn from_options(options: Options<'_>) -> Result<Self, Error> {
95
+ let mut builder = options.validate_keys::<Self>()?.deserialize::<Self>()?;
96
+ extract_native_option!(options, builder, max_age);
97
+ extract_native_option!(options, builder, expires);
98
+ extract_native_option!(options, builder, same_site);
99
+ Ok(builder)
100
+ }
77
101
 
102
+ /// Builds a native cookie from its name, value, and optional attributes.
103
+ fn build(mut self, name: String, value: String) -> Cookie {
78
104
  let mut cookie = RawCookie::new(name, value);
79
105
 
80
- if let Some(domain) = keywords.optional.0 {
106
+ if let Some(domain) = self.domain {
81
107
  cookie.set_domain(domain);
82
108
  }
83
109
 
84
- if let Some(path) = keywords.optional.1 {
110
+ if let Some(path) = self.path {
85
111
  cookie.set_path(path);
86
112
  }
87
113
 
88
- if let Some(max_age) = keywords.optional.2 {
89
- cookie.set_max_age(Duration::seconds(max_age as i64));
114
+ if let Some(max_age) = self.max_age.take() {
115
+ cookie.set_max_age(Duration::seconds(max_age));
90
116
  }
91
117
 
92
- if let Some(expires) = keywords.optional.3 {
93
- let duration = std::time::Duration::from_secs_f64(expires);
94
- if let Some(system_time) = SystemTime::UNIX_EPOCH.checked_add(duration) {
95
- cookie.set_expires(Expiration::DateTime(system_time.into()));
96
- }
118
+ if let Some(expires) = self.expires.take() {
119
+ cookie.set_expires(expires.into_inner());
97
120
  }
98
121
 
99
- cookie.set_http_only(keywords.optional.4);
100
- cookie.set_secure(keywords.optional.5);
122
+ cookie.set_http_only(self.http_only);
123
+ cookie.set_secure(self.secure);
101
124
 
102
- if let Some(same_site) = keywords.optional.6 {
125
+ if let Some(same_site) = self.same_site.take() {
103
126
  cookie.set_same_site(same_site.into_ffi());
104
127
  }
105
128
 
106
- Ok(Self(cookie))
129
+ Cookie(cookie)
130
+ }
131
+ }
132
+
133
+ // ===== Ruby Cookie API =====
134
+
135
+ impl Cookie {
136
+ /// Creates a [`Cookie`] from its name, value, and keyword options.
137
+ ///
138
+ /// # Errors
139
+ ///
140
+ /// Returns `ArgumentError` for unknown or duplicate keys. Invalid values
141
+ /// return the Ruby conversion error with the option name attached.
142
+ pub fn new(ruby: &Ruby, args: &[Value]) -> Result<Self, Error> {
143
+ let args = magnus::scan_args::scan_args::<(String, String), (), (), (), RHash, ()>(args)?;
144
+ let (name, value) = args.required;
145
+ Builder::from_options(Options::new(ruby, args.keywords))
146
+ .map(|builder| builder.build(name, value))
107
147
  }
108
148
 
109
149
  /// The name of the cookie.
@@ -130,18 +170,24 @@ impl Cookie {
130
170
  self.0.secure().unwrap_or(false)
131
171
  }
132
172
 
133
- /// Returns true if 'SameSite' directive is 'Lax'.
173
+ /// Returns true when the SameSite setting is Lax.
134
174
  #[inline]
135
175
  pub fn same_site_lax(&self) -> bool {
136
176
  self.0.same_site() == Some(cookie::SameSite::Lax)
137
177
  }
138
178
 
139
- /// Returns true if 'SameSite' directive is 'Strict'.
179
+ /// Returns true when the SameSite setting is Strict.
140
180
  #[inline]
141
181
  pub fn same_site_strict(&self) -> bool {
142
182
  self.0.same_site() == Some(cookie::SameSite::Strict)
143
183
  }
144
184
 
185
+ /// Returns the SameSite setting, if present.
186
+ #[inline]
187
+ pub fn same_site(&self) -> Option<SameSite> {
188
+ self.0.same_site().map(SameSite::from_ffi)
189
+ }
190
+
145
191
  /// Returns the path directive of the cookie, if set.
146
192
  #[inline]
147
193
  pub fn path(&self) -> Option<&str> {
@@ -154,41 +200,66 @@ impl Cookie {
154
200
  self.0.domain()
155
201
  }
156
202
 
157
- /// Get the Max-Age information.
203
+ /// Returns the signed Max-Age lifetime in seconds.
158
204
  #[inline]
159
205
  pub fn max_age(&self) -> Option<i64> {
160
206
  self.0.max_age().map(|d| d.whole_seconds())
161
207
  }
162
208
 
163
- /// The cookie expiration time.
209
+ /// Returns the cookie expiration as a UTC Ruby `Time`.
210
+ pub fn expires_at(ruby: &Ruby, rb_self: &Self) -> Result<Option<Time>, Error> {
211
+ rb_self
212
+ .0
213
+ .expires_datetime()
214
+ .map(|value| to_ruby_time(ruby, value))
215
+ .transpose()
216
+ }
217
+
218
+ /// Returns the cookie expiration as fractional Unix seconds.
164
219
  #[inline]
165
220
  pub fn expires(&self) -> Option<f64> {
166
- match self.0.expires() {
167
- Some(Expiration::DateTime(offset)) => {
168
- let system_time = SystemTime::from(offset);
169
- system_time
170
- .duration_since(SystemTime::UNIX_EPOCH)
171
- .ok()
172
- .map(|d| d.as_secs_f64())
173
- }
174
- None | Some(Expiration::Session) => None,
175
- }
221
+ self.0.expires_datetime().map(to_unix_timestamp)
222
+ }
223
+
224
+ /// Formats the cookie as a Set-Cookie value.
225
+ #[inline]
226
+ pub fn to_s(&self) -> String {
227
+ self.to_string()
176
228
  }
177
229
  }
178
230
 
231
+ // ===== Native Cookie helpers =====
232
+
179
233
  impl Cookie {
234
+ /// Clone this cookie for insertion into the native jar.
235
+ ///
236
+ /// [RFC 6265 section 5.2.2] says that zero or a negative Max-Age expires a
237
+ /// cookie immediately. The native jar recognizes zero for deletion, so
238
+ /// this clone maps negative values to zero before insertion. The Ruby
239
+ /// object keeps its original signed value.
240
+ ///
241
+ /// [RFC 6265 section 5.2.2]: https://www.rfc-editor.org/rfc/rfc6265.html#section-5.2.2
242
+ fn clone_for_jar(&self) -> RawCookie<'static> {
243
+ let mut cookie = self.0.clone();
244
+ if cookie.max_age().is_some_and(Duration::is_negative) {
245
+ cookie.set_max_age(Duration::ZERO);
246
+ }
247
+
248
+ cookie
249
+ }
250
+
180
251
  /// Parse cookies from a `HeaderMap`.
181
- pub fn extract_headers_cookies(headers: &HeaderMap) -> Vec<Cookie> {
252
+ pub(crate) fn extract_headers_cookies(headers: &HeaderMap) -> Vec<Cookie> {
182
253
  headers
183
254
  .get_all(header::SET_COOKIE)
184
255
  .iter()
185
- .map(Cookie::parse)
186
- .flat_map(Result::ok)
256
+ .filter_map(|value| Self::parse(value).ok())
187
257
  .map(RawCookie::into_owned)
188
258
  .map(Cookie)
189
259
  .collect()
190
260
  }
191
261
 
262
+ /// Parse one Set-Cookie header value.
192
263
  fn parse<'a>(value: &'a HeaderValue) -> Result<RawCookie<'a>, ParseError> {
193
264
  std::str::from_utf8(value.as_bytes())
194
265
  .map_err(cookie::ParseError::from)
@@ -206,13 +277,14 @@ impl fmt::Display for Cookie {
206
277
 
207
278
  impl TryConvert for Cookies {
208
279
  fn try_convert(value: magnus::Value) -> Result<Self, magnus::Error> {
280
+ let ruby = Ruby::get_with(value);
209
281
  // try extract uncompressed cookies
210
282
  if let Some(rhash) = RHash::from_value(value) {
211
283
  let mut cookies = Vec::new();
212
284
  rhash.foreach(|name: RString, value: RString| {
213
285
  let cookie = format!("{name}={value}");
214
286
  let header_value = HeaderValue::from_maybe_shared(Bytes::from(cookie))
215
- .map_err(header_value_error_to_magnus)?;
287
+ .map_err(|err| header_value_error(&ruby, err))?;
216
288
  cookies.push(header_value);
217
289
  Ok(ForEach::Continue)
218
290
  })?;
@@ -224,11 +296,11 @@ impl TryConvert for Cookies {
224
296
  if let Some(cookies) = RString::from_value(value) {
225
297
  return Ok(Self(vec![
226
298
  HeaderValue::from_maybe_shared(cookies.to_bytes())
227
- .map_err(header_value_error_to_magnus)?,
299
+ .map_err(|err| header_value_error(&ruby, err))?,
228
300
  ]));
229
301
  }
230
302
 
231
- Ok(Self::default())
303
+ Err(type_error(&ruby, "cookies must be a Hash or String"))
232
304
  }
233
305
  }
234
306
 
@@ -237,7 +309,7 @@ impl TryConvert for Cookies {
237
309
  impl Jar {
238
310
  /// Create a new [`Jar`] with an empty cookie store.
239
311
  pub fn new() -> Self {
240
- Jar(Arc::new(wreq::cookie::Jar::default()))
312
+ Self(Arc::new(wreq::cookie::Jar::default()))
241
313
  }
242
314
 
243
315
  /// Get all cookies.
@@ -256,14 +328,21 @@ impl Jar {
256
328
  }
257
329
 
258
330
  /// Add a cookie to this jar.
259
- pub fn add(&self, cookie: Value, url: String) {
331
+ ///
332
+ /// # Errors
333
+ ///
334
+ /// Returns `TypeError` when `cookie` is neither a [`Cookie`] nor a String.
335
+ pub fn add(&self, cookie: Value, url: String) -> Result<(), Error> {
260
336
  if let Ok(cookie) = Obj::<Cookie>::try_convert(cookie) {
261
- gvl::nogvl(|| self.0.add(cookie.0.clone(), &url))
337
+ gvl::nogvl(|| self.0.add(cookie.clone_for_jar(), &url));
338
+ return Ok(());
262
339
  }
263
340
 
264
- if let Ok(cookie_str) = String::try_convert(cookie) {
265
- gvl::nogvl(|| self.0.add(cookie_str.as_ref(), &url))
266
- }
341
+ let ruby = Ruby::get_with(cookie);
342
+ let cookie = String::try_convert(cookie)
343
+ .map_err(|_| type_error(&ruby, "cookie must be a Wreq::Cookie or String"))?;
344
+ gvl::nogvl(|| self.0.add(cookie.as_ref(), &url));
345
+ Ok(())
267
346
  }
268
347
 
269
348
  /// Remove a cookie from this jar by name and URL.
@@ -277,10 +356,121 @@ impl Jar {
277
356
  }
278
357
  }
279
358
 
359
+ mod helper {
360
+ //! Converts between Ruby time values and native cookie timestamps.
361
+
362
+ use cookie::time::{Duration, OffsetDateTime, error::ComponentRange};
363
+ use magnus::{
364
+ Error, Integer, Ruby, Time, TryConvert, Value,
365
+ time::{Offset, Timespec},
366
+ };
367
+
368
+ use crate::error::{argument_error, range_error};
369
+
370
+ /// A validated cookie expiration converted from Ruby.
371
+ ///
372
+ /// Ruby `Time` values keep nanosecond precision. Integer timestamps are
373
+ /// converted directly; other Numeric values use finite fractional Unix
374
+ /// seconds.
375
+ pub(super) struct CookieExpiration(OffsetDateTime);
376
+
377
+ impl CookieExpiration {
378
+ /// Unwraps the validated UTC expiration.
379
+ pub(super) fn into_inner(self) -> OffsetDateTime {
380
+ self.0
381
+ }
382
+ }
383
+
384
+ impl TryConvert for CookieExpiration {
385
+ fn try_convert(value: Value) -> Result<Self, Error> {
386
+ let ruby = Ruby::get_with(value);
387
+ expiration_from_value(&ruby, value).map(Self)
388
+ }
389
+ }
390
+
391
+ /// Converts a native cookie expiration into a UTC Ruby `Time`.
392
+ pub(super) fn to_ruby_time(ruby: &Ruby, value: OffsetDateTime) -> Result<Time, Error> {
393
+ ruby.time_timespec_new(
394
+ Timespec {
395
+ tv_sec: value.unix_timestamp(),
396
+ tv_nsec: i64::from(value.nanosecond()),
397
+ },
398
+ Offset::utc(),
399
+ )
400
+ }
401
+
402
+ /// Converts a native cookie expiration into fractional Unix seconds.
403
+ pub(super) fn to_unix_timestamp(value: OffsetDateTime) -> f64 {
404
+ value.unix_timestamp() as f64 + f64::from(value.nanosecond()) / 1_000_000_000.0
405
+ }
406
+
407
+ /// Converts a Ruby `Time` or Numeric value into a native UTC timestamp.
408
+ fn expiration_from_value(ruby: &Ruby, value: Value) -> Result<OffsetDateTime, Error> {
409
+ if let Some(time) = Time::from_value(value) {
410
+ return expiration_from_time(ruby, time);
411
+ }
412
+
413
+ if let Some(integer) = Integer::from_value(value) {
414
+ return integer
415
+ .to_i64()
416
+ .and_then(|seconds| expiration_from_seconds(ruby, seconds));
417
+ }
418
+
419
+ expiration_from_float(ruby, f64::try_convert(value)?)
420
+ }
421
+
422
+ /// Converts a Ruby `Time` from its signed timespec without using `SystemTime`.
423
+ fn expiration_from_time(ruby: &Ruby, value: Time) -> Result<OffsetDateTime, Error> {
424
+ let timespec = value.timespec()?;
425
+ let nanosecond = u32::try_from(timespec.tv_nsec)
426
+ .ok()
427
+ .filter(|value| *value < 1_000_000_000)
428
+ .ok_or_else(|| {
429
+ argument_error(ruby, "time nanoseconds are outside the supported range")
430
+ })?;
431
+
432
+ expiration_from_seconds(ruby, timespec.tv_sec)?
433
+ .replace_nanosecond(nanosecond)
434
+ .map_err(|error| expiration_range_error(ruby, error))
435
+ }
436
+
437
+ /// Converts signed Unix seconds into the native cookie time range.
438
+ fn expiration_from_seconds(ruby: &Ruby, seconds: i64) -> Result<OffsetDateTime, Error> {
439
+ OffsetDateTime::from_unix_timestamp(seconds)
440
+ .map_err(|error| expiration_range_error(ruby, error))
441
+ }
442
+
443
+ /// Converts a finite fractional Unix timestamp with checked arithmetic.
444
+ fn expiration_from_float(ruby: &Ruby, seconds: f64) -> Result<OffsetDateTime, Error> {
445
+ if !seconds.is_finite() {
446
+ return Err(argument_error(ruby, "timestamp must be finite"));
447
+ }
448
+
449
+ let duration = Duration::checked_seconds_f64(seconds)
450
+ .ok_or_else(|| range_error(ruby, "timestamp is outside the supported range"))?;
451
+ OffsetDateTime::UNIX_EPOCH
452
+ .checked_add(duration)
453
+ .ok_or_else(|| range_error(ruby, "timestamp is outside the supported range"))
454
+ }
455
+
456
+ /// Maps a native timestamp range error to Ruby's `RangeError`.
457
+ fn expiration_range_error(ruby: &Ruby, error: ComponentRange) -> Error {
458
+ range_error(
459
+ ruby,
460
+ format!("timestamp is outside the supported range: {error}"),
461
+ )
462
+ }
463
+ }
464
+
280
465
  pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> {
281
466
  // SameSite enum
282
467
  let same_site_class = gem_module.define_class("SameSite", ruby.class_object())?;
283
468
  SameSite::define_constants(same_site_class)?;
469
+ same_site_class.define_method("to_s", method!(SameSite::to_s, 0))?;
470
+ same_site_class.define_method("to_sym", method!(SameSite::to_sym, 0))?;
471
+ same_site_class.define_method("==", method!(SameSite::equals, 1))?;
472
+ same_site_class.define_method("eql?", method!(SameSite::is_eql, 1))?;
473
+ same_site_class.define_method("hash", method!(SameSite::hash_value, 0))?;
284
474
 
285
475
  // Cookie class
286
476
  let cookie_class = gem_module.define_class("Cookie", ruby.class_object())?;
@@ -293,10 +483,13 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> {
293
483
  cookie_class.define_method("secure?", method!(Cookie::secure, 0))?;
294
484
  cookie_class.define_method("same_site_lax?", method!(Cookie::same_site_lax, 0))?;
295
485
  cookie_class.define_method("same_site_strict?", method!(Cookie::same_site_strict, 0))?;
486
+ cookie_class.define_method("same_site", method!(Cookie::same_site, 0))?;
296
487
  cookie_class.define_method("path", method!(Cookie::path, 0))?;
297
488
  cookie_class.define_method("domain", method!(Cookie::domain, 0))?;
298
489
  cookie_class.define_method("max_age", method!(Cookie::max_age, 0))?;
490
+ cookie_class.define_method("expires_at", method!(Cookie::expires_at, 0))?;
299
491
  cookie_class.define_method("expires", method!(Cookie::expires, 0))?;
492
+ cookie_class.define_method("to_s", method!(Cookie::to_s, 0))?;
300
493
 
301
494
  // Jar class
302
495
  let jar_class = gem_module.define_class("Jar", ruby.class_object())?;