Add the start of a (video-recorded) HTTP/2 Client implementation.
adg and I recorded ourselves writing this: https://golang.org/s/http2clientvideo
diff --git a/frame.go b/frame.go
index c85b7d1..71397be 100644
--- a/frame.go
+++ b/frame.go
@@ -946,6 +946,10 @@
return &ContinuationFrame{fh, p}, nil
}
+func (f *ContinuationFrame) StreamEnded() bool {
+ return f.FrameHeader.Flags.Has(FlagDataEndStream)
+}
+
func (f *ContinuationFrame) HeaderBlockFragment() []byte {
f.checkValid()
return f.headerFragBuf
diff --git a/transport.go b/transport.go
new file mode 100644
index 0000000..49d413e
--- /dev/null
+++ b/transport.go
@@ -0,0 +1,297 @@
+// Copyright 2015 The Go Authors.
+// See https://go.googlesource.com/go/+/master/CONTRIBUTORS
+// Licensed under the same terms as Go itself:
+// https://go.googlesource.com/go/+/master/LICENSE
+
+package http2
+
+import (
+ "bufio"
+ "bytes"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+
+ "github.com/bradfitz/http2/hpack"
+)
+
+type Transport struct {
+ Fallback http.RoundTripper
+}
+
+type clientConn struct {
+ tconn *tls.Conn
+ bw *bufio.Writer
+ br *bufio.Reader
+ fr *Framer
+
+ readerDone chan struct{} // closed on error
+ readerErr error // set before readerDone is closed
+
+ werr error // first write error that has occurred
+
+ hbuf bytes.Buffer // HPACK encoder writes into this
+ henc *hpack.Encoder
+
+ hdec *hpack.Decoder
+
+ nextRes http.Header
+
+ // Settings from peer:
+ maxFrameSize uint32
+
+ mu sync.Mutex
+ streams map[uint32]*clientStream
+ nextStreamID uint32
+}
+
+type clientStream struct {
+ ID uint32
+ resc chan *http.Response
+ pw *io.PipeWriter
+ pr *io.PipeReader
+}
+
+type stickyErrWriter struct {
+ w io.Writer
+ err *error
+}
+
+func (sew stickyErrWriter) Write(p []byte) (n int, err error) {
+ if *sew.err != nil {
+ return 0, *sew.err
+ }
+ n, err = sew.w.Write(p)
+ *sew.err = err
+ return
+}
+
+func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
+ if req.URL.Scheme != "https" {
+ if t.Fallback == nil {
+ return nil, errors.New("http2: unsupported scheme and no Fallback")
+ }
+ return t.Fallback.RoundTrip(req)
+ }
+
+ host, port, err := net.SplitHostPort(req.URL.Host)
+ if err != nil {
+ host = req.URL.Host
+ port = "443"
+ }
+ cfg := &tls.Config{
+ ServerName: host,
+ NextProtos: []string{NextProtoTLS},
+ }
+ tconn, err := tls.Dial("tcp", host+":"+port, cfg)
+ if err != nil {
+ return nil, err
+ }
+ if err := tconn.Handshake(); err != nil {
+ return nil, err
+ }
+ if err := tconn.VerifyHostname(cfg.ServerName); err != nil {
+ return nil, err
+ }
+ state := tconn.ConnectionState()
+ if p := state.NegotiatedProtocol; p != NextProtoTLS {
+ // TODO(bradfitz): fall back to Fallback
+ return nil, fmt.Errorf("bad protocol: %v", p)
+ }
+ if !state.NegotiatedProtocolIsMutual {
+ return nil, errors.New("could not negotiate protocol mutually")
+ }
+ if _, err := tconn.Write(clientPreface); err != nil {
+ return nil, err
+ }
+
+ cc := &clientConn{
+ tconn: tconn,
+ readerDone: make(chan struct{}),
+ nextStreamID: 1,
+ streams: make(map[uint32]*clientStream),
+ }
+ cc.bw = bufio.NewWriter(stickyErrWriter{tconn, &cc.werr})
+ cc.br = bufio.NewReader(tconn)
+ cc.fr = NewFramer(cc.bw, cc.br)
+ cc.henc = hpack.NewEncoder(&cc.hbuf)
+
+ cc.fr.WriteSettings()
+ cc.bw.Flush()
+ if cc.werr != nil {
+ return nil, cc.werr
+ }
+
+ // Read the obligatory SETTINGS frame
+ f, err := cc.fr.ReadFrame()
+ if err != nil {
+ return nil, err
+ }
+ sf, ok := f.(*SettingsFrame)
+ if !ok {
+ return nil, fmt.Errorf("expected settings frame, got: %T", f)
+ }
+ cc.fr.WriteSettingsAck()
+ cc.bw.Flush()
+
+ sf.ForeachSetting(func(s Setting) error {
+ switch s.ID {
+ case SettingMaxFrameSize:
+ cc.maxFrameSize = s.Val
+ // TODO(bradfitz): handle the others
+ default:
+ log.Printf("Unhandled Setting: %v", s)
+ }
+ return nil
+ })
+ // TODO: figure out henc size
+ cc.hdec = hpack.NewDecoder(initialHeaderTableSize, cc.onNewHeaderField)
+
+ go cc.readLoop()
+
+ cs := cc.newStream()
+ hasBody := false // TODO
+
+ // we send: HEADERS[+CONTINUATION] + (DATA?)
+ hdrs := cc.encodeHeaders(req)
+ first := true
+ for len(hdrs) > 0 {
+ chunk := hdrs
+ if len(chunk) > int(cc.maxFrameSize) {
+ chunk = chunk[:cc.maxFrameSize]
+ }
+ hdrs = hdrs[len(chunk):]
+ endHeaders := len(hdrs) == 0
+ if first {
+ cc.fr.WriteHeaders(HeadersFrameParam{
+ StreamID: cs.ID,
+ BlockFragment: chunk,
+ EndStream: !hasBody,
+ EndHeaders: endHeaders,
+ })
+ first = false
+ } else {
+ cc.fr.WriteContinuation(cs.ID, endHeaders, chunk)
+ }
+ }
+ cc.bw.Flush()
+ if cc.werr != nil {
+ return nil, cc.werr
+ }
+
+ return <-cs.resc, nil
+}
+
+func (cc *clientConn) encodeHeaders(req *http.Request) []byte {
+ cc.hbuf.Reset()
+
+ // TODO(bradfitz): figure out :authority-vs-Host stuff between http2 and Go
+ host := req.Host
+ if host == "" {
+ host = req.URL.Host
+ }
+
+ cc.writeHeader(":method", req.Method)
+ cc.writeHeader(":scheme", "https")
+ cc.writeHeader(":authority", host) // probably not right for all sites
+ cc.writeHeader(":path", req.URL.Path)
+
+ for k, vv := range req.Header {
+ for _, v := range vv {
+ cc.writeHeader(strings.ToLower(k), v)
+ }
+ }
+ if _, ok := req.Header["Host"]; !ok {
+ cc.writeHeader("host", host)
+ }
+
+ return cc.hbuf.Bytes()
+}
+
+func (cc *clientConn) writeHeader(name, value string) {
+ log.Printf("sending %q = %q", name, value)
+ cc.henc.WriteField(hpack.HeaderField{Name: name, Value: value})
+}
+
+func (cc *clientConn) newStream() *clientStream {
+ cc.mu.Lock()
+ defer cc.mu.Unlock()
+
+ cs := &clientStream{
+ ID: cc.nextStreamID,
+ resc: make(chan *http.Response, 1),
+ }
+ cc.nextStreamID += 2
+ cc.streams[cs.ID] = cs
+
+ return cs
+}
+
+func (cc *clientConn) streamByID(id uint32) *clientStream {
+ cc.mu.Lock()
+ defer cc.mu.Unlock()
+ return cc.streams[id]
+}
+
+// runs in its own goroutine.
+func (cc *clientConn) readLoop() {
+ defer close(cc.readerDone)
+
+ for {
+ f, err := cc.fr.ReadFrame()
+ if err != nil {
+ cc.readerErr = err
+ // TODO: don't log it.
+ log.Printf("ReadFrame: %v", err)
+ return
+ }
+ cs := cc.streamByID(f.Header().StreamID)
+
+ log.Printf("Read %v: %#v", f.Header(), f)
+ headersEnded := false
+ streamEnded := false
+ if ff, ok := f.(interface {
+ StreamEnded() bool
+ }); ok {
+ streamEnded = ff.StreamEnded()
+ }
+ switch f := f.(type) {
+ case *HeadersFrame:
+ cc.nextRes = make(http.Header)
+ cs.pr, cs.pw = io.Pipe()
+ cc.hdec.Write(f.HeaderBlockFragment())
+ headersEnded = f.HeadersEnded()
+ case *ContinuationFrame:
+ // TODO: verify stream id is the same
+ cc.hdec.Write(f.HeaderBlockFragment())
+ headersEnded = f.HeadersEnded()
+ case *DataFrame:
+ log.Printf("DATA: %q", f.Data())
+ cs.pw.Write(f.Data())
+ default:
+ }
+ if streamEnded {
+ cs.pw.Close()
+ }
+ if headersEnded {
+ if cs == nil {
+ panic("couldn't find stream") // TODO be graceful
+ }
+ cs.resc <- &http.Response{
+ Header: cc.nextRes,
+ Body: cs.pr,
+ }
+ }
+ }
+}
+
+func (cc *clientConn) onNewHeaderField(f hpack.HeaderField) {
+ log.Printf("Header field: %+v", f)
+ cc.nextRes.Add(http.CanonicalHeaderKey(f.Name), f.Value)
+}
diff --git a/transport_test.go b/transport_test.go
new file mode 100644
index 0000000..dd98816
--- /dev/null
+++ b/transport_test.go
@@ -0,0 +1,29 @@
+// Copyright 2015 The Go Authors.
+// See https://go.googlesource.com/go/+/master/CONTRIBUTORS
+// Licensed under the same terms as Go itself:
+// https://go.googlesource.com/go/+/master/LICENSE
+
+package http2
+
+import (
+ "flag"
+ "net/http"
+ "os"
+ "testing"
+)
+
+var extNet = flag.Bool("extnet", false, "do external network tests")
+
+func TestTransport(t *testing.T) {
+ if !*extNet {
+ t.Skip("skipping external network test")
+ }
+ req, _ := http.NewRequest("GET", "https://http2.golang.org/", nil)
+ var rt http.RoundTripper = &Transport{}
+ //rt = http.DefaultTransport
+ res, err := rt.RoundTrip(req)
+ if err != nil {
+ t.Fatalf("%v", err)
+ }
+ res.Write(os.Stdout)
+}