protocol-http 0.52.0 → 0.54.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/context/headers.md +94 -0
  4. data/context/index.yaml +3 -0
  5. data/lib/protocol/http/body/buffered.rb +4 -2
  6. data/lib/protocol/http/body/completable.rb +18 -1
  7. data/lib/protocol/http/body/deflate.rb +13 -2
  8. data/lib/protocol/http/body/digestable.rb +11 -1
  9. data/lib/protocol/http/body/file.rb +6 -2
  10. data/lib/protocol/http/body/head.rb +7 -0
  11. data/lib/protocol/http/body/inflate.rb +1 -1
  12. data/lib/protocol/http/body/rewindable.rb +11 -1
  13. data/lib/protocol/http/body/stream.rb +15 -0
  14. data/lib/protocol/http/body/streamable.rb +18 -2
  15. data/lib/protocol/http/body/writable.rb +3 -3
  16. data/lib/protocol/http/header/accept.rb +6 -0
  17. data/lib/protocol/http/header/authorization.rb +7 -1
  18. data/lib/protocol/http/header/connection.rb +7 -0
  19. data/lib/protocol/http/header/cookie.rb +7 -0
  20. data/lib/protocol/http/header/date.rb +8 -1
  21. data/lib/protocol/http/header/digest.rb +70 -0
  22. data/lib/protocol/http/header/etag.rb +7 -0
  23. data/lib/protocol/http/header/multiple.rb +7 -0
  24. data/lib/protocol/http/header/server_timing.rb +92 -0
  25. data/lib/protocol/http/header/split.rb +7 -0
  26. data/lib/protocol/http/header/te.rb +131 -0
  27. data/lib/protocol/http/header/trailer.rb +23 -0
  28. data/lib/protocol/http/header/transfer_encoding.rb +78 -0
  29. data/lib/protocol/http/headers.rb +55 -7
  30. data/lib/protocol/http/response.rb +1 -1
  31. data/lib/protocol/http/version.rb +1 -1
  32. data/readme.md +12 -0
  33. data/releases.md +54 -0
  34. data.tar.gz.sig +0 -0
  35. metadata +7 -2
  36. metadata.gz.sig +0 -0
  37. data/agent.md +0 -145
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e1927ee19fba11fbeb421268490fabcde86d7ef9ca5d472320b9aaad6763892
4
- data.tar.gz: fe4c304a05ce7c3637ad460e976dbbf114c22dfb49fe425f9a2dbde1cf280d35
3
+ metadata.gz: c76db95baeca082a769340ab2a29ee5948bb837894e55039c082653453aa4f4f
4
+ data.tar.gz: 868cdcc4a21786c640c8c06e7892bfd6ef19f81670a90274692cfadef10a0c39
5
5
  SHA512:
6
- metadata.gz: 97d19c21e2d67d5870077b7ae2856438a1e4314934f01bc93148f63be4db4d55ffee18f45f66ed7b0665678dd8b0deb4718cda92fd9334f198bb2fa335bb297f
7
- data.tar.gz: 674bd9d94d4efacde5d42d8c02dd8876f7f8f96290a26051343103c11f6e68ae54678746bf6d034071c0bba3a5dd7263affecc8c3e86be9ec1766faefc93e50a
6
+ metadata.gz: acc4afb3f7caba7ec2d2611ab72dce9c954e88e8dfe72645645e442d8116909c3f228b22c2d460633d429510ffff5f6f4713bfff751f18d4873db167e773fcbe
7
+ data.tar.gz: ef0e92bac69dba33417d074c24ce6d2f2009e19e1d81d317eecaf945817f3c7fb0775052048ca24df32b3f39c15f8de83ca45054669e2d89b5bf3b5b445c7cf1
checksums.yaml.gz.sig CHANGED
Binary file
@@ -0,0 +1,94 @@
1
+ # Headers
2
+
3
+ This guide explains how to work with HTTP headers using `protocol-http`.
4
+
5
+ ## Core Concepts
6
+
7
+ `protocol-http` provides several core concepts for working with HTTP headers:
8
+
9
+ - A {ruby Protocol::HTTP::Headers} class which represents a collection of HTTP headers with built-in security and policy features.
10
+ - Header-specific classes like {ruby Protocol::HTTP::Header::Accept} and {ruby Protocol::HTTP::Header::Authorization} which provide specialized parsing and formatting.
11
+ - Trailer security validation to prevent HTTP request smuggling attacks.
12
+
13
+ ## Usage
14
+
15
+ The {Protocol::HTTP::Headers} class provides a comprehensive interface for creating and manipulating HTTP headers:
16
+
17
+ ```ruby
18
+ require 'protocol/http'
19
+
20
+ headers = Protocol::HTTP::Headers.new
21
+ headers.add('content-type', 'text/html')
22
+ headers.add('set-cookie', 'session=abc123')
23
+
24
+ # Access headers
25
+ content_type = headers['content-type'] # => "text/html"
26
+
27
+ # Check if header exists
28
+ headers.include?('content-type') # => true
29
+ ```
30
+
31
+ ### Header Policies
32
+
33
+ Different header types have different behaviors for merging, validation, and trailer handling:
34
+
35
+ ```ruby
36
+ # Some headers can be specified multiple times
37
+ headers.add('set-cookie', 'first=value1')
38
+ headers.add('set-cookie', 'second=value2')
39
+
40
+ # Others are singletons and will raise errors if duplicated
41
+ headers.add('content-length', '100')
42
+ # headers.add('content-length', '200') # Would raise DuplicateHeaderError
43
+ ```
44
+
45
+ ### Structured Headers
46
+
47
+ Some headers have specialized classes for parsing and formatting:
48
+
49
+ ```ruby
50
+ # Accept header with media ranges
51
+ accept = Protocol::HTTP::Header::Accept.new('text/html,application/json;q=0.9')
52
+ media_ranges = accept.media_ranges
53
+
54
+ # Authorization header
55
+ auth = Protocol::HTTP::Header::Authorization.basic('username', 'password')
56
+ # => "Basic dXNlcm5hbWU6cGFzc3dvcmQ="
57
+ ```
58
+
59
+ ### Trailer Security
60
+
61
+ HTTP trailers are headers that appear after the message body. For security reasons, only certain headers are allowed in trailers:
62
+
63
+ ```ruby
64
+ # Working with trailers
65
+ headers = Protocol::HTTP::Headers.new([
66
+ ['content-type', 'text/html'],
67
+ ['content-length', '1000']
68
+ ])
69
+
70
+ # Start trailer section
71
+ headers.trailer!
72
+
73
+ # These will be allowed (safe metadata)
74
+ headers.add('etag', '"12345"')
75
+ headers.add('date', Time.now.httpdate)
76
+
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
80
+ ```
81
+
82
+ The trailer security system prevents HTTP request smuggling by restricting which headers can appear in trailers:
83
+
84
+ **Allowed headers** (return `true` for `trailer?`):
85
+ - `date` - Response generation timestamps.
86
+ - `digest` - Content integrity verification.
87
+ - `etag` - Cache validation tags.
88
+ - `server-timing` - Performance metrics.
89
+
90
+ **Forbidden headers** (return `false` for `trailer?`):
91
+ - `authorization` - Prevents credential leakage.
92
+ - `connection`, `te`, `transfer-encoding` - Hop-by-hop headers that control connection behavior.
93
+ - `cookie`, `set-cookie` - State information needed during initial processing.
94
+ - `accept` - Content negotiation must occur before response generation.
data/context/index.yaml CHANGED
@@ -14,6 +14,9 @@ files:
14
14
  title: Message Body
15
15
  description: This guide explains how to work with HTTP request and response message
16
16
  bodies using `Protocol::HTTP::Body` classes.
17
+ - path: headers.md
18
+ title: Headers
19
+ description: This guide explains how to work with HTTP headers using `protocol-http`.
17
20
  - path: middleware.md
18
21
  title: Middleware
19
22
  description: This guide explains how to build and use HTTP middleware with `Protocol::HTTP::Middleware`.
@@ -153,8 +153,10 @@ module Protocol
153
153
  #
154
154
  # @returns [String] a string representation of the buffered body.
155
155
  def inspect
156
- if @chunks
157
- "\#<#{self.class} #{@chunks.size} chunks, #{self.length} bytes>"
156
+ if @chunks and @chunks.size > 0
157
+ "#<#{self.class} #{@index}/#{@chunks.size} chunks, #{self.length} bytes>"
158
+ else
159
+ "#<#{self.class} empty>"
158
160
  end
159
161
  end
160
162
  end
@@ -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-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "wrapper"
7
7
 
@@ -53,6 +53,23 @@ module Protocol
53
53
 
54
54
  super
55
55
  end
56
+
57
+ # Convert the body to a hash suitable for serialization.
58
+ #
59
+ # @returns [Hash] The body as a hash.
60
+ def as_json(...)
61
+ super.merge(
62
+ callback: @callback&.to_s
63
+ )
64
+ end
65
+
66
+ # Inspect the completable body.
67
+ #
68
+ # @returns [String] a string representation of the completable body.
69
+ def inspect
70
+ callback_status = @callback ? "callback pending" : "callback completed"
71
+ return "#{super} | #<#{self.class} #{callback_status}>"
72
+ end
56
73
  end
57
74
  end
58
75
  end
@@ -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-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "wrapper"
7
7
 
@@ -75,11 +75,22 @@ module Protocol
75
75
  end
76
76
  end
77
77
 
78
+ # Convert the body to a hash suitable for serialization.
79
+ #
80
+ # @returns [Hash] The body as a hash.
81
+ def as_json(...)
82
+ super.merge(
83
+ input_length: @input_length,
84
+ output_length: @output_length,
85
+ compression_ratio: (ratio * 100).round(2)
86
+ )
87
+ end
88
+
78
89
  # Inspect the body, including the compression ratio.
79
90
  #
80
91
  # @returns [String] a string representation of the body.
81
92
  def inspect
82
- "#{super} | \#<#{self.class} #{(ratio*100).round(2)}%>"
93
+ "#{super} | #<#{self.class} #{(ratio*100).round(2)}%>"
83
94
  end
84
95
  end
85
96
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2020-2024, by Samuel Williams.
4
+ # Copyright, 2020-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "wrapper"
7
7
 
@@ -64,6 +64,16 @@ module Protocol
64
64
  return nil
65
65
  end
66
66
  end
67
+
68
+ # Convert the body to a hash suitable for serialization.
69
+ #
70
+ # @returns [Hash] The body as a hash.
71
+ def as_json(...)
72
+ super.merge(
73
+ digest_class: @digest.class.name,
74
+ callback: @callback&.to_s
75
+ )
76
+ end
67
77
  end
68
78
  end
69
79
  end
@@ -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-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "readable"
7
7
 
@@ -135,7 +135,11 @@ module Protocol
135
135
  #
136
136
  # @returns [String] a string representation of the file body.
137
137
  def inspect
138
- "\#<#{self.class} file=#{@file.inspect} offset=#{@offset} remaining=#{@remaining}>"
138
+ if @offset > 0
139
+ "#<#{self.class} #{@file.inspect} +#{@offset}, #{@remaining} bytes remaining>"
140
+ else
141
+ "#<#{self.class} #{@file.inspect}, #{@remaining} bytes remaining>"
142
+ end
139
143
  end
140
144
  end
141
145
  end
@@ -53,6 +53,13 @@ module Protocol
53
53
  def length
54
54
  @length
55
55
  end
56
+
57
+ # Inspect the head body.
58
+ #
59
+ # @returns [String] a string representation of the head body.
60
+ def inspect
61
+ "#<#{self.class} #{@length} bytes (empty)>"
62
+ end
56
63
  end
57
64
  end
58
65
  end
@@ -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-2025, by Samuel Williams.
5
5
 
6
6
  require "zlib"
7
7
 
@@ -82,11 +82,21 @@ module Protocol
82
82
  true
83
83
  end
84
84
 
85
+ # Convert the body to a hash suitable for serialization.
86
+ #
87
+ # @returns [Hash] The body as a hash.
88
+ def as_json(...)
89
+ super.merge(
90
+ index: @index,
91
+ chunks: @chunks.size
92
+ )
93
+ end
94
+
85
95
  # Inspect the rewindable body.
86
96
  #
87
97
  # @returns [String] a string representation of the body.
88
98
  def inspect
89
- "\#<#{self.class} #{@index}/#{@chunks.size} chunks read>"
99
+ "#{super} | #<#{self.class} #{@index}/#{@chunks.size} chunks read>"
90
100
  end
91
101
  end
92
102
  end
@@ -386,6 +386,21 @@ module Protocol
386
386
  @closed
387
387
  end
388
388
 
389
+ # Inspect the stream.
390
+ #
391
+ # @returns [String] a string representation of the stream.
392
+ def inspect
393
+ buffer_info = @buffer ? "#{@buffer.bytesize} bytes buffered" : "no buffer"
394
+
395
+ status = []
396
+ status << "closed" if @closed
397
+ status << "read-closed" if @closed_read
398
+
399
+ status_info = status.empty? ? "open" : status.join(", ")
400
+
401
+ return "#<#{self.class} #{buffer_info}, #{status_info}>"
402
+ end
403
+
389
404
  # @returns [Boolean] Whether there are any output chunks remaining.
390
405
  def empty?
391
406
  @output.empty?
@@ -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-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "readable"
7
7
  require_relative "writable"
@@ -147,7 +147,23 @@ module Protocol
147
147
  #
148
148
  # @parameter error [Exception | Nil] The error that caused this stream to be closed, if any.
149
149
  def close_output(error = nil)
150
- @output&.close(error)
150
+ if output = @output
151
+ @output = nil
152
+ output.close(error)
153
+ end
154
+ end
155
+
156
+ # Inspect the streaming body.
157
+ #
158
+ # @returns [String] a string representation of the streaming body.
159
+ def inspect
160
+ if @block
161
+ "#<#{self.class} block available, not consumed>"
162
+ elsif @output
163
+ "#<#{self.class} block consumed, output active>"
164
+ else
165
+ "#<#{self.class} block consumed, output closed>"
166
+ end
151
167
  end
152
168
  end
153
169
 
@@ -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-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "readable"
7
7
 
@@ -166,9 +166,9 @@ module Protocol
166
166
  # @returns [String] A string representation of the body.
167
167
  def inspect
168
168
  if @error
169
- "\#<#{self.class} #{@count} chunks written, #{status}, error=#{@error}>"
169
+ "#<#{self.class} #{@count} chunks written, #{status}, error=#{@error}>"
170
170
  else
171
- "\#<#{self.class} #{@count} chunks written, #{status}>"
171
+ "#<#{self.class} #{@count} chunks written, #{status}>"
172
172
  end
173
173
  end
174
174
 
@@ -92,6 +92,12 @@ module Protocol
92
92
  join(",")
93
93
  end
94
94
 
95
+ # Whether this header is acceptable in HTTP trailers.
96
+ # @returns [Boolean] `false`, as Accept headers are used for response content negotiation.
97
+ def self.trailer?
98
+ false
99
+ end
100
+
95
101
  # Parse the `accept` header.
96
102
  #
97
103
  # @returns [Array(Charset)] the list of content types and their associated parameters.
@@ -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-2025, by Samuel Williams.
5
5
  # Copyright, 2024, by Earlopain.
6
6
 
7
7
  module Protocol
@@ -34,6 +34,12 @@ module Protocol
34
34
  "Basic #{strict_base64_encoded}"
35
35
  )
36
36
  end
37
+
38
+ # Whether this header is acceptable in HTTP trailers.
39
+ # @returns [Boolean] `false`, as authorization headers are used for request authentication.
40
+ def self.trailer?
41
+ false
42
+ end
37
43
  end
38
44
  end
39
45
  end
@@ -50,6 +50,13 @@ module Protocol
50
50
  def upgrade?
51
51
  self.include?(UPGRADE)
52
52
  end
53
+
54
+ # Whether this header is acceptable in HTTP trailers.
55
+ # Connection headers control the current connection and must not appear in trailers.
56
+ # @returns [Boolean] `false`, as connection headers are hop-by-hop and forbidden in trailers.
57
+ def self.trailer?
58
+ false
59
+ end
53
60
  end
54
61
  end
55
62
  end
@@ -23,6 +23,13 @@ module Protocol
23
23
 
24
24
  cookies.map{|cookie| [cookie.name, cookie]}.to_h
25
25
  end
26
+
27
+ # Whether this header is acceptable in HTTP trailers.
28
+ # Cookie headers should not appear in trailers as they contain state information needed early in processing.
29
+ # @returns [Boolean] `false`, as cookie headers are needed during initial request processing.
30
+ def self.trailer?
31
+ false
32
+ end
26
33
  end
27
34
 
28
35
  # The `set-cookie` header sends cookies from the server to the user agent.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2024, by Samuel Williams.
4
+ # Copyright, 2023-2025, by Samuel Williams.
5
5
 
6
6
  require "time"
7
7
 
@@ -25,6 +25,13 @@ module Protocol
25
25
  def to_time
26
26
  ::Time.parse(self)
27
27
  end
28
+
29
+ # Whether this header is acceptable in HTTP trailers.
30
+ # Date headers can safely appear in trailers as they provide metadata about response generation.
31
+ # @returns [Boolean] `true`, as date headers are metadata that can be computed after response generation.
32
+ def self.trailer?
33
+ true
34
+ end
28
35
  end
29
36
  end
30
37
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require_relative "split"
7
+ require_relative "quoted_string"
8
+ require_relative "../error"
9
+
10
+ module Protocol
11
+ module HTTP
12
+ module Header
13
+ # The `digest` header provides a digest of the message body for integrity verification.
14
+ #
15
+ # This header allows servers to send cryptographic hashes of the response body, enabling clients to verify data integrity. Multiple digest algorithms can be specified, and the header is particularly useful as a trailer since the digest can only be computed after the entire message body is available.
16
+ #
17
+ # ## Examples
18
+ #
19
+ # ```ruby
20
+ # digest = Digest.new("sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=")
21
+ # digest << "md5=9bb58f26192e4ba00f01e2e7b136bbd8"
22
+ # puts digest.to_s
23
+ # # => "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=, md5=9bb58f26192e4ba00f01e2e7b136bbd8"
24
+ # ```
25
+ class Digest < Split
26
+ ParseError = Class.new(Error)
27
+
28
+ # https://tools.ietf.org/html/rfc3230#section-4.3.2
29
+ ENTRY = /\A(?<algorithm>[a-zA-Z0-9][a-zA-Z0-9\-]*)\s*=\s*(?<value>.*)\z/
30
+
31
+ # A single digest entry in the Digest header.
32
+ Entry = Struct.new(:algorithm, :value) do
33
+ # Create a new digest entry.
34
+ #
35
+ # @parameter algorithm [String] the digest algorithm (e.g., "sha-256", "md5").
36
+ # @parameter value [String] the base64-encoded or hex-encoded digest value.
37
+ def initialize(algorithm, value)
38
+ super(algorithm.downcase, value)
39
+ end
40
+
41
+ # Convert the entry to its string representation.
42
+ #
43
+ # @returns [String] the formatted digest string.
44
+ def to_s
45
+ "#{algorithm}=#{value}"
46
+ end
47
+ end
48
+
49
+ # Parse the `digest` header value into a list of digest entries.
50
+ #
51
+ # @returns [Array(Entry)] the list of digest entries with their algorithms and values.
52
+ def entries
53
+ self.map do |value|
54
+ if match = value.match(ENTRY)
55
+ Entry.new(match[:algorithm], match[:value])
56
+ else
57
+ raise ParseError.new("Could not parse digest value: #{value.inspect}")
58
+ end
59
+ end
60
+ end
61
+
62
+ # Whether this header is acceptable in HTTP trailers.
63
+ # @returns [Boolean] `true`, as digest headers contain integrity hashes that can only be calculated after the entire message body is available.
64
+ def self.trailer?
65
+ true
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -25,6 +25,13 @@ module Protocol
25
25
  def weak?
26
26
  self.start_with?("W/")
27
27
  end
28
+
29
+ # Whether this header is acceptable in HTTP trailers.
30
+ # ETag headers can safely appear in trailers as they provide cache validation metadata.
31
+ # @returns [Boolean] `true`, as ETag headers are metadata that can be computed after response generation.
32
+ def self.trailer?
33
+ true
34
+ end
28
35
  end
29
36
  end
30
37
  end
@@ -25,6 +25,13 @@ module Protocol
25
25
  def to_s
26
26
  join("\n")
27
27
  end
28
+
29
+ # Whether this header is acceptable in HTTP trailers.
30
+ # This is a base class for headers with multiple values, default is to disallow in trailers.
31
+ # @returns [Boolean] `false`, as most multiple-value headers should not appear in trailers by default.
32
+ def self.trailer?
33
+ false
34
+ end
28
35
  end
29
36
  end
30
37
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require_relative "split"
7
+ require_relative "quoted_string"
8
+ require_relative "../error"
9
+
10
+ module Protocol
11
+ module HTTP
12
+ module Header
13
+ # The `server-timing` header communicates performance metrics about the request-response cycle to the client.
14
+ #
15
+ # This header allows servers to send timing information about various server-side operations, which can be useful for performance monitoring and debugging. Each metric can include a name, optional duration, and optional description.
16
+ #
17
+ # ## Examples
18
+ #
19
+ # ```ruby
20
+ # server_timing = ServerTiming.new("db;dur=53.2")
21
+ # server_timing << "cache;dur=12.1;desc=\"Redis lookup\""
22
+ # puts server_timing.to_s
23
+ # # => "db;dur=53.2, cache;dur=12.1;desc=\"Redis lookup\""
24
+ # ```
25
+ class ServerTiming < Split
26
+ ParseError = Class.new(Error)
27
+
28
+ # https://www.w3.org/TR/server-timing/
29
+ METRIC = /\A(?<name>[a-zA-Z0-9][a-zA-Z0-9_\-]*)(;(?<parameters>.*))?\z/
30
+ PARAMETER = /(?<key>dur|desc)=((?<value>#{TOKEN})|(?<quoted_value>#{QUOTED_STRING}))/
31
+
32
+ # A single metric in the Server-Timing header.
33
+ Metric = Struct.new(:name, :duration, :description) do
34
+ # Create a new server timing metric.
35
+ #
36
+ # @parameter name [String] the name of the metric.
37
+ # @parameter duration [Float | Nil] the duration in milliseconds.
38
+ # @parameter description [String | Nil] the description of the metric.
39
+ def initialize(name, duration = nil, description = nil)
40
+ super(name, duration, description)
41
+ end
42
+
43
+ # Convert the metric to its string representation.
44
+ #
45
+ # @returns [String] the formatted metric string.
46
+ def to_s
47
+ result = name.dup
48
+ result << ";dur=#{duration}" if duration
49
+ result << ";desc=\"#{description}\"" if description
50
+ result
51
+ end
52
+ end
53
+
54
+ # Parse the `server-timing` header value into a list of metrics.
55
+ #
56
+ # @returns [Array(Metric)] the list of metrics with their names, durations, and descriptions.
57
+ def metrics
58
+ self.map do |value|
59
+ if match = value.match(METRIC)
60
+ name = match[:name]
61
+ parameters = match[:parameters] || ""
62
+
63
+ duration = nil
64
+ description = nil
65
+
66
+ parameters.scan(PARAMETER) do |key, value, quoted_value|
67
+ value = QuotedString.unquote(quoted_value) if quoted_value
68
+
69
+ case key
70
+ when "dur"
71
+ duration = value.to_f
72
+ when "desc"
73
+ description = value
74
+ end
75
+ end
76
+
77
+ Metric.new(name, duration, description)
78
+ else
79
+ raise ParseError.new("Could not parse server timing metric: #{value.inspect}")
80
+ end
81
+ end
82
+ end
83
+
84
+ # Whether this header is acceptable in HTTP trailers.
85
+ # @returns [Boolean] `true`, as server-timing headers contain performance metrics that are typically calculated during response generation.
86
+ def self.trailer?
87
+ true
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -40,6 +40,13 @@ module Protocol
40
40
  join(",")
41
41
  end
42
42
 
43
+ # Whether this header is acceptable in HTTP trailers.
44
+ # This is a base class for comma-separated headers, default is to disallow in trailers.
45
+ # @returns [Boolean] `false`, as most comma-separated headers should not appear in trailers by default.
46
+ def self.trailer?
47
+ false
48
+ end
49
+
43
50
  protected
44
51
 
45
52
  def reverse_find(&block)