protocol-http 0.56.1 → 0.58.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9874b094d1320bc400ff81a991311fa74a9b95d0a89adc9c4a4c41b89380bb2a
4
- data.tar.gz: d2f9206d98a99557152c4758f266ef70d516072e2cfb9e0b6099dc4901bbbe95
3
+ metadata.gz: 971bece5e113d3f945faf8b8ce44858e97f0e6282e3a264b6e6fca1e57c8e71c
4
+ data.tar.gz: 8d1139a2022dbe96710d7e0286a5c30c2384e14fcba3e0907428361b25c3a703
5
5
  SHA512:
6
- metadata.gz: ec515579222f78ee8ee4353c346cf8d361bb244deefc4bf0deb5899fe28399c96e273fe8c1fca10e328b9e5872e5e2b40ea09fcf6c4463da704e097a58e795bb
7
- data.tar.gz: d2b739b64da0af2db050ddccfe292e464ed1defc7310e0152ddeeff58b4455b725483dd76aa237f21ff44d0ae3b1ff798269f81ed2c69a7ba6a1f865c1bc3820
6
+ metadata.gz: 69d17f997389daf2332cacc0d5ac407897e2dc2a45c3e329f23dfc9b18f7075b425e10c43fff27ff1728f64e92a6843e3291be5103e9ee8c72d1f810c2603c01
7
+ data.tar.gz: '09676f19f6b3bd4d6290b5dccd8f93d2c6409e82926fa74a9b21f39f4fa92df0d289ef8fac6b0460a9de2784d78d7114d262f9a5088f66518ef60b9d60a877f4'
checksums.yaml.gz.sig CHANGED
Binary file
@@ -205,5 +205,5 @@ interim_response_callback = proc do |status, headers|
205
205
  end
206
206
  end
207
207
 
208
- response = client.post("/upload", {'expect' => '100-continue'}, body, interim_response: interim_response_callback)
208
+ response = client.post("/upload", {"expect" => "100-continue"}, body, interim_response: interim_response_callback)
209
209
  ```
@@ -34,7 +34,7 @@ This gem does not provide any specific client or server implementation, rather i
34
34
  {ruby Protocol::HTTP::Request} represents an HTTP request which can be used both server and client-side.
35
35
 
36
36
  ``` ruby
37
- require 'protocol/http/request'
37
+ require "protocol/http/request"
38
38
 
39
39
  # Short form (recommended):
40
40
  request = Protocol::HTTP::Request["GET", "/index.html", {"accept" => "text/html"}]
@@ -54,7 +54,7 @@ request.headers # => Protocol::HTTP::Headers instance
54
54
  {ruby Protocol::HTTP::Response} represents an HTTP response which can be used both server and client-side.
55
55
 
56
56
  ``` ruby
57
- require 'protocol/http/response'
57
+ require "protocol/http/response"
58
58
 
59
59
  # Short form (recommended):
60
60
  response = Protocol::HTTP::Response[200, {"content-type" => "text/html"}, "Hello, World!"]
@@ -83,15 +83,15 @@ response.failure? # => false (400-599)
83
83
  #### Basic Usage
84
84
 
85
85
  ``` ruby
86
- require 'protocol/http/headers'
86
+ require "protocol/http/headers"
87
87
 
88
88
  headers = Protocol::HTTP::Headers.new
89
89
 
90
90
  # Assignment by title-case key:
91
- headers['Content-Type'] = "image/jpeg"
91
+ headers["Content-Type"] = "image/jpeg"
92
92
 
93
93
  # Lookup by lower-case (normalized) key:
94
- headers['content-type']
94
+ headers["content-type"]
95
95
  # => "image/jpeg"
96
96
  ```
97
97
 
@@ -101,8 +101,8 @@ Many headers receive special semantic processing, automatically splitting comma-
101
101
 
102
102
  ``` ruby
103
103
  # Accept header with quality values:
104
- headers['Accept'] = 'text/html, application/json;q=0.8, */*;q=0.1'
105
- accept = headers['accept']
104
+ headers["Accept"] = "text/html, application/json;q=0.8, */*;q=0.1"
105
+ accept = headers["accept"]
106
106
  # => ["text/html", "application/json;q=0.8", "*/*;q=0.1"]
107
107
 
108
108
  # Access parsed media ranges with quality factors:
@@ -114,17 +114,17 @@ end
114
114
  # */* (q=0.1)
115
115
 
116
116
  # Accept-Encoding automatically splits values:
117
- headers['Accept-Encoding'] = 'gzip, deflate, br;q=0.9'
118
- headers['accept-encoding']
117
+ headers["Accept-Encoding"] = "gzip, deflate, br;q=0.9"
118
+ headers["accept-encoding"]
119
119
  # => ["gzip", "deflate", "br;q=0.9"]
120
120
 
121
121
  # Cache-Control splits directives:
122
- headers['Cache-Control'] = 'max-age=3600, no-cache, must-revalidate'
123
- headers['cache-control']
122
+ headers["Cache-Control"] = "max-age=3600, no-cache, must-revalidate"
123
+ headers["cache-control"]
124
124
  # => ["max-age=3600", "no-cache", "must-revalidate"]
125
125
 
126
126
  # Vary header normalizes field names to lowercase:
127
- headers['Vary'] = 'Accept-Encoding, User-Agent'
128
- headers['vary']
127
+ headers["Vary"] = "Accept-Encoding, User-Agent"
128
+ headers["vary"]
129
129
  # => ["accept-encoding", "user-agent"]
130
130
  ```
data/context/headers.md CHANGED
@@ -15,17 +15,17 @@ This guide explains how to work with HTTP headers using `protocol-http`.
15
15
  The {Protocol::HTTP::Headers} class provides a comprehensive interface for creating and manipulating HTTP headers:
16
16
 
17
17
  ```ruby
18
- require 'protocol/http'
18
+ require "protocol/http"
19
19
 
20
20
  headers = Protocol::HTTP::Headers.new
21
- headers.add('content-type', 'text/html')
22
- headers.add('set-cookie', 'session=abc123')
21
+ headers.add("content-type", "text/html")
22
+ headers.add("set-cookie", "session=abc123")
23
23
 
24
24
  # Access headers
25
- content_type = headers['content-type'] # => "text/html"
25
+ content_type = headers["content-type"] # => "text/html"
26
26
 
27
27
  # Check if header exists
28
- headers.include?('content-type') # => true
28
+ headers.include?("content-type") # => true
29
29
  ```
30
30
 
31
31
  ### Header Policies
@@ -34,11 +34,11 @@ Different header types have different behaviors for merging, validation, and tra
34
34
 
35
35
  ```ruby
36
36
  # Some headers can be specified multiple times
37
- headers.add('set-cookie', 'first=value1')
38
- headers.add('set-cookie', 'second=value2')
37
+ headers.add("set-cookie", "first=value1")
38
+ headers.add("set-cookie", "second=value2")
39
39
 
40
40
  # Others are singletons and will raise errors if duplicated
41
- headers.add('content-length', '100')
41
+ headers.add("content-length", "100")
42
42
  # headers.add('content-length', '200') # Would raise DuplicateHeaderError
43
43
  ```
44
44
 
@@ -48,11 +48,11 @@ Some headers have specialized classes for parsing and formatting:
48
48
 
49
49
  ```ruby
50
50
  # Accept header with media ranges
51
- accept = Protocol::HTTP::Header::Accept.new('text/html,application/json;q=0.9')
51
+ accept = Protocol::HTTP::Header::Accept.new("text/html,application/json;q=0.9")
52
52
  media_ranges = accept.media_ranges
53
53
 
54
54
  # Authorization header
55
- auth = Protocol::HTTP::Header::Authorization.basic('username', 'password')
55
+ auth = Protocol::HTTP::Header::Authorization.basic("username", "password")
56
56
  # => "Basic dXNlcm5hbWU6cGFzc3dvcmQ="
57
57
  ```
58
58
 
@@ -63,20 +63,20 @@ HTTP trailers are headers that appear after the message body. For security reaso
63
63
  ```ruby
64
64
  # Working with trailers
65
65
  headers = Protocol::HTTP::Headers.new([
66
- ['content-type', 'text/html'],
67
- ['content-length', '1000']
66
+ ["content-type", "text/html"],
67
+ ["content-length", "1000"]
68
68
  ])
69
69
 
70
70
  # Start trailer section
71
71
  headers.trailer!
72
72
 
73
73
  # These will be allowed (safe metadata)
74
- headers.add('etag', '"12345"')
75
- headers.add('date', Time.now.httpdate)
74
+ headers.add("etag", '"12345"')
75
+ headers.add("date", Time.now.httpdate)
76
76
 
77
77
  # These will be silently ignored for security
78
- headers.add('authorization', 'Bearer token') # Ignored - credential leakage risk
79
- headers.add('connection', 'close') # Ignored - hop-by-hop header
78
+ headers.add("authorization", "Bearer token") # Ignored - credential leakage risk
79
+ headers.add("connection", "close") # Ignored - hop-by-hop header
80
80
  ```
81
81
 
82
82
  The trailer security system prevents HTTP request smuggling by restricting which headers can appear in trailers:
@@ -9,10 +9,10 @@ This guide explains how to use `Protocol::HTTP::Reference` for constructing and
9
9
  ## Basic Construction
10
10
 
11
11
  ``` ruby
12
- require 'protocol/http/reference'
12
+ require "protocol/http/reference"
13
13
 
14
14
  # Simple reference with parameters:
15
- reference = Protocol::HTTP::Reference.new("/search", nil, nil, {q: 'kittens', limit: 10})
15
+ reference = Protocol::HTTP::Reference.new("/search", nil, nil, {q: "kittens", limit: 10})
16
16
  reference.to_s
17
17
  # => "/search?q=kittens&limit=10"
18
18
 
@@ -97,7 +97,7 @@ body.empty? # => true
97
97
  Use {ruby Protocol::HTTP::Body::File} for serving files efficiently:
98
98
 
99
99
  ``` ruby
100
- require 'protocol/http/body/file'
100
+ require "protocol/http/body/file"
101
101
 
102
102
  # Open a file:
103
103
  body = Protocol::HTTP::Body::File.open("/path/to/file.txt")
@@ -140,7 +140,7 @@ body = Protocol::HTTP::Body::File.new(file, block_size: 8192) # 8KB chunks
140
140
  Use {ruby Protocol::HTTP::Body::Writable} for dynamic content generation:
141
141
 
142
142
  ``` ruby
143
- require 'protocol/http/body/writable'
143
+ require "protocol/http/body/writable"
144
144
 
145
145
  # Create a writable body:
146
146
  body = Protocol::HTTP::Body::Writable.new
@@ -181,7 +181,7 @@ body.write("chunk 1")
181
181
  Use {ruby Protocol::HTTP::Body::Streamable} for computed content:
182
182
 
183
183
  ``` ruby
184
- require 'protocol/http/body/streamable'
184
+ require "protocol/http/body/streamable"
185
185
 
186
186
  # Generate content dynamically:
187
187
  body = Protocol::HTTP::Body::Streamable.new do |output|
@@ -202,7 +202,7 @@ end
202
202
  Use {ruby Protocol::HTTP::Body::Stream} to wrap IO-like objects:
203
203
 
204
204
  ``` ruby
205
- require 'protocol/http/body/stream'
205
+ require "protocol/http/body/stream"
206
206
 
207
207
  # Wrap an IO object:
208
208
  io = StringIO.new("Hello\nWorld\nFrom\nStream")
@@ -224,8 +224,8 @@ rest = body.read # => "Stream"
224
224
  ### Compression Bodies
225
225
 
226
226
  ``` ruby
227
- require 'protocol/http/body/deflate'
228
- require 'protocol/http/body/inflate'
227
+ require "protocol/http/body/deflate"
228
+ require "protocol/http/body/inflate"
229
229
 
230
230
  # Compress a body:
231
231
  original = Protocol::HTTP::Body::Buffered.new(["Hello World"])
@@ -241,7 +241,7 @@ content = decompressed.join # => "Hello World"
241
241
  Create custom body transformations:
242
242
 
243
243
  ``` ruby
244
- require 'protocol/http/body/wrapper'
244
+ require "protocol/http/body/wrapper"
245
245
 
246
246
  class UppercaseBody < Protocol::HTTP::Body::Wrapper
247
247
  def read
@@ -299,7 +299,7 @@ end
299
299
  Make any body rewindable by buffering:
300
300
 
301
301
  ``` ruby
302
- require 'protocol/http/body/rewindable'
302
+ require "protocol/http/body/rewindable"
303
303
 
304
304
  # Wrap a non-rewindable body:
305
305
  file_body = Protocol::HTTP::Body::File.open("data.txt")
@@ -318,7 +318,7 @@ same_chunk = rewindable.read # Same as first_chunk
318
318
  For HEAD requests that need content-length but no body:
319
319
 
320
320
  ``` ruby
321
- require 'protocol/http/body/head'
321
+ require "protocol/http/body/head"
322
322
 
323
323
  # Create head body from another body:
324
324
  original = Protocol::HTTP::Body::File.open("large_file.zip")
@@ -38,7 +38,7 @@ end
38
38
  The `Protocol::HTTP::Middleware` class provides a convenient base for building middleware:
39
39
 
40
40
  ``` ruby
41
- require 'protocol/http/middleware'
41
+ require "protocol/http/middleware"
42
42
 
43
43
  class LoggingMiddleware < Protocol::HTTP::Middleware
44
44
  def call(request)
@@ -60,7 +60,7 @@ app = LoggingMiddleware.new(Protocol::HTTP::Middleware::HelloWorld)
60
60
  Use `Protocol::HTTP::Middleware.build` to construct middleware stacks:
61
61
 
62
62
  ``` ruby
63
- require 'protocol/http/middleware'
63
+ require "protocol/http/middleware"
64
64
 
65
65
  app = Protocol::HTTP::Middleware.build do
66
66
  use LoggingMiddleware
@@ -84,7 +84,7 @@ Convert a block into middleware using `Middleware.for`:
84
84
 
85
85
  ``` ruby
86
86
  middleware = Protocol::HTTP::Middleware.for do |request|
87
- if request.path == '/health'
87
+ if request.path == "/health"
88
88
  Protocol::HTTP::Response[200, {}, ["OK"]]
89
89
  else
90
90
  # This would normally delegate, but this example doesn't have a delegate
@@ -141,7 +141,7 @@ class AuthenticationMiddleware < Protocol::HTTP::Middleware
141
141
  end
142
142
 
143
143
  def call(request)
144
- auth_header = request.headers['authorization']
144
+ auth_header = request.headers["authorization"]
145
145
 
146
146
  unless auth_header == "Bearer #{@api_key}"
147
147
  return Protocol::HTTP::Response[401, {}, ["Unauthorized"]]
@@ -166,8 +166,8 @@ class ContentTypeMiddleware < Protocol::HTTP::Middleware
166
166
  response = super
167
167
 
168
168
  # Add content-type header if not present
169
- unless response.headers.include?('content-type')
170
- response.headers['content-type'] = 'text/plain'
169
+ unless response.headers.include?("content-type")
170
+ response.headers["content-type"] = "text/plain"
171
171
  end
172
172
 
173
173
  response
@@ -189,7 +189,7 @@ describe MyMiddleware do
189
189
  end
190
190
 
191
191
  it "closes properly" do
192
- expect { app.close }.not.to raise_exception
192
+ expect{app.close}.not.to raise_exception
193
193
  end
194
194
  end
195
195
  ```
data/context/streaming.md CHANGED
@@ -9,15 +9,15 @@ The request and response body work independently of each other can stream data i
9
9
  ```ruby
10
10
  #!/usr/bin/env ruby
11
11
 
12
- require 'async'
13
- require 'async/http/client'
14
- require 'async/http/server'
15
- require 'async/http/endpoint'
12
+ require "async"
13
+ require "async/http/client"
14
+ require "async/http/server"
15
+ require "async/http/endpoint"
16
16
 
17
- require 'protocol/http/body/stream'
18
- require 'protocol/http/body/writable'
17
+ require "protocol/http/body/stream"
18
+ require "protocol/http/body/writable"
19
19
 
20
- endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000')
20
+ endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000")
21
21
 
22
22
  Async do
23
23
  server = Async::HTTP::Server.for(endpoint) do |request|
@@ -74,15 +74,15 @@ While WebSockets can work on the above streaming interface, it's a bit more conv
74
74
  ```ruby
75
75
  #!/usr/bin/env ruby
76
76
 
77
- require 'async'
78
- require 'async/http/client'
79
- require 'async/http/server'
80
- require 'async/http/endpoint'
77
+ require "async"
78
+ require "async/http/client"
79
+ require "async/http/server"
80
+ require "async/http/endpoint"
81
81
 
82
- require 'protocol/http/body/stream'
83
- require 'protocol/http/body/writable'
82
+ require "protocol/http/body/stream"
83
+ require "protocol/http/body/writable"
84
84
 
85
- endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000')
85
+ endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000")
86
86
 
87
87
  Async do
88
88
  server = Async::HTTP::Server.for(endpoint) do |request|
@@ -11,7 +11,7 @@ While basic query parameter encoding follows the `application/x-www-form-urlenco
11
11
  ## Basic Query Parameter Parsing
12
12
 
13
13
  ``` ruby
14
- require 'protocol/http/url'
14
+ require "protocol/http/url"
15
15
 
16
16
  # Parse query parameters from a URL:
17
17
  reference = Protocol::HTTP::Reference.parse("/search?q=ruby&category=programming&page=2")
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2025, by Samuel Williams.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "middleware"
7
7
 
@@ -21,7 +21,7 @@ module Protocol
21
21
  # The default wrappers to use for decoding content.
22
22
  DEFAULT_WRAPPERS = {
23
23
  "gzip" => Body::Inflate.method(:for),
24
- "identity" => ->(body) {body}, # Identity means no encoding
24
+ "identity" => ->(body){body}, # Identity means no encoding
25
25
 
26
26
  # There is no point including this:
27
27
  # 'identity' => ->(body){body},
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2025, by Samuel Williams.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
5
  # Copyright, 2020, by Bryan Powell.
6
6
  # Copyright, 2025, by William T. Nelson.
7
7
 
@@ -90,7 +90,7 @@ module Protocol
90
90
 
91
91
  # The length of the body. Will compute and cache the length of the body, if it was not provided.
92
92
  def length
93
- @length ||= @chunks.inject(0) {|sum, chunk| sum + chunk.bytesize}
93
+ @length ||= @chunks.inject(0){|sum, chunk| sum + chunk.bytesize}
94
94
  end
95
95
 
96
96
  # @returns [Boolean] if the body is empty.
@@ -26,5 +26,18 @@ module Protocol
26
26
  # @attribute [String] key The header key that was duplicated.
27
27
  attr :key
28
28
  end
29
+
30
+ # Raised when an invalid trailer header is encountered in headers.
31
+ class InvalidTrailerError < Error
32
+ include BadRequest
33
+
34
+ # @parameter key [String] The trailer key that is invalid.
35
+ def initialize(key)
36
+ super("Invalid trailer key: #{key.inspect}")
37
+ end
38
+
39
+ # @attribute [String] key The trailer key that is invalid.
40
+ attr :key
41
+ end
29
42
  end
30
43
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2025, by Samuel Williams.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "multiple"
7
7
  require_relative "../cookie"
@@ -24,6 +24,11 @@ module Protocol
24
24
  cookies.map{|cookie| [cookie.name, cookie]}.to_h
25
25
  end
26
26
 
27
+ # Serializes the `cookie` header by joining individual cookie strings with semicolons.
28
+ def to_s
29
+ join(";")
30
+ end
31
+
27
32
  # Whether this header is acceptable in HTTP trailers.
28
33
  # Cookie headers should not appear in trailers as they contain state information needed early in processing.
29
34
  # @returns [Boolean] `false`, as cookie headers are needed during initial request processing.
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require_relative "split"
7
+
8
+ module Protocol
9
+ module HTTP
10
+ module Header
11
+ # Represents generic or custom headers that can be used in trailers.
12
+ #
13
+ # This class is used as the default policy for headers not explicitly defined in the POLICY hash.
14
+ #
15
+ # It allows generic headers to be used in HTTP trailers, which is important for:
16
+ # - Custom application headers.
17
+ # - gRPC status headers (grpc-status, grpc-message).
18
+ # - Headers used by proxies and middleware.
19
+ # - Future HTTP extensions.
20
+ class Generic < Split
21
+ # Whether this header is acceptable in HTTP trailers.
22
+ # Generic headers are allowed in trailers by default to support extensibility.
23
+ # @returns [Boolean] `true`, generic headers are allowed in trailers.
24
+ def self.trailer?
25
+ true
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2024, by Samuel Williams.
4
+ # Copyright, 2024-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "split"
7
7
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "split"
7
7
  require_relative "../quoted_string"
@@ -105,32 +105,32 @@ module Protocol
105
105
 
106
106
  # @returns [Boolean] whether the `chunked` encoding is accepted.
107
107
  def chunked?
108
- self.any? {|value| value.start_with?(CHUNKED)}
108
+ self.any?{|value| value.start_with?(CHUNKED)}
109
109
  end
110
110
 
111
111
  # @returns [Boolean] whether the `gzip` encoding is accepted.
112
112
  def gzip?
113
- self.any? {|value| value.start_with?(GZIP)}
113
+ self.any?{|value| value.start_with?(GZIP)}
114
114
  end
115
115
 
116
116
  # @returns [Boolean] whether the `deflate` encoding is accepted.
117
117
  def deflate?
118
- self.any? {|value| value.start_with?(DEFLATE)}
118
+ self.any?{|value| value.start_with?(DEFLATE)}
119
119
  end
120
120
 
121
121
  # @returns [Boolean] whether the `compress` encoding is accepted.
122
122
  def compress?
123
- self.any? {|value| value.start_with?(COMPRESS)}
123
+ self.any?{|value| value.start_with?(COMPRESS)}
124
124
  end
125
125
 
126
126
  # @returns [Boolean] whether the `identity` encoding is accepted.
127
127
  def identity?
128
- self.any? {|value| value.start_with?(IDENTITY)}
128
+ self.any?{|value| value.start_with?(IDENTITY)}
129
129
  end
130
130
 
131
131
  # @returns [Boolean] whether trailers are accepted.
132
132
  def trailers?
133
- self.any? {|value| value.start_with?(TRAILERS)}
133
+ self.any?{|value| value.start_with?(TRAILERS)}
134
134
  end
135
135
 
136
136
  # Whether this header is acceptable in HTTP trailers.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2025, by Samuel Williams.
4
+ # Copyright, 2018-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "error"
7
7
 
@@ -20,6 +20,7 @@ require_relative "header/priority"
20
20
  require_relative "header/trailer"
21
21
  require_relative "header/server_timing"
22
22
  require_relative "header/digest"
23
+ require_relative "header/generic"
23
24
 
24
25
  require_relative "header/accept"
25
26
  require_relative "header/accept_charset"
@@ -158,7 +159,26 @@ module Protocol
158
159
  return trailer(&block)
159
160
  end
160
161
 
162
+ # Enumerate all the headers in the header, if there are any.
163
+ #
164
+ # @yields {|key, value| ...} The header key and value.
165
+ # @parameter key [String] The header key.
166
+ # @parameter value [String] The raw header value.
167
+ def header(&block)
168
+ return to_enum(:header) unless block_given?
169
+
170
+ if @tail and @tail < @fields.size
171
+ @fields.first(@tail).each(&block)
172
+ else
173
+ @fields.each(&block)
174
+ end
175
+ end
176
+
161
177
  # Enumerate all headers in the trailer, if there are any.
178
+ #
179
+ # @yields {|key, value| ...} The header key and value.
180
+ # @parameter key [String] The header key.
181
+ # @parameter value [String] The raw header value.
162
182
  def trailer(&block)
163
183
  return to_enum(:trailer) unless block_given?
164
184
 
@@ -227,9 +247,18 @@ module Protocol
227
247
  #
228
248
  # @parameter key [String] the header key.
229
249
  # @parameter value [String] the header value to assign.
230
- def add(key, value)
250
+ # @parameter trailer [Boolean] whether this header is being added as a trailer.
251
+ def add(key, value, trailer: self.trailer?)
231
252
  value = value.to_s
232
253
 
254
+ if trailer
255
+ policy = @policy[key.downcase]
256
+
257
+ if !policy or !policy.trailer?
258
+ raise InvalidTrailerError, key
259
+ end
260
+ end
261
+
233
262
  if @indexed
234
263
  merge_into(@indexed, key.downcase, value)
235
264
  end
@@ -279,7 +308,7 @@ module Protocol
279
308
  # @parameter key [String] The header key.
280
309
  # @returns [String | Array | Object] The header value.
281
310
  def [] key
282
- to_h[key]
311
+ self.to_h[key]
283
312
  end
284
313
 
285
314
  # Merge the headers into this instance.
@@ -297,6 +326,10 @@ module Protocol
297
326
  end
298
327
 
299
328
  # The policy for various headers, including how they are merged and normalized.
329
+ #
330
+ # A policy may be `false` to indicate that the header may only be specified once and is a simple string.
331
+ #
332
+ # Otherwise, the policy is a class which implements the header normalization logic, including `parse` and `coerce` class methods.
300
333
  POLICY = {
301
334
  # Headers which may only be specified once:
302
335
  "content-disposition" => false,
@@ -315,8 +348,11 @@ module Protocol
315
348
  "user-agent" => false,
316
349
  "trailer" => Header::Trailer,
317
350
 
318
- # Custom headers:
351
+ # Connection handling:
319
352
  "connection" => Header::Connection,
353
+ "upgrade" => Header::Split,
354
+
355
+ # Cache handling:
320
356
  "cache-control" => Header::CacheControl,
321
357
  "te" => Header::TE,
322
358
  "vary" => Header::Vary,
@@ -354,16 +390,21 @@ module Protocol
354
390
 
355
391
  # Accept headers:
356
392
  "accept" => Header::Accept,
393
+ "accept-ranges" => Header::Split,
357
394
  "accept-charset" => Header::AcceptCharset,
358
395
  "accept-encoding" => Header::AcceptEncoding,
359
396
  "accept-language" => Header::AcceptLanguage,
360
397
 
398
+ # Content negotiation headers:
399
+ "content-encoding" => Header::Split,
400
+ "content-range" => false,
401
+
361
402
  # Performance headers:
362
403
  "server-timing" => Header::ServerTiming,
363
404
 
364
405
  # Content integrity headers:
365
406
  "digest" => Header::Digest,
366
- }.tap{|hash| hash.default = Split}
407
+ }.tap{|hash| hash.default = Header::Generic}
367
408
 
368
409
  # Delete all header values for the given key, and return the merged value.
369
410
  #
@@ -403,28 +444,14 @@ module Protocol
403
444
  # @parameter hash [Hash] The hash to merge into.
404
445
  # @parameter key [String] The header key.
405
446
  # @parameter value [String] The raw header value.
406
- protected def merge_into(hash, key, value, trailer = @tail)
447
+ protected def merge_into(hash, key, value)
407
448
  if policy = @policy[key]
408
- # Check if we're adding to trailers and this header is allowed:
409
- if trailer && !policy.trailer?
410
- return false
411
- end
412
-
413
449
  if current_value = hash[key]
414
450
  current_value << value
415
451
  else
416
- if policy.respond_to?(:parse)
417
- hash[key] = policy.parse(value)
418
- else
419
- hash[key] = policy.new(value)
420
- end
452
+ hash[key] = policy.parse(value)
421
453
  end
422
454
  else
423
- # By default, headers are not allowed in trailers:
424
- if trailer
425
- return false
426
- end
427
-
428
455
  if hash.key?(key)
429
456
  raise DuplicateHeaderError, key
430
457
  end
@@ -435,16 +462,19 @@ module Protocol
435
462
 
436
463
  # Compute a hash table of headers, where the keys are normalized to lower case and the values are normalized according to the policy for that header.
437
464
  #
465
+ # This will enforce policy rules, such as merging multiple headers into arrays, or raising errors for duplicate headers.
466
+ #
438
467
  # @returns [Hash] A hash table of `{key, value}` pairs.
439
468
  def to_h
440
469
  unless @indexed
441
- @indexed = {}
470
+ indexed = {}
442
471
 
443
- @fields.each_with_index do |(key, value), index|
444
- trailer = (@tail && index >= @tail)
445
-
446
- merge_into(@indexed, key.downcase, value, trailer)
472
+ @fields.each do |key, value|
473
+ merge_into(indexed, key.downcase, value)
447
474
  end
475
+
476
+ # Deferred assignment so that exceptions in `merge_into` don't leave us in an inconsistent state:
477
+ @indexed = indexed
448
478
  end
449
479
 
450
480
  return @indexed
@@ -465,7 +495,7 @@ module Protocol
465
495
  def == other
466
496
  case other
467
497
  when Hash
468
- to_h == other
498
+ self.to_h == other
469
499
  when Headers
470
500
  @fields == other.fields
471
501
  else
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2024, by Samuel Williams.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "../middleware"
7
7
 
@@ -25,7 +25,7 @@ module Protocol
25
25
  # @parameter options [Hash] The options to pass to the middleware constructor.
26
26
  # @parameter block [Proc] The block to pass to the middleware constructor.
27
27
  def use(middleware, *arguments, **options, &block)
28
- @use << proc {|app| middleware.new(app, *arguments, **options, &block)}
28
+ @use << proc{|app| middleware.new(app, *arguments, **options, &block)}
29
29
  end
30
30
 
31
31
  # Specify the (default) middleware application to use.
@@ -39,7 +39,7 @@ module Protocol
39
39
  #
40
40
  # @returns [Middleware] The application.
41
41
  def to_app
42
- @use.reverse.inject(@app) {|app, use| use.call(app)}
42
+ @use.reverse.inject(@app){|app, use| use.call(app)}
43
43
  end
44
44
  end
45
45
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  module Protocol
7
7
  module HTTP
@@ -44,4 +44,4 @@ module Protocol
44
44
  end
45
45
  end
46
46
  end
47
- end
47
+ end
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Protocol
7
7
  module HTTP
8
- VERSION = "0.56.1"
8
+ VERSION = "0.58.0"
9
9
  end
10
10
  end
data/license.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MIT License
2
2
 
3
- Copyright, 2018-2025, by Samuel Williams.
3
+ Copyright, 2018-2026, by Samuel Williams.
4
4
  Copyright, 2019, by Yuta Iwama.
5
5
  Copyright, 2020, by Olle Jonsson.
6
6
  Copyright, 2020, by Bryan Powell.
data/readme.md CHANGED
@@ -30,6 +30,18 @@ Please see the [project documentation](https://socketry.github.io/protocol-http/
30
30
 
31
31
  Please see the [project releases](https://socketry.github.io/protocol-http/releases/index) for all releases.
32
32
 
33
+ ### v0.58.0
34
+
35
+ - Move trailer validation to `Headers#add` method to ensure all additions are checked at the time of addition as this is a hard requirement.
36
+ - Introduce `Headers#header` method to enumerate only the main headers, excluding trailers. This can be used after invoking `Headers#trailer!` to avoid race conditions.
37
+ - Fix `Headers#to_h` so that indexed headers are not left in an inconsistent state if errors occur during processing.
38
+
39
+ ### v0.57.0
40
+
41
+ - Always use `#parse` when parsing header values from strings to ensure proper normalization and validation.
42
+ - Introduce `Protocol::HTTP::InvalidTrailerError` which is raised when a trailer header is not allowed by the current policy.
43
+ - **Breaking**: `Headers#each` now yields parsed values according to the current policy. For the previous behaviour, use `Headers#fields`.
44
+
33
45
  ### v0.56.0
34
46
 
35
47
  - Introduce `Header::*.parse(value)` which parses a raw header value string into a header instance.
@@ -77,15 +89,6 @@ Please see the [project releases](https://socketry.github.io/protocol-http/relea
77
89
 
78
90
  - Add support for parsing `accept`, `accept-charset`, `accept-encoding` and `accept-language` headers into structured values.
79
91
 
80
- ### v0.46.0
81
-
82
- - Add support for `priority:` header.
83
-
84
- ### v0.33.0
85
-
86
- - Clarify behaviour of streaming bodies and copy `Protocol::Rack::Body::Streaming` to `Protocol::HTTP::Body::Streamable`.
87
- - Copy `Async::HTTP::Body::Writable` to `Protocol::HTTP::Body::Writable`.
88
-
89
92
  ## See Also
90
93
 
91
94
  - [protocol-http1](https://github.com/socketry/protocol-http1) — HTTP/1 client/server implementation using this
data/releases.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Releases
2
2
 
3
+ ## v0.58.0
4
+
5
+ - Move trailer validation to `Headers#add` method to ensure all additions are checked at the time of addition as this is a hard requirement.
6
+ - Introduce `Headers#header` method to enumerate only the main headers, excluding trailers. This can be used after invoking `Headers#trailer!` to avoid race conditions.
7
+ - Fix `Headers#to_h` so that indexed headers are not left in an inconsistent state if errors occur during processing.
8
+
9
+ ## v0.57.0
10
+
11
+ - Always use `#parse` when parsing header values from strings to ensure proper normalization and validation.
12
+ - Introduce `Protocol::HTTP::InvalidTrailerError` which is raised when a trailer header is not allowed by the current policy.
13
+ - **Breaking**: `Headers#each` now yields parsed values according to the current policy. For the previous behaviour, use `Headers#fields`.
14
+
3
15
  ## v0.56.0
4
16
 
5
17
  - Introduce `Header::*.parse(value)` which parses a raw header value string into a header instance.
@@ -57,13 +69,13 @@ module GRPCMessage
57
69
  end
58
70
 
59
71
  GRPC_POLICY = Protocol::HTTP::Headers::POLICY.dup
60
- GRPC_POLICY['grpc-status'] = GRPCStatus
61
- GRPC_POLICY['grpc-message'] = GRPCMessage
72
+ GRPC_POLICY["grpc-status"] = GRPCStatus
73
+ GRPC_POLICY["grpc-message"] = GRPCMessage
62
74
 
63
75
  # Reinterpret the headers using the new policy:
64
76
  response.headers.policy = GRPC_POLICY
65
- response.headers['grpc-status'] # => 0
66
- response.headers['grpc-message'] # => "OK"
77
+ response.headers["grpc-status"] # => 0
78
+ response.headers["grpc-message"] # => "OK"
67
79
  ```
68
80
 
69
81
  ## v0.53.0
@@ -117,6 +129,7 @@ client.get("/", headers: {"accept" => "text/html"}, authority: "example.com")
117
129
  # Response keyword arguments:
118
130
  def call(request)
119
131
  return Response[200, headers: {"content-Type" => "text/html"}, body: "Hello, World!"]
132
+ end
120
133
  ```
121
134
 
122
135
  ### Interim Response Handling
@@ -127,7 +140,12 @@ On the client side, you can pass a callback using the `interim_response` keyword
127
140
 
128
141
  ``` ruby
129
142
  client = ...
130
- response = client.get("/index", interim_response: proc{|status, headers| ...})
143
+
144
+ interim_response = proc do |status, headers|
145
+ puts "Received interim response: #{status} -> #{headers.inspect}"
146
+ end
147
+
148
+ response = client.get("/index", interim_response: interim_response)
131
149
  ```
132
150
 
133
151
  On the server side, you can send an interim response using the `#send_interim_response` method:
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-http
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.56.1
4
+ version: 0.58.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -94,6 +94,7 @@ files:
94
94
  - lib/protocol/http/header/digest.rb
95
95
  - lib/protocol/http/header/etag.rb
96
96
  - lib/protocol/http/header/etags.rb
97
+ - lib/protocol/http/header/generic.rb
97
98
  - lib/protocol/http/header/multiple.rb
98
99
  - lib/protocol/http/header/priority.rb
99
100
  - lib/protocol/http/header/server_timing.rb
@@ -134,7 +135,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
134
135
  - !ruby/object:Gem::Version
135
136
  version: '0'
136
137
  requirements: []
137
- rubygems_version: 3.6.9
138
+ rubygems_version: 4.0.3
138
139
  specification_version: 4
139
140
  summary: Provides abstractions to handle HTTP protocols.
140
141
  test_files: []
metadata.gz.sig CHANGED
Binary file