ftw 0.0.1
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.
- data/README.md +49 -0
- data/lib/ftw/agent.rb +40 -0
- data/lib/ftw/connection.rb +231 -0
- data/lib/ftw/crlf.rb +6 -0
- data/lib/ftw/dns.rb +62 -0
- data/lib/ftw/http/headers.rb +122 -0
- data/lib/ftw/http/message.rb +92 -0
- data/lib/ftw/namespace.rb +3 -0
- data/lib/ftw/request.rb +102 -0
- data/lib/ftw/version.rb +5 -0
- data/lib/net-ftw.rb +1 -0
- data/lib/net/ftw.rb +5 -0
- data/lib/net/ftw/agent.rb +10 -0
- data/lib/net/ftw/connection.rb +296 -0
- data/lib/net/ftw/connection2.rb +247 -0
- data/lib/net/ftw/crlf.rb +6 -0
- data/lib/net/ftw/dns.rb +57 -0
- data/lib/net/ftw/http.rb +2 -0
- data/lib/net/ftw/http/client.rb +116 -0
- data/lib/net/ftw/http/client2.rb +80 -0
- data/lib/net/ftw/http/connection.rb +42 -0
- data/lib/net/ftw/http/headers.rb +122 -0
- data/lib/net/ftw/http/machine.rb +38 -0
- data/lib/net/ftw/http/message.rb +91 -0
- data/lib/net/ftw/http/request.rb +80 -0
- data/lib/net/ftw/http/response.rb +80 -0
- data/lib/net/ftw/http/server.rb +5 -0
- data/lib/net/ftw/machine.rb +59 -0
- data/lib/net/ftw/namespace.rb +6 -0
- data/lib/net/ftw/protocol/tls.rb +12 -0
- data/lib/net/ftw/websocket.rb +139 -0
- data/test/net/ftw/crlf.rb +12 -0
- data/test/net/ftw/http/dns.rb +6 -0
- data/test/net/ftw/http/headers.rb +50 -0
- data/test/testing.rb +23 -0
- metadata +82 -0
data/README.md
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
# For The Web
|
2
|
+
|
3
|
+
net/http is pretty much not good.
|
4
|
+
|
5
|
+
I want:
|
6
|
+
|
7
|
+
* A HTTP client that acts as a full user agent, not just a single connection.
|
8
|
+
* HTTP and SPDY support.
|
9
|
+
* WebSockets support.
|
10
|
+
* SSL/TLS support.
|
11
|
+
* An API that lets me do what I need.
|
12
|
+
* Server and Client modes.
|
13
|
+
* Support for both normal operation and EventMachine would be nice.
|
14
|
+
|
15
|
+
## DONE
|
16
|
+
|
17
|
+
* TCP connection
|
18
|
+
* DNS resolution (wraps Socket.gethostname)
|
19
|
+
* HTTP client partially done
|
20
|
+
|
21
|
+
## TODO
|
22
|
+
|
23
|
+
* Tests, yo.
|
24
|
+
* Logging, yo. With cabin, obviously.
|
25
|
+
* [DNS in Ruby stdlib is broken](https://github.com/jordansissel/experiments/tree/master/ruby/dns-resolving-bug), I need to write my own
|
26
|
+
|
27
|
+
## API Scratch
|
28
|
+
|
29
|
+
### Common case
|
30
|
+
|
31
|
+
agent = FTW::Agent.new
|
32
|
+
request = agent.get("http://www.google.com/")
|
33
|
+
response = request.execute
|
34
|
+
|
35
|
+
### SPDY
|
36
|
+
|
37
|
+
# SPDY should automatically be attempted. The caller should be unaware.
|
38
|
+
|
39
|
+
### WebSockets
|
40
|
+
|
41
|
+
# 'http(s)' or 'ws(s)' urls are valid here. They will mean the same thing.
|
42
|
+
request = agent.websocket("http://somehost/endpoint")
|
43
|
+
# Set auth header
|
44
|
+
request["Authorization"] = ...
|
45
|
+
request["Cookie"] = ...
|
46
|
+
|
47
|
+
websocket, error = request.execute
|
48
|
+
# Now websocket.read receives a message, websocket.write sends a message.
|
49
|
+
|
data/lib/ftw/agent.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
require "ftw/namespace"
|
2
|
+
require "ftw/request"
|
3
|
+
require "ftw/connection"
|
4
|
+
require "addressable/uri"
|
5
|
+
|
6
|
+
# This should act as a proper agent.
|
7
|
+
#
|
8
|
+
# * Keep cookies. Offer local-storage of cookies
|
9
|
+
# * Reuse connections. HTTP 1.1 Connection: keep-alive
|
10
|
+
# * HTTP Upgrade support
|
11
|
+
# * Websockets
|
12
|
+
# * SSL/TLS
|
13
|
+
class FTW::Agent
|
14
|
+
# TODO(sissel): All standard HTTP methods should be defined here.
|
15
|
+
# Also allow users to specify non-standard methods.
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
end
|
19
|
+
|
20
|
+
# Returns a FTW::Request
|
21
|
+
# TODO(sissel): SSL/TLS support
|
22
|
+
def get(uri, options={})
|
23
|
+
return request("GET", uri, options)
|
24
|
+
end # def get
|
25
|
+
|
26
|
+
def request(method, uri, options)
|
27
|
+
request = FTW::Request.new(uri)
|
28
|
+
request.method = method
|
29
|
+
request.connection = connection(uri.host, uri.port)
|
30
|
+
return request
|
31
|
+
end # def request
|
32
|
+
|
33
|
+
# Returns a FTW::Connection connected to this host:port.
|
34
|
+
# TODO(sissel): Implement connection reuse
|
35
|
+
# TODO(sissel): support SSL/TLS
|
36
|
+
private
|
37
|
+
def connection(host, port)
|
38
|
+
return FTW::Connection.new("#{host}:#{port}")
|
39
|
+
end # def connect
|
40
|
+
end # class FTW::Agent
|
@@ -0,0 +1,231 @@
|
|
1
|
+
require "cabin" # rubygem "cabin"
|
2
|
+
require "net/ftw/dns"
|
3
|
+
require "net/ftw/namespace"
|
4
|
+
require "socket"
|
5
|
+
require "timeout" # ruby stdlib, just for the Timeout exception.
|
6
|
+
require "backport-bij" # for Array#rotate, IO::WaitWritable, etc, in ruby < 1.9
|
7
|
+
|
8
|
+
# A network connection. This is TCP.
|
9
|
+
#
|
10
|
+
# You can use IO::select on this objects of this type.
|
11
|
+
# (at least, in MRI you can)
|
12
|
+
class FTW::Connection
|
13
|
+
# A new network connection.
|
14
|
+
# The 'destination' argument can be an array of strings or a single string.
|
15
|
+
# String format is expected to be "host:port"
|
16
|
+
#
|
17
|
+
# Example:
|
18
|
+
#
|
19
|
+
# conn = FTW::Connection.new(["1.2.3.4:80", "1.2.3.5:80"])
|
20
|
+
#
|
21
|
+
# If you specify multiple destinations, they are used in a round-robin
|
22
|
+
# decision made during reconnection.
|
23
|
+
public
|
24
|
+
def initialize(destinations)
|
25
|
+
if destinations.is_a?(String)
|
26
|
+
@destinations = [destinations]
|
27
|
+
else
|
28
|
+
@destinations = destinations
|
29
|
+
end
|
30
|
+
|
31
|
+
@connect_timeout = 2
|
32
|
+
|
33
|
+
# Use a fixed-size string that we set to BINARY encoding.
|
34
|
+
# Not all byte sequences are UTF-8 friendly :0
|
35
|
+
@read_size = 16384
|
36
|
+
@read_buffer = " " * @read_size
|
37
|
+
|
38
|
+
# Tell Ruby 1.9 that this string is a binary string, not utf-8 or somesuch.
|
39
|
+
if @read_buffer.respond_to?(:force_encoding)
|
40
|
+
@read_buffer.force_encoding("BINARY")
|
41
|
+
end
|
42
|
+
|
43
|
+
# TODO(sissel): Validate @destinations
|
44
|
+
end # def initialize
|
45
|
+
|
46
|
+
public
|
47
|
+
def connect(timeout=nil)
|
48
|
+
# TODO(sissel): Raise if we're already connected?
|
49
|
+
close if connected?
|
50
|
+
host, port = @destinations.first.split(":")
|
51
|
+
@destinations = @destinations.rotate # round-robin
|
52
|
+
|
53
|
+
# Do dns resolution on the host. If there are multiple
|
54
|
+
# addresses resolved, return one at random.
|
55
|
+
@remote_address = FTW::DNS.singleton.resolve_random(host)
|
56
|
+
|
57
|
+
# Addresses with colon ':' in them are assumed to be IPv6
|
58
|
+
family = @remote_address.include?(":") ? Socket::AF_INET6 : Socket::AF_INET
|
59
|
+
@socket = Socket.new(family, Socket::SOCK_STREAM, 0)
|
60
|
+
|
61
|
+
# This api is terrible. pack_sockaddr_in? This isn't C, man...
|
62
|
+
sockaddr = Socket.pack_sockaddr_in(port, @remote_address)
|
63
|
+
# TODO(sissel): Support local address binding
|
64
|
+
|
65
|
+
# Connect with timeout
|
66
|
+
begin
|
67
|
+
@socket.connect_nonblock(sockaddr)
|
68
|
+
rescue IO::WaitWritable
|
69
|
+
# Ruby actually raises Errno::EINPROGRESS, but for some reason
|
70
|
+
# the documentation says to use this IO::WaitWritable thing...
|
71
|
+
# I don't get it, but whatever :(
|
72
|
+
if writable?(timeout)
|
73
|
+
begin
|
74
|
+
@socket.connect_nonblock(sockaddr) # check connection failure
|
75
|
+
rescue Errno::EISCONN # Ignore, we're already connected.
|
76
|
+
rescue Errno::ECONNREFUSED => e
|
77
|
+
# Fire 'disconnected' event with reason :refused
|
78
|
+
trigger(DISCONNECTED, :refused, e)
|
79
|
+
end
|
80
|
+
else
|
81
|
+
# Connection timeout
|
82
|
+
# Fire 'disconnected' event with reason :timeout
|
83
|
+
trigger(DISCONNECTED, :connect_timeout, nil)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# We're now connected.
|
88
|
+
trigger(CONNECTED, "#{host}:#{port}")
|
89
|
+
end # def connect
|
90
|
+
|
91
|
+
# Is this Connection connected?
|
92
|
+
public
|
93
|
+
def connected?
|
94
|
+
return @connected
|
95
|
+
end # def connected?
|
96
|
+
|
97
|
+
# Write data to this connection.
|
98
|
+
# This method blocks until the write succeeds unless a timeout is given.
|
99
|
+
#
|
100
|
+
# Returns the number of bytes written (See IO#syswrite)
|
101
|
+
public
|
102
|
+
def write(data, timeout=nil)
|
103
|
+
#connect if !connected?
|
104
|
+
if writable?(timeout)
|
105
|
+
return @socket.syswrite(data)
|
106
|
+
else
|
107
|
+
raise Timeout::Error.new
|
108
|
+
end
|
109
|
+
end # def write
|
110
|
+
|
111
|
+
# Read data from this connection
|
112
|
+
# This method blocks until the read succeeds unless a timeout is given.
|
113
|
+
#
|
114
|
+
# This method is not guaranteed to read exactly 'length' bytes. See
|
115
|
+
# IO#sysread
|
116
|
+
public
|
117
|
+
def read(timeout=nil)
|
118
|
+
if readable?(timeout)
|
119
|
+
if !@unread_buffer.empty?
|
120
|
+
data = @unread_buffer
|
121
|
+
@unread_buffer = ""
|
122
|
+
return data
|
123
|
+
end
|
124
|
+
|
125
|
+
begin
|
126
|
+
@socket.sysread(@read_size, @read_buffer)
|
127
|
+
return @read_buffer
|
128
|
+
rescue EOFError
|
129
|
+
trigger(READER_CLOSED)
|
130
|
+
end
|
131
|
+
else
|
132
|
+
raise Timeout::Error.new
|
133
|
+
end
|
134
|
+
end # def read
|
135
|
+
|
136
|
+
# Un-read some data
|
137
|
+
public
|
138
|
+
def unread(data)
|
139
|
+
@unread_buffer << data
|
140
|
+
end # def unread
|
141
|
+
|
142
|
+
# End this connection, specifying why.
|
143
|
+
public
|
144
|
+
def disconnect(reason)
|
145
|
+
begin
|
146
|
+
#@reader_closed = true
|
147
|
+
@socket.close_read
|
148
|
+
rescue IOError => e
|
149
|
+
# Ignore
|
150
|
+
end
|
151
|
+
|
152
|
+
begin
|
153
|
+
@socket.close_write
|
154
|
+
rescue IOError => e
|
155
|
+
# Ignore
|
156
|
+
end
|
157
|
+
|
158
|
+
trigger(DISCONNECTED, reason)
|
159
|
+
end # def disconnect
|
160
|
+
|
161
|
+
# Is this connection writable? Returns true if it is writable within
|
162
|
+
# the timeout period. False otherwise.
|
163
|
+
#
|
164
|
+
# The time out is in seconds. Fractional seconds are OK.
|
165
|
+
public
|
166
|
+
def writable?(timeout)
|
167
|
+
ready = IO.select(nil, [@socket], nil, timeout)
|
168
|
+
return !ready.nil?
|
169
|
+
end # def writable?
|
170
|
+
|
171
|
+
# Is this connection readable? Returns true if it is readable within
|
172
|
+
# the timeout period. False otherwise.
|
173
|
+
#
|
174
|
+
# The time out is in seconds. Fractional seconds are OK.
|
175
|
+
public
|
176
|
+
def readable?(timeout)
|
177
|
+
#return false if @reader_closed
|
178
|
+
ready = IO.select([@socket], nil, nil, timeout)
|
179
|
+
return !ready.nil?
|
180
|
+
end # def readable?
|
181
|
+
|
182
|
+
protected
|
183
|
+
def connected(address)
|
184
|
+
@remote_address = nil
|
185
|
+
@connected = true
|
186
|
+
end # def connected
|
187
|
+
|
188
|
+
protected
|
189
|
+
def disconnected(reason, error)
|
190
|
+
@remote_address = nil
|
191
|
+
@connected = false
|
192
|
+
end # def disconnected
|
193
|
+
|
194
|
+
# The host:port
|
195
|
+
public
|
196
|
+
def peer
|
197
|
+
return @remote_address
|
198
|
+
end # def peer
|
199
|
+
|
200
|
+
# Run this Connection.
|
201
|
+
# This is generally meant for Threaded or synchronous operation.
|
202
|
+
# For EventMachine, see TODO(sissel): Implement EventMachine support.
|
203
|
+
public
|
204
|
+
def run
|
205
|
+
connect(@connect_timeout) if not connected?
|
206
|
+
while connected?
|
207
|
+
read_and_trigger
|
208
|
+
end
|
209
|
+
end # def run
|
210
|
+
|
211
|
+
# Read data and trigger data callbacks.
|
212
|
+
#
|
213
|
+
# This is mainly useful if you are implementing your own run loops
|
214
|
+
# and IO::select shenanigans.
|
215
|
+
public
|
216
|
+
def read_and_trigger
|
217
|
+
data = read(@read_size)
|
218
|
+
if data.length == 0
|
219
|
+
disconnect(EOFError)
|
220
|
+
else
|
221
|
+
trigger(DATA, data)
|
222
|
+
end
|
223
|
+
end # def read_and_trigger
|
224
|
+
|
225
|
+
# Support 'to_io' so you can use IO::select on this object.
|
226
|
+
public
|
227
|
+
def to_io
|
228
|
+
return @socket
|
229
|
+
end
|
230
|
+
end # class FTW::Connection
|
231
|
+
|
data/lib/ftw/crlf.rb
ADDED
data/lib/ftw/dns.rb
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
require "ftw/namespace"
|
2
|
+
require "socket" # for Socket.gethostbyname
|
3
|
+
|
4
|
+
# TODO(sissel): Switch to using Resolv::DNS since it lets you (the programmer)
|
5
|
+
# choose dns configuration (servers, etc)
|
6
|
+
#
|
7
|
+
# I wrap whatever Ruby provides because it is historically very
|
8
|
+
# inconsistent in implementation behavior across ruby platforms and versions.
|
9
|
+
# In the future, this will probably implement the DNS protocol, but for now
|
10
|
+
# chill in the awkward, but already-written, ruby stdlib.
|
11
|
+
#
|
12
|
+
# I didn't really want to write a DNS library, but a consistent API and
|
13
|
+
# behavior is necessary for my continued sanity :)
|
14
|
+
class FTW::DNS
|
15
|
+
V4_IN_V6_PREFIX = "0:" * 12
|
16
|
+
|
17
|
+
def self.singleton
|
18
|
+
@resolver ||= self.new
|
19
|
+
end # def self.singleton
|
20
|
+
|
21
|
+
# This method is only intended to do A or AAAA lookups
|
22
|
+
# I may add PTR lookups later.
|
23
|
+
def resolve(hostname)
|
24
|
+
official, aliases, family, *addresses = Socket.gethostbyname(hostname)
|
25
|
+
# We ignore family, here. Ruby will return v6 *and* v4 addresses in
|
26
|
+
# the same gethostbyname() call. It is confusing.
|
27
|
+
#
|
28
|
+
# Let's just rely entirely on the length of the address string.
|
29
|
+
return addresses.collect do |address|
|
30
|
+
if address.length == 16
|
31
|
+
unpack_v6(address)
|
32
|
+
else
|
33
|
+
unpack_v4(address)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end # def resolve
|
37
|
+
|
38
|
+
def resolve_random(hostname)
|
39
|
+
addresses = resolve(hostname)
|
40
|
+
return addresses[rand(addresses.size)]
|
41
|
+
end # def resolve_random
|
42
|
+
|
43
|
+
private
|
44
|
+
def unpack_v4(address)
|
45
|
+
return address.unpack("C4").join(".")
|
46
|
+
end # def unpack_v4
|
47
|
+
|
48
|
+
private
|
49
|
+
def unpack_v6(address)
|
50
|
+
if address.length == 16
|
51
|
+
# Unpack 16 bit chunks, convert to hex, join with ":"
|
52
|
+
address.unpack("n8").collect { |p| p.to_s(16) } \
|
53
|
+
.join(":").sub(/(?:0:(?:0:)+)/, "::")
|
54
|
+
else
|
55
|
+
# assume ipv4
|
56
|
+
# Per the following sites, "::127.0.0.1" is valid and correct
|
57
|
+
# http://en.wikipedia.org/wiki/IPv6#IPv4-mapped_IPv6_addresses
|
58
|
+
# http://www.tcpipguide.com/free/t_IPv6IPv4AddressEmbedding.htm
|
59
|
+
"::" + unpack_v4(address)
|
60
|
+
end
|
61
|
+
end # def unpack_v6
|
62
|
+
end # class FTW::DNS
|
@@ -0,0 +1,122 @@
|
|
1
|
+
require "net/ftw/namespace"
|
2
|
+
require "ftw/crlf"
|
3
|
+
|
4
|
+
# HTTP Headers
|
5
|
+
#
|
6
|
+
# See RFC2616 section 4.2: <http://tools.ietf.org/html/rfc2616#section-4.2>
|
7
|
+
#
|
8
|
+
# Section 14.44 says Field Names in the header are case-insensitive, so
|
9
|
+
# this library always forces field names to be lowercase. This includes
|
10
|
+
# get() calls.
|
11
|
+
#
|
12
|
+
# headers.set("HELLO", "world")
|
13
|
+
# headers.get("hello") # ===> "world"
|
14
|
+
#
|
15
|
+
class FTW::HTTP::Headers
|
16
|
+
include Enumerable
|
17
|
+
include FTW::CRLF
|
18
|
+
|
19
|
+
# Make a new headers container. You can pass a hash of
|
20
|
+
public
|
21
|
+
def initialize(headers={})
|
22
|
+
super()
|
23
|
+
@version = 1.1
|
24
|
+
@headers = headers
|
25
|
+
end # def initialize
|
26
|
+
|
27
|
+
# Set a header field to a specific value.
|
28
|
+
# Any existing value(s) for this field are destroyed.
|
29
|
+
def set(field, value)
|
30
|
+
@headers[field.downcase] = value
|
31
|
+
end # def set
|
32
|
+
|
33
|
+
# Set a header field to a specific value.
|
34
|
+
# Any existing value(s) for this field are destroyed.
|
35
|
+
def include?(field)
|
36
|
+
@headers.include?(field.downcase)
|
37
|
+
end # def include?
|
38
|
+
|
39
|
+
# Add a header field with a value.
|
40
|
+
#
|
41
|
+
# If this field already exists, another value is added.
|
42
|
+
# If this field does not already exist, it is set.
|
43
|
+
def add(field, value)
|
44
|
+
field = field.downcase
|
45
|
+
if @headers.include?(field)
|
46
|
+
if @headers[field].is_a?(Array)
|
47
|
+
@headers[field] << value
|
48
|
+
else
|
49
|
+
@headers[field] = [@headers[field], value]
|
50
|
+
end
|
51
|
+
else
|
52
|
+
set(field, value)
|
53
|
+
end
|
54
|
+
end # def add
|
55
|
+
|
56
|
+
# Removes a header entry. If the header has multiple values
|
57
|
+
# (like X-Forwarded-For can), you can delete a specific entry
|
58
|
+
# by passing the value of the header field to remove.
|
59
|
+
#
|
60
|
+
# # Remove all X-Forwarded-For entries
|
61
|
+
# headers.remove("X-Forwarded-For")
|
62
|
+
# # Remove a specific X-Forwarded-For entry
|
63
|
+
# headers.remove("X-Forwarded-For", "1.2.3.4")
|
64
|
+
#
|
65
|
+
# * If you remove a field that doesn't exist, no error will occur.
|
66
|
+
# * If you remove a field value that doesn't exist, no error will occur.
|
67
|
+
# * If you remove a field value that is the only value, it is the same as
|
68
|
+
# removing that field by name.
|
69
|
+
def remove(field, value=nil)
|
70
|
+
field = field.downcase
|
71
|
+
if value.nil?
|
72
|
+
# no value, given, remove the entire field.
|
73
|
+
@headers.delete(field)
|
74
|
+
else
|
75
|
+
field_value = @headers[field]
|
76
|
+
if field_value.is_a?(Array)
|
77
|
+
# remove a specific value
|
78
|
+
field_value.delete(value)
|
79
|
+
# Down to a String again if there's only one value.
|
80
|
+
if field_value.size == 1
|
81
|
+
set(field, field_value.first)
|
82
|
+
end
|
83
|
+
else
|
84
|
+
# Remove this field if the value matches
|
85
|
+
if field_value == value
|
86
|
+
remove(field)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end # def remove
|
91
|
+
|
92
|
+
# Get a field value.
|
93
|
+
#
|
94
|
+
# This will return:
|
95
|
+
# * String if there is only a single value for this field
|
96
|
+
# * Array of String if there are multiple values for this field
|
97
|
+
def get(field)
|
98
|
+
field = field.downcase
|
99
|
+
return @headers[field]
|
100
|
+
end # def get
|
101
|
+
|
102
|
+
# Iterate over headers. Given to the block are two arguments, the field name
|
103
|
+
# and the field value. For fields with multiple values, you will receive
|
104
|
+
# that same field name multiple times, like:
|
105
|
+
# yield "Host", "www.example.com"
|
106
|
+
# yield "X-Forwarded-For", "1.2.3.4"
|
107
|
+
# yield "X-Forwarded-For", "1.2.3.5"
|
108
|
+
def each(&block)
|
109
|
+
@headers.each do |field_name, field_value|
|
110
|
+
if field_value.is_a?(Array)
|
111
|
+
field_value.map { |value| yield field_name, v }
|
112
|
+
else
|
113
|
+
yield field_name, field_value
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end # end each
|
117
|
+
|
118
|
+
public
|
119
|
+
def to_s
|
120
|
+
return @headers.collect { |name, value| "#{name}: #{value}" }.join(CRLF) + CRLF
|
121
|
+
end # def to_s
|
122
|
+
end # class FTW::HTTP::Headers
|