wintertc 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: 6d89343a04cf6097d2229ad2d14423636f3909bc5e101e07d2d3d0787ac0f272
4
+ data.tar.gz: ff8a77f280384bcf57b63763173dffa1fa7f29fd2edd6d3654f18cf9490ed50a
5
+ SHA512:
6
+ metadata.gz: a1ac56ebd31fa3411fb4cfa34ed461d0207a5c5a67f0fdc0f8f9e85d3a8ee96a19d349738921a5ffee12a82f46a5370bc4b89b2c01bd505e8c52e59d1530734d
7
+ data.tar.gz: ac43fdc877c54158e05ded060284a2d5700e66539bf9af887bd8d4e58787f977cd1dab9f5fe4cd4bfd96f04f8332021fb9b8a5aae6eb297b32573a6cb3fad9c4
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
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
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-03-08
11
+
12
+ ### Added
13
+ - Initial release.
14
+ - `WinterTc::Headers` — case-insensitive HTTP header map.
15
+ - `WinterTc::Request` — immutable HTTP request object.
16
+ - `WinterTc::Response` — HTTP response with `#text`, `#json`, `#ok` helpers, and `.json` static constructor.
17
+ - `WinterTc.fetch` — synchronous Fetch-API-compatible HTTP client.
18
+ - RBS type signatures in `sig/wintertc.rbs`.
19
+ - GitHub Actions CI for Ruby 3.3, 3.4, and head.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kuboon
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,137 @@
1
+ # wintertc.rb
2
+
3
+ [![CI](https://github.com/kuboon/wintertc.rb/actions/workflows/ci.yml/badge.svg)](https://github.com/kuboon/wintertc.rb/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/wintertc.svg)](https://badge.fury.io/rb/wintertc)
5
+
6
+ A Ruby HTTP client whose interface mirrors the JavaScript
7
+ [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) as
8
+ closely as possible.
9
+
10
+ **No runtime gem dependencies** — only Ruby's built-in `net/http`, `uri`, and
11
+ `json` standard-library modules are used.
12
+
13
+ ## Installation
14
+
15
+ Add to your `Gemfile`:
16
+
17
+ ```ruby
18
+ gem "wintertc"
19
+ ```
20
+
21
+ or install directly:
22
+
23
+ ```
24
+ gem install wintertc
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```ruby
30
+ require "wintertc"
31
+
32
+ # Simple GET
33
+ response = WinterTc.fetch("https://httpbin.org/get")
34
+ puts response.status # => 200
35
+ puts response.ok # => true
36
+ puts response.json["url"] # => "https://httpbin.org/get"
37
+
38
+ # POST with a JSON body
39
+ response = WinterTc.fetch(
40
+ "https://httpbin.org/post",
41
+ method: "POST",
42
+ headers: { "Content-Type" => "application/json" },
43
+ body: JSON.generate({ hello: "world" })
44
+ )
45
+ puts response.json["json"] # => {"hello"=>"world"}
46
+
47
+ # Re-use a Request object
48
+ req = WinterTc::Request.new("https://httpbin.org/get")
49
+ response = WinterTc.fetch(req)
50
+ ```
51
+
52
+ ## API Reference
53
+
54
+ ### `WinterTc.fetch(input, **options) → Response`
55
+
56
+ Performs a synchronous HTTP request and returns a `Response`.
57
+
58
+ | Option | Type | Default | Description |
59
+ |------------|-----------------------------------|------------|-----------------------------------------------------|
60
+ | `method` | `String` | `"GET"` | HTTP verb (GET, POST, PUT, PATCH, DELETE, …) |
61
+ | `headers` | `Hash` / `WinterTc::Headers` | `{}` | Request headers |
62
+ | `body` | `String` / `nil` | `nil` | Request body |
63
+ | `redirect` | `:follow` / `:manual` / `:error` | `:follow` | Redirect strategy |
64
+
65
+ Redirects on 301/302/303 change the method to `GET`; 307/308 preserve the
66
+ original method — consistent with browser behaviour.
67
+
68
+ ### `WinterTc::Headers`
69
+
70
+ A case-insensitive header map.
71
+
72
+ ```ruby
73
+ h = WinterTc::Headers.new("Content-Type" => "text/html")
74
+ h.get("content-type") # => "text/html"
75
+ h.has("Content-Type") # => true
76
+ h.set("Accept", "application/json")
77
+ h.append("Accept", "text/plain")
78
+ h.get("accept") # => "application/json, text/plain"
79
+ h.delete("accept")
80
+ h.to_h # => {}
81
+ ```
82
+
83
+ ### `WinterTc::Request`
84
+
85
+ ```ruby
86
+ req = WinterTc::Request.new(
87
+ "https://api.example.com/items",
88
+ method: "POST",
89
+ headers: { "Authorization" => "Bearer token" },
90
+ body: JSON.generate({ name: "widget" })
91
+ )
92
+ req.url # => "https://api.example.com/items"
93
+ req.method # => "POST"
94
+ req.headers # => #<WinterTc::Headers ...>
95
+ req.body # => '{"name":"widget"}'
96
+
97
+ # Clone with overrides
98
+ updated = WinterTc::Request.new(req, body: JSON.generate({ name: "gadget" }))
99
+ ```
100
+
101
+ ### `WinterTc::Response`
102
+
103
+ ```ruby
104
+ response.status # => 200
105
+ response.ok # => true (status 200–299)
106
+ response.ok? # alias for ok
107
+ response.status_text # => "OK"
108
+ response.headers # => #<WinterTc::Headers ...>
109
+ response.text # => raw body string
110
+ response.json # => parsed JSON (raises JSON::ParserError on invalid JSON)
111
+ response.url # => final URL after redirects
112
+ ```
113
+
114
+ ## Error Classes
115
+
116
+ | Class | Raised when |
117
+ |--------------------------------|---------------------------------------------------|
118
+ | `WinterTc::Error` | Base class for all WinterTc errors |
119
+ | `WinterTc::TooManyRedirectsError` | More than `WinterTc::MAX_REDIRECTS` redirects |
120
+ | `WinterTc::RedirectError` | `redirect: :error` and a redirect is received |
121
+ | `WinterTc::UnsupportedMethodError` | An unknown HTTP verb is requested |
122
+
123
+ ## Type Signatures (RBS)
124
+
125
+ RBS signatures are shipped in `sig/wintertc.rbs` and are compatible with
126
+ [Steep](https://github.com/soutaro/steep) and other RBS-aware tools.
127
+
128
+ ## Development
129
+
130
+ ```
131
+ bundle install
132
+ bundle exec rake test
133
+ ```
134
+
135
+ ## License
136
+
137
+ [MIT](LICENSE)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WinterTc
4
+ # Base class for all WinterTc errors.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when more redirects than {WinterTc::MAX_REDIRECTS} are encountered.
8
+ class TooManyRedirectsError < Error; end
9
+
10
+ # Raised when a redirect is encountered and `redirect: :error` is set.
11
+ class RedirectError < Error; end
12
+
13
+ # Raised when an unsupported HTTP method is requested.
14
+ class UnsupportedMethodError < Error; end
15
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module WinterTc
7
+ # The default maximum number of redirects that {WinterTc.fetch} will follow
8
+ # before raising {TooManyRedirectsError}.
9
+ MAX_REDIRECTS = 20
10
+
11
+ # @api private
12
+ # Maps HTTP method strings to Net::HTTP request classes.
13
+ NET_HTTP_METHOD_MAP = {
14
+ "GET" => Net::HTTP::Get,
15
+ "POST" => Net::HTTP::Post,
16
+ "PUT" => Net::HTTP::Put,
17
+ "PATCH" => Net::HTTP::Patch,
18
+ "DELETE" => Net::HTTP::Delete,
19
+ "HEAD" => Net::HTTP::Head,
20
+ "OPTIONS" => Net::HTTP::Options,
21
+ }.freeze
22
+ private_constant :NET_HTTP_METHOD_MAP
23
+
24
+ class << self
25
+ # Performs an HTTP request, mirroring the JavaScript
26
+ # {https://developer.mozilla.org/en-US/docs/Web/API/fetch fetch()} function.
27
+ #
28
+ # Unlike the JavaScript version this method is *synchronous* — it blocks
29
+ # until the server returns a complete response (no Promise / async / await).
30
+ #
31
+ # Redirects are followed automatically by default (up to {MAX_REDIRECTS}
32
+ # hops). On 301 / 302 / 303 responses the method is changed to GET, while
33
+ # 307 / 308 preserve the original method — consistent with browser behaviour.
34
+ #
35
+ # @param input [String, Request] target URL string or a {Request} object.
36
+ # @param method [String, nil] HTTP method (default: +"GET"+). Ignored
37
+ # when +input+ is a Request and +method+ is not explicitly provided.
38
+ # @param headers [Hash, Headers, nil] request headers.
39
+ # @param body [String, nil] request body.
40
+ # @param redirect [Symbol] redirect handling strategy:
41
+ # * +:follow+ (default) — follow redirects transparently.
42
+ # * +:manual+ — return the 3xx response as-is without following.
43
+ # * +:error+ — raise {RedirectError} when a redirect is encountered.
44
+ # @return [Response]
45
+ # @raise [ArgumentError] if the URL scheme is not http or https.
46
+ # @raise [TooManyRedirectsError] if more than {MAX_REDIRECTS} redirects
47
+ # are encountered.
48
+ # @raise [RedirectError] if +redirect: :error+ and a redirect
49
+ # response is returned.
50
+ #
51
+ # @example Simple GET
52
+ # response = WinterTc.fetch("https://example.com")
53
+ # puts response.status #=> 200
54
+ # puts response.ok #=> true
55
+ # puts response.text #=> "<html>..."
56
+ #
57
+ # @example POST with a JSON body
58
+ # response = WinterTc.fetch(
59
+ # "https://api.example.com/items",
60
+ # method: "POST",
61
+ # headers: { "Content-Type" => "application/json" },
62
+ # body: JSON.generate({ name: "widget" })
63
+ # )
64
+ # item = response.json #=> { "id" => 42, "name" => "widget" }
65
+ #
66
+ # @example Re-using a Request object
67
+ # req = WinterTc::Request.new("https://api.example.com/items", method: "GET")
68
+ # response = WinterTc.fetch(req)
69
+ def fetch(input, method: nil, headers: nil, body: nil, redirect: :follow, **_opts)
70
+ request = build_request(input, method: method, headers: headers, body: body)
71
+ perform(request, redirect: redirect, hops: 0)
72
+ end
73
+
74
+ private
75
+
76
+ # Builds a {Request} from a URL string or existing Request, applying
77
+ # any extra keyword-argument overrides.
78
+ def build_request(input, method:, headers:, body:)
79
+ if input.is_a?(Request)
80
+ Request.new(input, method: method, headers: headers, body: body)
81
+ else
82
+ Request.new(input.to_s, method: method, headers: headers, body: body)
83
+ end
84
+ end
85
+
86
+ # Executes the HTTP request, handling redirects recursively.
87
+ def perform(request, redirect:, hops:)
88
+ uri = parse_uri(request.url)
89
+ net_req = build_net_request(request, uri) # validate method before opening a socket
90
+
91
+ net_response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
92
+ http.request(net_req)
93
+ end
94
+
95
+ if net_response.is_a?(Net::HTTPRedirection)
96
+ handle_redirect(net_response, request, redirect: redirect, hops: hops)
97
+ else
98
+ build_response(net_response, url: request.url)
99
+ end
100
+ end
101
+
102
+ # Parses a URL string into a URI, raising ArgumentError for non-HTTP(S) URLs.
103
+ def parse_uri(url)
104
+ uri = URI.parse(url)
105
+ unless uri.is_a?(URI::HTTP)
106
+ raise ArgumentError, "Only http and https URLs are supported; got: #{url.inspect}"
107
+ end
108
+
109
+ uri
110
+ end
111
+
112
+ # Builds a Net::HTTP request object from a {Request}.
113
+ def build_net_request(request, uri)
114
+ klass = NET_HTTP_METHOD_MAP[request.method] ||
115
+ raise(UnsupportedMethodError, "Unsupported HTTP method: #{request.method}")
116
+
117
+ net_req = klass.new(uri.request_uri)
118
+ request.headers.each { |name, value| net_req[name] = value }
119
+ net_req.body = request.body if request.body
120
+ net_req
121
+ end
122
+
123
+ # Handles a 3xx redirect response according to the +redirect+ strategy.
124
+ def handle_redirect(net_response, original_request, redirect:, hops:)
125
+ case redirect
126
+ when :follow
127
+ if hops >= MAX_REDIRECTS
128
+ raise TooManyRedirectsError,
129
+ "Too many redirects (maximum is #{MAX_REDIRECTS})"
130
+ end
131
+
132
+ location = net_response["location"]
133
+ unless location
134
+ raise RedirectError,
135
+ "Redirect response (#{net_response.code}) is missing a Location header"
136
+ end
137
+ new_url = resolve_url(location, original_request.url)
138
+
139
+ # 301/302/303 → change to GET; 307/308 → preserve original method.
140
+ new_method = case net_response.code.to_i
141
+ when 307, 308 then original_request.method
142
+ else "GET"
143
+ end
144
+ request_kwargs = { method: new_method, headers: original_request.headers }
145
+ request_kwargs[:body] = original_request.body if new_method == original_request.method && original_request.body
146
+ new_request = Request.new(new_url, **request_kwargs)
147
+ perform(new_request, redirect: :follow, hops: hops + 1)
148
+
149
+ when :error
150
+ raise RedirectError,
151
+ "Unexpected redirect (#{net_response.code}) to #{net_response["location"]}"
152
+
153
+ when :manual
154
+ build_response(net_response, url: original_request.url)
155
+
156
+ else
157
+ raise ArgumentError, "Unknown redirect option: #{redirect.inspect}"
158
+ end
159
+ end
160
+
161
+ # Converts a Net::HTTPResponse into a {Response}.
162
+ def build_response(net_response, url:)
163
+ headers = Headers.new
164
+ net_response.each_header { |k, v| headers.set(k, v) }
165
+ Response.new(
166
+ status: net_response.code.to_i,
167
+ headers: headers,
168
+ body: net_response.body,
169
+ url: url,
170
+ )
171
+ end
172
+
173
+ # Resolves a (possibly relative) redirect location against the base URL.
174
+ def resolve_url(location, base_url)
175
+ if location.start_with?("http://", "https://")
176
+ location
177
+ else
178
+ URI.join(base_url, location).to_s
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WinterTc
4
+ # A case-insensitive map of HTTP headers, mirroring the JavaScript
5
+ # {https://developer.mozilla.org/en-US/docs/Web/API/Headers Headers} interface.
6
+ #
7
+ # Header names are normalised to lowercase internally so that lookups are
8
+ # always case-insensitive, just like the browser implementation.
9
+ #
10
+ # @example Basic usage
11
+ # headers = WinterTc::Headers.new("Content-Type" => "application/json")
12
+ # headers.get("content-type") #=> "application/json"
13
+ # headers.has("CONTENT-TYPE") #=> true
14
+ # headers.set("Accept", "text/html")
15
+ # headers.append("Accept", "application/xhtml+xml")
16
+ # headers.get("accept") #=> "text/html, application/xhtml+xml"
17
+ class Headers
18
+ include Enumerable
19
+
20
+ # Creates a new Headers object.
21
+ #
22
+ # @param init [Hash, Array<Array<String>>, Headers, nil]
23
+ # Initial headers. Accepts a Hash (String => String), an Array of
24
+ # two-element [name, value] arrays, another Headers object, or nil for
25
+ # an empty collection.
26
+ # @raise [TypeError] when init is not one of the accepted types
27
+ def initialize(init = nil)
28
+ @data = {}
29
+ case init
30
+ when Hash then init.each { |k, v| set(k, v) }
31
+ when Array then init.each { |(k, v)| set(k, v) }
32
+ when Headers then init.each { |k, v| @data[k] = v }
33
+ when nil then # empty
34
+ else raise TypeError, "init must be a Hash, Array, Headers, or nil"
35
+ end
36
+ end
37
+
38
+ # Returns the first value associated with the given header name, or +nil+
39
+ # if the header is not present.
40
+ #
41
+ # @param name [String] header name (case-insensitive)
42
+ # @return [String, nil]
43
+ def get(name)
44
+ @data[normalize(name)]
45
+ end
46
+
47
+ # Sets a header, replacing any existing value.
48
+ #
49
+ # @param name [String] header name (case-insensitive)
50
+ # @param value [String] header value
51
+ # @return [void]
52
+ def set(name, value)
53
+ @data[normalize(name)] = value.to_s
54
+ nil
55
+ end
56
+
57
+ # Returns +true+ if a header with the given name exists.
58
+ #
59
+ # @param name [String] header name (case-insensitive)
60
+ # @return [Boolean]
61
+ def has(name)
62
+ @data.key?(normalize(name))
63
+ end
64
+
65
+ # Removes the header with the given name.
66
+ #
67
+ # @param name [String] header name (case-insensitive)
68
+ # @return [void]
69
+ def delete(name)
70
+ @data.delete(normalize(name))
71
+ nil
72
+ end
73
+
74
+ # Appends a value to an existing header. If the header is not yet
75
+ # present it is created. Multiple values are joined with +", "+,
76
+ # following the HTTP specification.
77
+ #
78
+ # @param name [String] header name (case-insensitive)
79
+ # @param value [String] value to append
80
+ # @return [void]
81
+ def append(name, value)
82
+ key = normalize(name)
83
+ if @data.key?(key)
84
+ @data[key] = "#{@data[key]}, #{value}"
85
+ else
86
+ @data[key] = value.to_s
87
+ end
88
+ nil
89
+ end
90
+
91
+ # Yields each +[name, value]+ pair. Names are in lowercase.
92
+ #
93
+ # @yieldparam name [String]
94
+ # @yieldparam value [String]
95
+ # @return [Enumerator] if no block is given
96
+ # @return [self] otherwise
97
+ def each
98
+ return to_enum(:each) unless block_given?
99
+
100
+ @data.each do |name, value|
101
+ yield name, value
102
+ end
103
+ self
104
+ end
105
+
106
+ # Returns all header names (lowercase).
107
+ #
108
+ # @return [Array<String>]
109
+ def keys
110
+ @data.keys
111
+ end
112
+
113
+ # Returns all header values.
114
+ #
115
+ # @return [Array<String>]
116
+ def values
117
+ @data.values
118
+ end
119
+
120
+ # Returns a plain +Hash+ copy of all headers.
121
+ #
122
+ # @return [Hash{String => String}]
123
+ def to_h
124
+ @data.dup
125
+ end
126
+
127
+ # @return [String]
128
+ def inspect
129
+ "#<#{self.class} #{@data.inspect}>"
130
+ end
131
+
132
+ private
133
+
134
+ # Normalises a header name to lowercase.
135
+ def normalize(name)
136
+ name.to_s.downcase
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WinterTc
4
+ # Represents an HTTP request, mirroring the JavaScript
5
+ # {https://developer.mozilla.org/en-US/docs/Web/API/Request Request} interface.
6
+ #
7
+ # A Request object can be passed directly to {WinterTc.fetch} instead of a
8
+ # plain URL string. It can also be used to clone an existing request while
9
+ # overriding individual fields.
10
+ #
11
+ # @example Creating a POST request
12
+ # req = WinterTc::Request.new(
13
+ # "https://example.com/api",
14
+ # method: "POST",
15
+ # headers: { "Content-Type" => "application/json" },
16
+ # body: JSON.generate({ key: "value" })
17
+ # )
18
+ # response = WinterTc.fetch(req)
19
+ #
20
+ # @example Cloning a request with a different body
21
+ # cloned = WinterTc::Request.new(req, body: JSON.generate({ key: "new" }))
22
+ class Request
23
+ # The absolute URL of the request.
24
+ # @return [String]
25
+ attr_reader :url
26
+
27
+ # The HTTP method in uppercase, e.g. +"GET"+ or +"POST"+.
28
+ # @return [String]
29
+ attr_reader :method
30
+
31
+ # The request headers.
32
+ # @return [Headers]
33
+ attr_reader :headers
34
+
35
+ # The request body, or +nil+ for requests without a body.
36
+ # @return [String, nil]
37
+ attr_reader :body
38
+
39
+ # Creates a new Request.
40
+ #
41
+ # @param input [String, Request] the target URL or an existing Request to
42
+ # clone.
43
+ # @param method [String, nil] HTTP method. Defaults to +"GET"+, or to
44
+ # the method of the cloned request.
45
+ # @param headers [Hash, Headers, nil] Additional headers. When cloning a
46
+ # request the headers are merged: these values take precedence.
47
+ # @param body [String, nil] Request body. Defaults to +nil+, or to the
48
+ # body of the cloned request.
49
+ # @raise [TypeError] when input is not a String or Request
50
+ def initialize(input, method: nil, headers: nil, body: nil)
51
+ case input
52
+ when Request
53
+ @url = input.url
54
+ @method = (method || input.method).to_s.upcase
55
+ @headers = merge_headers(input.headers, headers)
56
+ @body = body.nil? ? input.body : body
57
+ when String
58
+ @url = input
59
+ @method = (method || "GET").to_s.upcase
60
+ @headers = Headers.new(headers)
61
+ @body = body
62
+ else
63
+ raise TypeError, "input must be a String URL or a Request; got #{input.class}"
64
+ end
65
+ end
66
+
67
+ # @return [String]
68
+ def inspect
69
+ "#<#{self.class} #{@method} #{@url}>"
70
+ end
71
+
72
+ private
73
+
74
+ # Merges base headers with optional overrides, returning a new Headers.
75
+ def merge_headers(base, extra)
76
+ h = Headers.new(base)
77
+ return h unless extra
78
+
79
+ case extra
80
+ when Headers then extra.each { |k, v| h.set(k, v) }
81
+ when Hash then extra.each { |k, v| h.set(k, v) }
82
+ end
83
+ h
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module WinterTc
6
+ # Represents an HTTP response, mirroring the JavaScript
7
+ # {https://developer.mozilla.org/en-US/docs/Web/API/Response Response} interface.
8
+ #
9
+ # Response objects are returned by {WinterTc.fetch}. The raw body can be
10
+ # read as a plain string with {#text}, or parsed as JSON with {#json}.
11
+ #
12
+ # @example
13
+ # response = WinterTc.fetch("https://api.example.com/users")
14
+ # if response.ok
15
+ # users = response.json # => Array of user hashes
16
+ # else
17
+ # puts "Error: #{response.status} #{response.status_text}"
18
+ # end
19
+ class Response
20
+ # Mapping of common HTTP status codes to their standard reason phrases.
21
+ STATUS_TEXTS = {
22
+ 100 => "Continue",
23
+ 101 => "Switching Protocols",
24
+ 102 => "Processing",
25
+ 200 => "OK",
26
+ 201 => "Created",
27
+ 202 => "Accepted",
28
+ 203 => "Non-Authoritative Information",
29
+ 204 => "No Content",
30
+ 205 => "Reset Content",
31
+ 206 => "Partial Content",
32
+ 301 => "Moved Permanently",
33
+ 302 => "Found",
34
+ 303 => "See Other",
35
+ 304 => "Not Modified",
36
+ 307 => "Temporary Redirect",
37
+ 308 => "Permanent Redirect",
38
+ 400 => "Bad Request",
39
+ 401 => "Unauthorized",
40
+ 402 => "Payment Required",
41
+ 403 => "Forbidden",
42
+ 404 => "Not Found",
43
+ 405 => "Method Not Allowed",
44
+ 406 => "Not Acceptable",
45
+ 409 => "Conflict",
46
+ 410 => "Gone",
47
+ 411 => "Length Required",
48
+ 413 => "Content Too Large",
49
+ 415 => "Unsupported Media Type",
50
+ 422 => "Unprocessable Content",
51
+ 429 => "Too Many Requests",
52
+ 500 => "Internal Server Error",
53
+ 501 => "Not Implemented",
54
+ 502 => "Bad Gateway",
55
+ 503 => "Service Unavailable",
56
+ 504 => "Gateway Timeout",
57
+ }.freeze
58
+
59
+ # The HTTP status code (e.g. +200+, +404+).
60
+ # @return [Integer]
61
+ attr_reader :status
62
+
63
+ # The response headers.
64
+ # @return [Headers]
65
+ attr_reader :headers
66
+
67
+ # The URL that produced this response (the final URL after any redirects).
68
+ # @return [String, nil]
69
+ attr_reader :url
70
+
71
+ # Creates a {Response} whose body is the JSON serialisation of +data+ and
72
+ # whose +Content-Type+ header is set to +application/json+. This mirrors
73
+ # the JavaScript
74
+ # {https://developer.mozilla.org/en-US/docs/Web/API/Response/json_static
75
+ # Response.json()} static method.
76
+ #
77
+ # @param data [Object] any JSON-serialisable value (Hash, Array, String, …)
78
+ # @param status [Integer] HTTP status code (default: +200+)
79
+ # @param headers [Hash, Headers, nil] additional response headers
80
+ # @return [Response]
81
+ #
82
+ # @example
83
+ # res = WinterTc::Response.json({ message: "hello" })
84
+ # res.status #=> 200
85
+ # res.headers.get("content-type") #=> "application/json"
86
+ # res.json #=> { "message" => "hello" }
87
+ def self.json(data, status: 200, headers: nil)
88
+ body = JSON.generate(data)
89
+ merged = Headers.new(headers)
90
+ merged.set("Content-Type", "application/json")
91
+ new(status: status, headers: merged, body: body)
92
+ end
93
+
94
+ # Creates a new Response.
95
+ #
96
+ # @param status [Integer] HTTP status code
97
+ # @param headers [Hash, Headers] response headers
98
+ # @param body [String, nil] raw response body
99
+ # @param url [String, nil] the URL that produced the response
100
+ def initialize(status:, headers:, body:, url: nil)
101
+ @status = Integer(status)
102
+ @headers = headers.is_a?(Headers) ? headers : Headers.new(headers)
103
+ @body = body.to_s
104
+ @url = url
105
+ end
106
+
107
+ # Returns +true+ if the HTTP status code indicates success (200–299).
108
+ #
109
+ # @return [Boolean]
110
+ def ok
111
+ @status >= 200 && @status < 300
112
+ end
113
+
114
+ # Alias for {#ok} for idiomatic Ruby usage.
115
+ alias ok? ok
116
+
117
+ # Returns the response body as a plain +String+.
118
+ #
119
+ # @return [String]
120
+ def text
121
+ @body
122
+ end
123
+
124
+ # Parses the response body as JSON and returns the result.
125
+ #
126
+ # @return [Object] the parsed JSON value (Hash, Array, String, etc.)
127
+ # @raise [JSON::ParserError] if the body is not valid JSON
128
+ def json
129
+ JSON.parse(@body)
130
+ end
131
+
132
+ # Returns the standard HTTP reason phrase for the {#status} code
133
+ # (e.g. +"OK"+ for 200, +"Not Found"+ for 404). Returns an empty
134
+ # string for unrecognised codes.
135
+ #
136
+ # @return [String]
137
+ def status_text
138
+ STATUS_TEXTS[@status] || ""
139
+ end
140
+
141
+ # @return [String]
142
+ def inspect
143
+ "#<#{self.class} #{@status} #{status_text}>"
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WinterTc
4
+ # The current version of the wintertc gem.
5
+ VERSION = "0.1.0"
6
+ end
data/lib/wintertc.rb ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "wintertc/version"
4
+ require_relative "wintertc/error"
5
+ require_relative "wintertc/headers"
6
+ require_relative "wintertc/request"
7
+ require_relative "wintertc/response"
8
+ require_relative "wintertc/fetch"
9
+
10
+ # WinterTc is a Ruby HTTP client library that provides an interface as close as
11
+ # possible to the JavaScript
12
+ # {https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API Fetch API}.
13
+ #
14
+ # The three primary building blocks mirror their JavaScript counterparts:
15
+ #
16
+ # * {WinterTc::Headers} — a case-insensitive map of HTTP header fields.
17
+ # * {WinterTc::Request} — an immutable description of an HTTP request.
18
+ # * {WinterTc::Response} — the server's response, with helpers for reading the
19
+ # body as text or JSON.
20
+ # * {WinterTc.fetch} — the main entry point; performs the request and returns a
21
+ # {WinterTc::Response}.
22
+ #
23
+ # The library has **no runtime dependencies** beyond Ruby's standard library
24
+ # (`net/http`, `uri`, `json`).
25
+ #
26
+ # @example Quick start
27
+ # require "wintertc"
28
+ #
29
+ # # Simple GET
30
+ # res = WinterTc.fetch("https://httpbin.org/get")
31
+ # puts res.status #=> 200
32
+ # puts res.ok #=> true
33
+ # puts res.json["url"] #=> "https://httpbin.org/get"
34
+ #
35
+ # # POST with JSON
36
+ # res = WinterTc.fetch(
37
+ # "https://httpbin.org/post",
38
+ # method: "POST",
39
+ # headers: { "Content-Type" => "application/json" },
40
+ # body: JSON.generate({ hello: "world" })
41
+ # )
42
+ # puts res.json["json"] #=> { "hello" => "world" }
43
+ module WinterTc
44
+ end
data/sig/wintertc.rbs ADDED
@@ -0,0 +1,110 @@
1
+ module WinterTc
2
+ VERSION: String
3
+ MAX_REDIRECTS: Integer
4
+
5
+ # ---------------------------------------------------------------------------
6
+ # Error classes
7
+ # ---------------------------------------------------------------------------
8
+
9
+ class Error < StandardError
10
+ end
11
+
12
+ class TooManyRedirectsError < Error
13
+ end
14
+
15
+ class RedirectError < Error
16
+ end
17
+
18
+ class UnsupportedMethodError < Error
19
+ end
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Headers
23
+ # ---------------------------------------------------------------------------
24
+
25
+ # A case-insensitive map of HTTP header fields, mirroring the JavaScript
26
+ # Headers interface.
27
+ class Headers
28
+ include Enumerable[[String, String]]
29
+
30
+ def initialize: (?Hash[String, String] | Array[[String, String]] | Headers | nil) -> void
31
+ def get: (String name) -> String?
32
+ def set: (String name, String value) -> nil
33
+ def has: (String name) -> bool
34
+ def delete: (String name) -> nil
35
+ def append: (String name, String value) -> nil
36
+ def each: () { ([String, String]) -> void } -> self
37
+ def keys: () -> Array[String]
38
+ def values: () -> Array[String]
39
+ def to_h: () -> Hash[String, String]
40
+ def inspect: () -> String
41
+ end
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Request
45
+ # ---------------------------------------------------------------------------
46
+
47
+ # An immutable description of an HTTP request.
48
+ class Request
49
+ attr_reader url: String
50
+ attr_reader method: String
51
+ attr_reader headers: Headers
52
+ attr_reader body: String?
53
+
54
+ def initialize: (
55
+ String | Request input,
56
+ ?method: String?,
57
+ ?headers: Hash[String, String] | Headers | nil,
58
+ ?body: String?
59
+ ) -> void
60
+
61
+ def inspect: () -> String
62
+ end
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Response
66
+ # ---------------------------------------------------------------------------
67
+
68
+ # The server's HTTP response.
69
+ class Response
70
+ STATUS_TEXTS: Hash[Integer, String]
71
+
72
+ attr_reader status: Integer
73
+ attr_reader headers: Headers
74
+ attr_reader url: String?
75
+
76
+ def self.json: (
77
+ untyped data,
78
+ ?status: Integer,
79
+ ?headers: Hash[String, String] | Headers | nil
80
+ ) -> Response
81
+
82
+ def initialize: (
83
+ status: Integer,
84
+ headers: Hash[String, String] | Headers,
85
+ body: String?,
86
+ ?url: String?
87
+ ) -> void
88
+
89
+ def ok: () -> bool
90
+ def ok?: () -> bool
91
+ def text: () -> String
92
+ def json: () -> untyped
93
+ def status_text: () -> String
94
+ def inspect: () -> String
95
+ end
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # fetch
99
+ # ---------------------------------------------------------------------------
100
+
101
+ # Performs an HTTP request and returns the response. Synchronous.
102
+ def self.fetch: (
103
+ String | Request input,
104
+ ?method: String?,
105
+ ?headers: Hash[String, String] | Headers | nil,
106
+ ?body: String?,
107
+ ?redirect: :follow | :manual | :error,
108
+ **untyped
109
+ ) -> Response
110
+ end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wintertc
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - kuboon
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13'
40
+ description: |
41
+ WinterTc provides a Ruby HTTP client whose interface mirrors the JavaScript
42
+ Fetch API as closely as possible. It exposes WinterTc::Request,
43
+ WinterTc::Response, WinterTc::Headers, and WinterTc.fetch — all with the
44
+ same semantics as their browser counterparts. No runtime gem dependencies:
45
+ only Ruby's built-in net/http, uri, and json are used.
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE
52
+ - README.md
53
+ - lib/wintertc.rb
54
+ - lib/wintertc/error.rb
55
+ - lib/wintertc/fetch.rb
56
+ - lib/wintertc/headers.rb
57
+ - lib/wintertc/request.rb
58
+ - lib/wintertc/response.rb
59
+ - lib/wintertc/version.rb
60
+ - sig/wintertc.rbs
61
+ homepage: https://github.com/kuboon/wintertc.rb
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ homepage_uri: https://github.com/kuboon/wintertc.rb
66
+ source_code_uri: https://github.com/kuboon/wintertc.rb
67
+ changelog_uri: https://github.com/kuboon/wintertc.rb/blob/main/CHANGELOG.md
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.3'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 4.0.3
83
+ specification_version: 4
84
+ summary: JavaScript-like Fetch API for Ruby
85
+ test_files: []