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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/context/headers.md +94 -0
- data/context/index.yaml +3 -0
- data/lib/protocol/http/body/buffered.rb +4 -2
- data/lib/protocol/http/body/completable.rb +18 -1
- data/lib/protocol/http/body/deflate.rb +13 -2
- data/lib/protocol/http/body/digestable.rb +11 -1
- data/lib/protocol/http/body/file.rb +6 -2
- data/lib/protocol/http/body/head.rb +7 -0
- data/lib/protocol/http/body/inflate.rb +1 -1
- data/lib/protocol/http/body/rewindable.rb +11 -1
- data/lib/protocol/http/body/stream.rb +15 -0
- data/lib/protocol/http/body/streamable.rb +18 -2
- data/lib/protocol/http/body/writable.rb +3 -3
- data/lib/protocol/http/header/accept.rb +6 -0
- data/lib/protocol/http/header/authorization.rb +7 -1
- data/lib/protocol/http/header/connection.rb +7 -0
- data/lib/protocol/http/header/cookie.rb +7 -0
- data/lib/protocol/http/header/date.rb +8 -1
- data/lib/protocol/http/header/digest.rb +70 -0
- data/lib/protocol/http/header/etag.rb +7 -0
- data/lib/protocol/http/header/multiple.rb +7 -0
- data/lib/protocol/http/header/server_timing.rb +92 -0
- data/lib/protocol/http/header/split.rb +7 -0
- data/lib/protocol/http/header/te.rb +131 -0
- data/lib/protocol/http/header/trailer.rb +23 -0
- data/lib/protocol/http/header/transfer_encoding.rb +78 -0
- data/lib/protocol/http/headers.rb +55 -7
- data/lib/protocol/http/response.rb +1 -1
- data/lib/protocol/http/version.rb +1 -1
- data/readme.md +12 -0
- data/releases.md +54 -0
- data.tar.gz.sig +0 -0
- metadata +7 -2
- metadata.gz.sig +0 -0
- data/agent.md +0 -145
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c76db95baeca082a769340ab2a29ee5948bb837894e55039c082653453aa4f4f
|
4
|
+
data.tar.gz: 868cdcc4a21786c640c8c06e7892bfd6ef19f81670a90274692cfadef10a0c39
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: acc4afb3f7caba7ec2d2611ab72dce9c954e88e8dfe72645645e442d8116909c3f228b22c2d460633d429510ffff5f6f4713bfff751f18d4873db167e773fcbe
|
7
|
+
data.tar.gz: ef0e92bac69dba33417d074c24ce6d2f2009e19e1d81d317eecaf945817f3c7fb0775052048ca24df32b3f39c15f8de83ca45054669e2d89b5bf3b5b445c7cf1
|
checksums.yaml.gz.sig
CHANGED
Binary file
|
data/context/headers.md
ADDED
@@ -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
|
-
"
|
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-
|
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-
|
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} |
|
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-
|
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-
|
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
|
-
|
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
|
@@ -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
|
-
"
|
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-
|
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
|
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
|
-
"
|
169
|
+
"#<#{self.class} #{@count} chunks written, #{status}, error=#{@error}>"
|
170
170
|
else
|
171
|
-
"
|
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-
|
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-
|
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)
|