From f53ce2f5715a2cd91164e7e73f7ef93c9a1ee5cf Mon Sep 17 00:00:00 2001 From: fschade Date: Tue, 30 Sep 2025 14:38:04 +0200 Subject: [PATCH] chore: adopt fschade/icap-client --- .travis.yml | 2 +- README.md | 51 ++-- client.go | 128 ++++------ client_test.go | 320 +++++++---------------- config.go | 33 +++ conn.go | 108 ++++++++ conn_test.go | 89 +++++++ consts.go | 57 ----- debug.go | 55 ---- doc.go | 71 +---- driver.go | 99 ------- driver_test.go | 40 --- examples/custom_driver.go | 76 ------ examples/debug.go | 47 ---- examples/reqmod.go | 39 +-- examples/respmod.go | 43 ++-- go.mod | 4 +- header.go | 124 --------- icap_client.go | 444 ++++++++++++++++++++++++++++++++ icap_client_test.go | 526 ++++++++++++++++++++++++++++++++++++++ methods.go | 9 - parser.go | 179 ------------- parser_test.go | 206 --------------- request.go | 230 +++++++++++------ request_test.go | 182 ++----------- response.go | 123 --------- response_test.go | 208 --------------- test_server.go | 6 +- transport.go | 123 --------- validate.go | 63 ----- 30 files changed, 1575 insertions(+), 2110 deletions(-) create mode 100644 config.go create mode 100644 conn.go create mode 100644 conn_test.go delete mode 100644 consts.go delete mode 100644 debug.go delete mode 100644 driver.go delete mode 100644 driver_test.go delete mode 100644 examples/custom_driver.go delete mode 100644 examples/debug.go delete mode 100644 header.go create mode 100644 icap_client.go create mode 100644 icap_client_test.go delete mode 100644 methods.go delete mode 100644 parser.go delete mode 100644 parser_test.go delete mode 100644 response.go delete mode 100644 response_test.go delete mode 100644 transport.go delete mode 100644 validate.go diff --git a/.travis.yml b/.travis.yml index bc1f78c..b96fe30 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ env: - GO111MODULE=ON go: - - 1.12.x + - 1.21.x git: depth: 1 diff --git a/README.md b/README.md index 5b873a5..388f6bb 100644 --- a/README.md +++ b/README.md @@ -19,64 +19,53 @@ go get -u github.com/egirna/icap-client ```go import ic "github.com/egirna/icap-client" - ``` **Making a simple RESPMOD call** ```go - - req, err := ic.NewRequest(ic.MethodRESPMOD, "icap://:/", httpReq, httpResp) - + req, err := ic.NewRequest(context.Background(), MethodRESPMOD, "icap://:/", httpReq, httpResp) if err != nil { log.Fatal(err) } - client := &ic.Client{ - Timeout: 5 * time.Second, - } + client, err := ic.NewClient( + ic.WithICAPConnectionTimeout(5 * time.Second), + ) + if err != nil { + log.Fatal(err) + } resp, err := client.Do(req) - - if err != nil { - log.Fatal(err) - } - + if err != nil { + log.Fatal(err) + } ``` -**Note**: ``httpReq`` & ``httpResp`` here are ``*http.Response`` & ``*http.Request`` respectively +**Note**: `httpReq` & `httpResp` here are `*http.Response` & `*http.Request` respectively **Setting preview obtained from OPTIONS call** ```go - - optReq, err := ic.NewRequest(ic.MethodOPTIONS, "icap://:/", nil, nil) - + req, err := ic.NewRequest(context.Background(), ic.MethodOPTIONS, "icap://:/", nil, nil) if err != nil { log.Fatal(err) - return } - client := &ic.Client{ - Timeout: 5 * time.Second, + client, err := ic.NewClient( + ic.WithICAPConnectionTimeout(5 * time.Second), + ) + if err != nil { + log.Fatal(err) } req.SetPreview(optReq.PreviewBytes) // do something with req(ICAP *Request) - -``` - -**DEBUG Mode** - -Turn on debug mode to inspect detailed & verbose logs to debug your code during development - -```go - ic.SetDebugMode(true) - ``` -By default the icap-client will dump the debugging logs to the standard output(stdout), but you can always add your custom writer +By default, the icap-client will dump the debugging logs to the standard output(stdout), +but you can always add your custom writer ```go f, _ := os.OpenFile("logs.txt", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) @@ -88,7 +77,7 @@ For more details, see the [docs](https://godoc.org/github.com/egirna/icap-client ### Contributing -This package is still WIP, so totally open to suggestions. See the contributions guide [here](CONTRIBUTING.md). +This package is still WIP, so totally open to suggestions. See the contribution guide [here](CONTRIBUTING.md). ### License diff --git a/client.go b/client.go index b7933db..bd6354d 100644 --- a/client.go +++ b/client.go @@ -1,115 +1,81 @@ package icapclient import ( + "bufio" + "bytes" + "errors" "net/http" - "strconv" - "time" + "strings" ) -// Client represents the icap client who makes the icap server calls +// Client represents the ICAP client who makes the ICAP server calls type Client struct { - scktDriver *Driver - Timeout time.Duration + config Config // Store config for connection parameters } -// Do makes does everything required to make a call to the ICAP server -func (c *Client) Do(req *Request) (*Response, error) { - - if c.scktDriver == nil { // create a new socket driver if one wasn't explicitly created - port, err := strconv.Atoi(req.URL.Port()) - - if err != nil { - return nil, err - } - c.scktDriver = NewDriver(req.URL.Hostname(), port) +// NewClient creates a new ICAP client (no persistent connection) +func NewClient(options ...ConfigOption) (Client, error) { + config := DefaultConfig() + for _, option := range options { + option(&config) } + return Client{config: config}, nil +} - c.setDefaultTimeouts() // assinging default timeouts if not set already - - if req.ctx != nil { // connect with the given context if context is set - if err := c.scktDriver.ConnectWithContext(*req.ctx); err != nil { - return nil, err - } - } else { - if err := c.scktDriver.Connect(); err != nil { - return nil, err - } +// Do make the ICAP request, creating and dropping a connection each time +func (c Client) Do(req Request) (res Response, err error) { + conn, err := NewICAPConn(c.config.ICAPConn) + if err != nil { + return Response{}, err } - defer c.scktDriver.Close() // closing the socket connection - - req.SetDefaultRequestHeaders() // assigning default headers if not set already - - logDebug("The request headers: ") - dumpDebug(req.Header) + if err := conn.Connect(req.ctx, req.URL.Host); err != nil { + return Response{}, err + } + defer func() { + err = errors.Join(err, conn.Close()) + }() - d, err := DumpRequest(req) // getting the byte representation of the ICAP request + req.setDefaultRequestHeaders() + message, err := toICAPRequest(req) if err != nil { - return nil, err + return Response{}, err } - if err := c.scktDriver.Send(d); err != nil { // sending the entire TCP message of the ICAP client to the server connected - return nil, err + // send the ICAP message to the server + dataRes, err := conn.Send(message) + if err != nil { + return Response{}, err } - resp, err := c.scktDriver.Receive() // taking the response - + res, err = toClientResponse(bufio.NewReader(strings.NewReader(string(dataRes)))) if err != nil { - return nil, err + return Response{}, err } - if resp.StatusCode == http.StatusContinue && !req.bodyFittedInPreview && req.previewSet { // this block suggests that the ICAP request contained preview body bytes and whole body did not fit in the preview, so the serber responded with 100 Continue and the client is to send the remaining body bytes only - logDebug("Making request for the rest of the remaining body bytes after preview, as received 100 Continue from the server...") - return c.DoRemaining(req) + // check if the message is fully done scanning or if it needs to be sent another chunk + done := !(res.StatusCode == http.StatusContinue && !req.bodyFittedInPreview && req.previewSet) + if done { + return res, nil } - return resp, nil -} - -// DoRemaining requests an ICAP server with the remaining body bytes which did not fit in the preview in the original request -func (c *Client) DoRemaining(req *Request) (*Response, error) { - + // get the remaining body bytes data := req.remainingPreviewBytes - - if !bodyAlreadyChunked(string(data)) { // if the body is not already chunke, then add the basic hexa body bytes notation - ds := string(data) - addHexaBodyByteNotations(&ds) - data = []byte(ds) + if !bodyIsChunked(string(data)) { + data = []byte(addHexBodyByteNotations(string(data))) } - if err := c.scktDriver.Send(data); err != nil { - return nil, err + // hydrate the ICAP message with closing doubleCRLF suffix + if !bytes.HasSuffix(data, []byte(doubleCRLF)) { + data = append(data, []byte(crlf)...) } - resp, err := c.scktDriver.Receive() - + // send the remaining body bytes to the server + dataRes, err = conn.Send(data) if err != nil { - return nil, err + return Response{}, err } - return resp, nil -} - -// SetDriver sets a new socket driver with the client -func (c *Client) SetDriver(d *Driver) { - c.scktDriver = d -} - -func (c *Client) setDefaultTimeouts() { - if c.Timeout == 0 { - c.Timeout = defaultTimeout - } - - if c.scktDriver.DialerTimeout == 0 { - c.scktDriver.DialerTimeout = c.Timeout - } - - if c.scktDriver.ReadTimeout == 0 { - c.scktDriver.ReadTimeout = c.Timeout - } - - if c.scktDriver.WriteTimeout == 0 { - c.scktDriver.WriteTimeout = c.Timeout - } + return toClientResponse(bufio.NewReader(strings.NewReader(string(dataRes)))) } diff --git a/client_test.go b/client_test.go index 6828cca..645ab90 100644 --- a/client_test.go +++ b/client_test.go @@ -1,38 +1,34 @@ package icapclient import ( + "context" "fmt" - "io/ioutil" + "io" "net/http" "reflect" "strconv" "strings" "testing" - "time" ) -func TestClient(t *testing.T) { +func TestClient_Do(t *testing.T) { if !testServerRunning() { go startTestServer() } - t.Run("Client Do RESPMOD", func(t *testing.T) { + t.Parallel() + t.Run("RESPMOD", func(t *testing.T) { httpReq, err := http.NewRequest(http.MethodGet, "http://someurl.com", nil) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } type testSample struct { - httpResp *http.Response - wantedStatusCode int - wantedStatus string - wantedTimeout time.Duration - wantedDialerTimeout time.Duration - wantedReadTimeout time.Duration - wantedWriteTimeout time.Duration + httpResp *http.Response + wantedStatusCode int + wantedStatus string } sampleTable := []testSample{ @@ -48,14 +44,10 @@ func TestClient(t *testing.T) { "Content-Length": []string{"19"}, }, ContentLength: 19, - Body: ioutil.NopCloser(strings.NewReader("This is a GOOD FILE")), + Body: io.NopCloser(strings.NewReader("This is a GOOD FILE")), }, - wantedStatusCode: http.StatusNoContent, - wantedStatus: "No Modifications", - wantedTimeout: defaultTimeout, - wantedDialerTimeout: defaultTimeout, - wantedReadTimeout: defaultTimeout, - wantedWriteTimeout: defaultTimeout, + wantedStatusCode: http.StatusNoContent, + wantedStatus: "No Modifications", }, { httpResp: &http.Response{ @@ -69,155 +61,92 @@ func TestClient(t *testing.T) { "Content-Length": []string{"18"}, }, ContentLength: 18, - Body: ioutil.NopCloser(strings.NewReader("This is a BAD FILE")), + Body: io.NopCloser(strings.NewReader("This is a BAD FILE")), }, - wantedStatusCode: http.StatusOK, - wantedStatus: "OK", - wantedTimeout: defaultTimeout, - wantedDialerTimeout: defaultTimeout, - wantedReadTimeout: defaultTimeout, - wantedWriteTimeout: defaultTimeout, + wantedStatusCode: http.StatusOK, + wantedStatus: "OK", }, } for _, sample := range sampleTable { - req, err := NewRequest(MethodRESPMOD, fmt.Sprintf("icap://localhost:%d/respmod", port), httpReq, sample.httpResp) + req, err := NewRequest(context.Background(), MethodRESPMOD, fmt.Sprintf("icap://localhost:%d/respmod", port), httpReq, sample.httpResp) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } - client := Client{} + client, _ := NewClient() resp, err := client.Do(req) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } if resp.StatusCode != sample.wantedStatusCode { - t.Logf("Wanted status code:%d, got:%d", sample.wantedStatusCode, resp.StatusCode) - t.Fail() + t.Errorf("Wanted status code:%d, got:%d", sample.wantedStatusCode, resp.StatusCode) } if resp.Status != sample.wantedStatus { - t.Logf("Wanted status:%s, got:%s", sample.wantedStatus, resp.Status) - t.Fail() - } - - if client.Timeout != sample.wantedTimeout { - t.Logf("Wanted timeout to be:%v, got:%v", sample.wantedTimeout, client.Timeout) - t.Fail() - } - if client.scktDriver.DialerTimeout != sample.wantedDialerTimeout { - t.Logf("Wanted DialierTimeout to be:%v , got:%v", sample.wantedDialerTimeout, client.scktDriver.DialerTimeout) - t.Fail() - } - if client.scktDriver.ReadTimeout != sample.wantedReadTimeout { - t.Logf("Wanted ReadTimeout to be:%v , got:%v", sample.wantedReadTimeout, client.scktDriver.ReadTimeout) - t.Fail() - } - if client.scktDriver.WriteTimeout != sample.wantedWriteTimeout { - t.Logf("Wanted WriteTimeout to be:%v , got:%v", sample.wantedWriteTimeout, client.scktDriver.WriteTimeout) - t.Fail() + t.Errorf("Wanted status:%s, got:%s", sample.wantedStatus, resp.Status) } } }) - t.Run("Client Do REQMOD", func(t *testing.T) { - + t.Run("REQMOD", func(t *testing.T) { type testSample struct { - urlStr string - wantedStatusCode int - wantedStatus string - wantedTimeout time.Duration - wantedDialerTimeout time.Duration - wantedReadTimeout time.Duration - wantedWriteTimeout time.Duration + urlStr string + wantedStatusCode int + wantedStatus string } sampleTable := []testSample{ { - urlStr: "http://goodifle.com", - wantedStatusCode: http.StatusNoContent, - wantedStatus: "No Modifications", - wantedTimeout: defaultTimeout, - wantedDialerTimeout: defaultTimeout, - wantedReadTimeout: defaultTimeout, - wantedWriteTimeout: defaultTimeout, + urlStr: "http://goodifle.com", + wantedStatusCode: http.StatusNoContent, + wantedStatus: "No Modifications", }, { - urlStr: "http://badfile.com", - wantedStatusCode: http.StatusOK, - wantedStatus: "OK", - wantedTimeout: defaultTimeout, - wantedDialerTimeout: defaultTimeout, - wantedReadTimeout: defaultTimeout, - wantedWriteTimeout: defaultTimeout, + urlStr: "http://badfile.com", + wantedStatusCode: http.StatusOK, + wantedStatus: "OK", }, } for _, sample := range sampleTable { httpReq, err := http.NewRequest(http.MethodGet, sample.urlStr, nil) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } - req, err := NewRequest(MethodREQMOD, fmt.Sprintf("icap://localhost:%d/reqmod", port), httpReq, nil) + req, err := NewRequest(context.Background(), MethodREQMOD, fmt.Sprintf("icap://localhost:%d/reqmod", port), httpReq, nil) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } - client := Client{} + client, _ := NewClient() resp, err := client.Do(req) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } if resp.StatusCode != sample.wantedStatusCode { - t.Logf("Wanted status code:%d, got:%d", sample.wantedStatusCode, resp.StatusCode) - t.Fail() + t.Errorf("Wanted status code:%d, got:%d", sample.wantedStatusCode, resp.StatusCode) } if resp.Status != sample.wantedStatus { - t.Logf("Wanted status:%s, got:%s", sample.wantedStatus, resp.Status) - t.Fail() - } - - if client.Timeout != sample.wantedTimeout { - t.Logf("Wanted timeout to be:%v, got:%v", sample.wantedTimeout, client.Timeout) - t.Fail() - } - if client.scktDriver.DialerTimeout != sample.wantedDialerTimeout { - t.Logf("Wanted DialierTimeout to be:%v , got:%v", sample.wantedDialerTimeout, client.scktDriver.DialerTimeout) - t.Fail() - } - if client.scktDriver.ReadTimeout != sample.wantedReadTimeout { - t.Logf("Wanted ReadTimeout to be:%v , got:%v", sample.wantedReadTimeout, client.scktDriver.ReadTimeout) - t.Fail() + t.Errorf("Wanted status:%s, got:%s", sample.wantedStatus, resp.Status) } - if client.scktDriver.WriteTimeout != sample.wantedWriteTimeout { - t.Logf("Wanted WriteTimeout to be:%v , got:%v", sample.wantedWriteTimeout, client.scktDriver.WriteTimeout) - t.Fail() - } - } }) - t.Run("Clien Do RESPMOD with OPTIONS", func(t *testing.T) { - + t.Run("RESPMOD with OPTIONS", func(t *testing.T) { httpReq, err := http.NewRequest(http.MethodGet, "http://someurl.com", nil) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } @@ -244,7 +173,7 @@ func TestClient(t *testing.T) { "Content-Length": []string{"41"}, }, ContentLength: 41, - Body: ioutil.NopCloser(strings.NewReader("Hello World!This is a GOOD FILE! bye bye!")), + Body: io.NopCloser(strings.NewReader("Hello World!This is a GOOD FILE! bye bye!")), }, wantedStatusCode: http.StatusNoContent, wantedStatus: "No Modifications", @@ -270,7 +199,7 @@ func TestClient(t *testing.T) { "Content-Length": []string{"18"}, }, ContentLength: 18, - Body: ioutil.NopCloser(strings.NewReader("This is a BAD FILE")), + Body: io.NopCloser(strings.NewReader("This is a BAD FILE")), }, wantedStatusCode: http.StatusOK, wantedStatus: "OK", @@ -287,87 +216,73 @@ func TestClient(t *testing.T) { } for _, sample := range sampleTable { - urlStr := fmt.Sprintf("icap://localhost:%d/respmod", port) - optReq, err := NewRequest(MethodOPTIONS, urlStr, nil, nil) + optReq, err := NewRequest(context.Background(), MethodOPTIONS, urlStr, nil, nil) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } - client := Client{} + client, _ := NewClient() optResp, err := client.Do(optReq) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } if optResp.Status != sample.wantedOptionStatus { - t.Logf("Wanted status:%s, got:%s", sample.wantedOptionStatus, optResp.Status) - t.Fail() + t.Errorf("Wanted status:%s, got:%s", sample.wantedOptionStatus, optResp.Status) } if optResp.StatusCode != sample.wantedOptionStatusCode { - t.Logf("Wanted status code:%d, got:%d", sample.wantedOptionStatusCode, optResp.StatusCode) - t.Fail() + t.Errorf("Wanted status code:%d, got:%d", sample.wantedOptionStatusCode, optResp.StatusCode) } if optResp.PreviewBytes != sample.wantedPreviewBytes { - t.Logf("Wanted preview bytes:%d , got:%d", sample.wantedPreviewBytes, optResp.PreviewBytes) - t.Fail() + t.Errorf("Wanted preview bytes:%d , got:%d", sample.wantedPreviewBytes, optResp.PreviewBytes) } for k, v := range sample.wantedOptionHeader { if val, exists := optResp.Header[k]; exists { if !reflect.DeepEqual(val, v) { - t.Logf("Wanted value for header:%s to be:%v, got:%v", k, v, val) - t.Fail() + t.Errorf("Wanted value for header:%s to be:%v, got:%v", k, v, val) } continue } - t.Logf("Expected header:%s but not found", k) - t.Fail() - + t.Errorf("Expected header:%s but not found", k) } - req, err := NewRequest(MethodRESPMOD, urlStr, httpReq, sample.httpResp) + req, err := NewRequest(context.Background(), MethodRESPMOD, urlStr, httpReq, sample.httpResp) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } - if err := req.ExtendHeader(optResp.Header); err != nil { - t.Log(err.Error()) - t.Fail() + if err := req.extendHeader(optResp.Header); err != nil { + t.Error(err) return } resp, err := client.Do(req) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } if resp.StatusCode != sample.wantedStatusCode { - t.Logf("Wanted status code:%d, got:%d", sample.wantedStatusCode, resp.StatusCode) - t.Fail() + t.Errorf("Wanted status code:%d, got:%d", sample.wantedStatusCode, resp.StatusCode) } if resp.Status != sample.wantedStatus { - t.Logf("Wanted status:%s, got:%s", sample.wantedStatus, resp.Status) - t.Fail() + t.Errorf("Wanted status:%s, got:%s", sample.wantedStatus, resp.Status) } } }) - t.Run("Client Do REQMOD with OPTIONS", func(t *testing.T) { + t.Run("REQMOD with OPTIONS", func(t *testing.T) { type testSample struct { urlStr string wantedStatusCode int @@ -408,79 +323,67 @@ func TestClient(t *testing.T) { for _, sample := range sampleTable { - client := Client{} urlStr := fmt.Sprintf("icap://localhost:%d/reqmod", port) - optReq, err := NewRequest(MethodOPTIONS, urlStr, nil, nil) + optReq, err := NewRequest(context.Background(), MethodOPTIONS, urlStr, nil, nil) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } + client, _ := NewClient() optResp, err := client.Do(optReq) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } if optResp.Status != sample.wantedOptionStatus { - t.Logf("Wanted status:%s , got:%s", sample.wantedOptionStatus, optResp.Status) - t.Fail() + t.Errorf("Wanted status:%s , got:%s", sample.wantedOptionStatus, optResp.Status) } if optResp.StatusCode != sample.wantedOptionStatusCode { - t.Logf("Wanted status code:%d , got:%d", sample.wantedOptionStatusCode, optResp.StatusCode) - t.Fail() + t.Errorf("Wanted status code:%d , got:%d", sample.wantedOptionStatusCode, optResp.StatusCode) } for k, v := range sample.wantedOptionHeader { if val, exists := optResp.Header[k]; exists { if !reflect.DeepEqual(val, v) { - t.Logf("Wanted header:%s to have value:%v, got:%v", k, v, val) - t.Fail() + t.Errorf("Wanted header:%s to have value:%v, got:%v", k, v, val) } continue } - t.Logf("Expected header:%s but not found", k) - t.Fail() + t.Errorf("Expected header:%s but not found", k) } httpReq, err := http.NewRequest(http.MethodGet, sample.urlStr, nil) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } - req, err := NewRequest(MethodREQMOD, urlStr, httpReq, nil) + req, err := NewRequest(context.Background(), MethodREQMOD, urlStr, httpReq, nil) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } - if err := req.ExtendHeader(optResp.Header); err != nil { - t.Log(err.Error()) - t.Fail() + if err := req.extendHeader(optResp.Header); err != nil { + t.Error(err) return } resp, err := client.Do(req) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } if resp.StatusCode != sample.wantedStatusCode { - t.Logf("Wanted status code:%d, got:%d", sample.wantedStatusCode, resp.StatusCode) - t.Fail() + t.Errorf("Wanted status code:%d, got:%d", sample.wantedStatusCode, resp.StatusCode) } if resp.Status != sample.wantedStatus { - t.Logf("Wanted status:%s, got:%s", sample.wantedStatus, resp.Status) - t.Fail() + t.Errorf("Wanted status:%s, got:%s", sample.wantedStatus, resp.Status) } } @@ -489,91 +392,50 @@ func TestClient(t *testing.T) { t.Run("Client Do REQMOD with Custom Driver", func(t *testing.T) { type testSample struct { - urlStr string - wantedStatusCode int - wantedStatus string - wantedTimeout time.Duration - wantedDialerTimeout time.Duration - wantedReadTimeout time.Duration - wantedWriteTimeout time.Duration + urlStr string + wantedStatusCode int + wantedStatus string } sampleTable := []testSample{ { - urlStr: "http://goodifle.com", - wantedStatusCode: http.StatusNoContent, - wantedStatus: "No Modifications", - wantedTimeout: defaultTimeout, - wantedDialerTimeout: 2 * time.Second, - wantedReadTimeout: 2 * time.Second, - wantedWriteTimeout: 2 * time.Second, + urlStr: "http://goodifle.com", + wantedStatusCode: http.StatusNoContent, + wantedStatus: "No Modifications", }, { - urlStr: "http://badfile.com", - wantedStatusCode: http.StatusOK, - wantedStatus: "OK", - wantedTimeout: defaultTimeout, - wantedDialerTimeout: 2 * time.Second, - wantedReadTimeout: 2 * time.Second, - wantedWriteTimeout: 2 * time.Second, + urlStr: "http://badfile.com", + wantedStatusCode: http.StatusOK, + wantedStatus: "OK", }, } for _, sample := range sampleTable { httpReq, err := http.NewRequest(http.MethodGet, sample.urlStr, nil) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } - req, err := NewRequest(MethodREQMOD, fmt.Sprintf("icap://localhost:%d/reqmod", port), httpReq, nil) + req, err := NewRequest(context.Background(), MethodREQMOD, fmt.Sprintf("icap://localhost:%d/reqmod", port), httpReq, nil) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } - client := Client{} - client.SetDriver(&Driver{ - Host: "127.0.0.1", - Port: 1344, - DialerTimeout: 2 * time.Second, - ReadTimeout: 2 * time.Second, - WriteTimeout: 2 * time.Second, - }) + client, _ := NewClient() resp, err := client.Do(req) if err != nil { - t.Log(err.Error()) - t.Fail() + t.Error(err) return } if resp.StatusCode != sample.wantedStatusCode { - t.Logf("Wanted status code:%d, got:%d", sample.wantedStatusCode, resp.StatusCode) - t.Fail() + t.Errorf("Wanted status code:%d, got:%d", sample.wantedStatusCode, resp.StatusCode) } if resp.Status != sample.wantedStatus { - t.Logf("Wanted status:%s, got:%s", sample.wantedStatus, resp.Status) - t.Fail() - } - - if client.Timeout != sample.wantedTimeout { - t.Logf("Wanted timeout to be:%v, got:%v", sample.wantedTimeout, client.Timeout) - t.Fail() - } - if client.scktDriver.DialerTimeout != sample.wantedDialerTimeout { - t.Logf("Wanted DialierTimeout to be:%v , got:%v", sample.wantedDialerTimeout, client.scktDriver.DialerTimeout) - t.Fail() - } - if client.scktDriver.ReadTimeout != sample.wantedReadTimeout { - t.Logf("Wanted ReadTimeout to be:%v , got:%v", sample.wantedReadTimeout, client.scktDriver.ReadTimeout) - t.Fail() - } - if client.scktDriver.WriteTimeout != sample.wantedWriteTimeout { - t.Logf("Wanted WriteTimeout to be:%v , got:%v", sample.wantedWriteTimeout, client.scktDriver.WriteTimeout) - t.Fail() + t.Errorf("Wanted status:%s, got:%s", sample.wantedStatus, resp.Status) } } diff --git a/config.go b/config.go new file mode 100644 index 0000000..94f023a --- /dev/null +++ b/config.go @@ -0,0 +1,33 @@ +package icapclient + +import ( + "time" +) + +// Config is the shared configuration for the icap client library +type Config struct { + ICAPConn ICAPConnConfig +} + +// DefaultConfig returns the default configuration for the icap client library +func DefaultConfig() Config { + return Config{ + ICAPConn: ICAPConnConfig{ + Timeout: 15 * time.Second, + }, + } +} + +// ConfigOption is a function that configures the icap client +type ConfigOption func(*Config) + +// WithICAPConnectionTimeout sets the timeout for the connection to the icap server +func WithICAPConnectionTimeout(timeout time.Duration) ConfigOption { + return func(cfg *Config) { + if timeout <= 0 { + return + } + + cfg.ICAPConn.Timeout = timeout + } +} diff --git a/conn.go b/conn.go new file mode 100644 index 0000000..43b9335 --- /dev/null +++ b/conn.go @@ -0,0 +1,108 @@ +package icapclient + +import ( + "bytes" + "context" + "io" + "net" + "sync" + "time" +) + +// ICAPConnConfig is the configuration for the icap connection +type ICAPConnConfig struct { + // Timeout is the maximum amount of time a connection will be kept open + Timeout time.Duration +} + +// ICAPConn manages the transport layer for ICAP protocol. +type ICAPConn struct { + tcp net.Conn + mu sync.Mutex + timeout time.Duration +} + +// NewICAPConn creates a new connection configuration. +func NewICAPConn(conf ICAPConnConfig) (*ICAPConn, error) { + return &ICAPConn{ + timeout: conf.Timeout, + }, nil +} + +// Connect connects to the ICAP server. +func (c *ICAPConn) Connect(ctx context.Context, address string) error { + dialer := net.Dialer{Timeout: c.timeout} + conn, err := dialer.DialContext(ctx, "tcp", address) + if err != nil { + return err + } + c.tcp = conn + + if c.timeout > 0 { + deadline := time.Now().Add(c.timeout) + if err := c.tcp.SetDeadline(deadline); err != nil { + return err + } + } + + return nil +} + +// Send sends a request to the ICAP server and reads the response. +func (c *ICAPConn) Send(in []byte) ([]byte, error) { + if !c.ok() { + return nil, ErrInvalidConnection + } + + c.mu.Lock() + defer c.mu.Unlock() + + _, err := c.tcp.Write(in) + if err != nil { + return nil, err + } + + var data []byte + buf := make([]byte, 4096) + for { + n, err := c.tcp.Read(buf) + if err != nil && err != io.EOF { + return nil, err + } + + if err == io.EOF || n == 0 { + break + } + + data = append(data, buf[:n]...) + + // Protocol checks for message termination + { + if bytes.Equal(data, []byte(icap100ContinueMsg)) { + break + } + + if bytes.HasSuffix(data, []byte(doubleCRLF)) { + break + } + + if bytes.Contains(data, []byte(icap204NoModsMsg)) { + break + } + } + } + + return data, nil +} + +// Close closes the TCP connection. +func (c *ICAPConn) Close() error { + if !c.ok() { + return ErrInvalidConnection + } + return c.tcp.Close() +} + +func (c *ICAPConn) ok() bool { + return c != nil && c.tcp != nil +} diff --git a/conn_test.go b/conn_test.go new file mode 100644 index 0000000..5777143 --- /dev/null +++ b/conn_test.go @@ -0,0 +1,89 @@ +package icapclient_test + +import ( + "context" + "fmt" + "net" + "testing" + "time" + + "github.com/phayes/freeport" + + icapclient "github.com/egirna/icap-client" +) + +func TestICAPConn_Send(t *testing.T) { + port, err := freeport.GetFreePort() + if err != nil { + t.Fatal(err) + } + + tcp, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + t.Fatal(err) + } + defer tcp.Close() + + clientConn, err := icapclient.NewICAPConn(icapclient.ICAPConnConfig{Timeout: 50 * time.Second}) + if err != nil { + t.Fatal(err) + } + + err = clientConn.Connect(context.Background(), tcp.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + tcpConn, err := tcp.Accept() + if err != nil { + fmt.Println("Error: ", err.Error()) + return + } + defer tcpConn.Close() + + { // test section: send a request to the tcp // icap server + tests := []struct { + name string + messages []string + want string + terminate bool + }{ + { + name: "icap100ContinueMsg", + messages: []string{icapclient.ICAP100ContinueMsg}, + want: icapclient.ICAP100ContinueMsg, + }, + { + name: "doubleCRLF", + messages: []string{"prefix" + icapclient.DoubleCRLF}, + want: "prefix" + icapclient.DoubleCRLF, + }, + { + name: "icap204NoModsMsg", + messages: []string{"prefix" + icapclient.ICAP204NoModsMsg + "suffix"}, + want: "prefix" + icapclient.ICAP204NoModsMsg + "suffix", + }, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("send/receive message: %s", tc.name), func(t *testing.T) { + for _, message := range tc.messages { + _, err = tcpConn.Write([]byte(message)) + if err != nil { + t.Fatal(err) + } + } + + res, err := clientConn.Send(nil) + if err != nil { + t.Fatal(err) + } + + if got := string(res); got != tc.want { + t.Errorf("ICAPConn.Send() = %v, want %v", got, tc.want) + } + }) + } + } +} diff --git a/consts.go b/consts.go deleted file mode 100644 index 28c8bc7..0000000 --- a/consts.go +++ /dev/null @@ -1,57 +0,0 @@ -package icapclient - -import "time" - -// the icap request methods -const ( - MethodOPTIONS = "OPTIONS" - MethodRESPMOD = "RESPMOD" - MethodREQMOD = "REQMOD" -) - -// the error messages -const ( - ErrInvalidScheme = "the url scheme must be icap://" - ErrMethodNotRegistered = "the requested method is not registered" - ErrInvalidHost = "the requested host is invalid" - ErrConnectionNotOpen = "no open connection to close" - ErrInvalidTCPMsg = "invalid tcp message" - ErrREQMODWithNoReq = "http request cannot be nil for method REQMOD" - ErrREQMODWithResp = "http response must be nil for method REQMOD" - ErrRESPMODWithNoResp = "http response cannot be nil for method RESPMOD" -) - -// general constants required for the package -const ( - SchemeICAP = "icap" - ICAPVersion = "ICAP/1.0" - HTTPVersion = "HTTP/1.1" - SchemeHTTPReq = "http_request" - SchemeHTTPResp = "http_response" - CRLF = "\r\n" - DoubleCRLF = "\r\n\r\n" - LF = "\n" - bodyEndIndicator = CRLF + "0" + CRLF - fullBodyEndIndicatorPreviewMode = "; ieof" + DoubleCRLF - icap100ContinueMsg = "ICAP/1.0 100 Continue" + DoubleCRLF - icap204NoModsMsg = "ICAP/1.0 204 No modifications" - defaultChunkLength = 512 - defaultTimeout = 15 * time.Second -) - -// Common ICAP headers -const ( - PreviewHeader = "Preview" - MethodsHeader = "Methods" - AllowHeader = "Allow" - EncapsulatedHeader = "Encapsulated" - TransferPreviewHeader = "Transfer-Preview" - ServiceHeader = "Service" - ISTagHeader = "ISTag" - OptBodyTypeHeader = "Opt-body-type" - MaxConnectionsHeader = "Max-Connections" - OptionsTTLHeader = "Options-TTL" - ServiceIDHeader = "Service-ID" - TransferIgnoreHeader = "Transfer-Ignore" - TransferCompleteHeader = "Transfer-Complete" -) diff --git a/debug.go b/debug.go deleted file mode 100644 index e8b8ce0..0000000 --- a/debug.go +++ /dev/null @@ -1,55 +0,0 @@ -package icapclient - -import ( - "io" - "log" - "os" - - "github.com/davecgh/go-spew/spew" -) - -// the debug mode determiner & the writer to the write the debug output to -var ( - DEBUG = false - debugWriter io.Writer - logger *log.Logger -) - -const ( - debugPrefix = "icap-client says: " -) - -// SetDebugMode sets the debug mode for the entire package depending on the bool -func SetDebugMode(debug bool) { - DEBUG = debug - - if DEBUG { // setting os.Stdout as the default debug writer if debug mode is enabled & also the debug prefix - debugWriter = os.Stdout - logger = log.New(debugWriter, debugPrefix, log.LstdFlags) - - } -} - -// SetDebugOutput sets writer to write the debug outputs (default: os.Stdout) -func SetDebugOutput(w io.Writer) { - debugWriter = w - logger.SetOutput(debugWriter) -} - -func logDebug(a ...interface{}) { - if DEBUG { - logger.Println(a...) - } -} - -func logfDebug(s string, a ...interface{}) { - if DEBUG { - logger.Printf(s, a...) - } -} - -func dumpDebug(a ...interface{}) { - if DEBUG { - spew.Fdump(debugWriter, a...) - } -} diff --git a/doc.go b/doc.go index 15f9d26..f9914c1 100644 --- a/doc.go +++ b/doc.go @@ -1,73 +1,4 @@ -//Package icapclient is a client package for the ICAP protocol +// Package icapclient is a client package for the ICAP protocol // -// Here is a basic example: -// package main -// -// import ( -// "fmt" -// "log" -// "net/http" -// "time" -// -// ic "github.com/egirna/icap-client" -// ) -// -// func main() { -// /* preparing the http request required for the RESPMOD */ -// httpReq, err := http.NewRequest(http.MethodGet, "http://localhost:8000/sample.pdf", nil) -// -// if err != nil { -// log.Fatal(err) -// } -// -// /* making the http client & making the request call to get the response needed for the icap RESPMOD call */ -// httpClient := &http.Client{} -// -// httpResp, err := httpClient.Do(httpReq) -// -// if err != nil { -// log.Fatal(err) -// } -// -// /* making a icap request with OPTIONS method */ -// optReq, err := ic.NewRequest(ic.MethodOPTIONS, "icap://127.0.0.1:1344/respmod", nil, nil) -// -// if err != nil { -// log.Fatal(err) -// return -// } -// -// /* making the icap client responsible for making the requests */ -// client := &ic.Client{ -// Timeout: 5 * time.Second, -// } -// -// /* making the OPTIONS request call */ -// optResp, err := client.Do(optReq) -// -// if err != nil { -// log.Fatal(err) -// return -// } -// -// /* making a icap request with RESPMOD method */ -// req, err := ic.NewRequest(ic.MethodRESPMOD, "icap://127.0.0.1:1344/respmod", httpReq, httpResp) -// -// if err != nil { -// log.Fatal(err) -// } -// -// req.SetPreview(optResp.PreviewBytes) // setting the preview bytes obtained from the OPTIONS call -// -// /* making the RESPMOD request call */ -// resp, err := client.Do(req) -// -// if err != nil { -// log.Fatal(err) -// } -// -// fmt.Println(resp.StatusCode) -// -// } // See https://github.com/egirna/icap-client/examples. package icapclient diff --git a/driver.go b/driver.go deleted file mode 100644 index 0d4bc6c..0000000 --- a/driver.go +++ /dev/null @@ -1,99 +0,0 @@ -package icapclient - -import ( - "bufio" - "context" - "errors" - "fmt" - "strings" - "time" -) - -// Driver os the one responsible for driving the transport layer operations -type Driver struct { - Host string - Port int - DialerTimeout time.Duration - ReadTimeout time.Duration - WriteTimeout time.Duration - tcp *transport -} - -// NewDriver is the factory function for Driver -func NewDriver(host string, port int) *Driver { - return &Driver{ - Host: host, - Port: port, - } -} - -// Connect fires up a tcp socket connection with the icap server -func (d *Driver) Connect() error { - - d.tcp = &transport{ - network: "tcp", - addr: fmt.Sprintf("%s:%d", d.Host, d.Port), - timeout: d.DialerTimeout, - readTimeout: d.ReadTimeout, - writeTimeout: d.WriteTimeout, - } - - return d.tcp.dial() -} - -// ConnectWithContext connects to the server satisfying the context -func (d *Driver) ConnectWithContext(ctx context.Context) error { - d.tcp = &transport{ - network: "tcp", - addr: fmt.Sprintf("%s:%d", d.Host, d.Port), - timeout: d.DialerTimeout, - readTimeout: d.ReadTimeout, - writeTimeout: d.WriteTimeout, - } - - return d.tcp.dialWithContext(ctx) -} - -// Close closes the socket connection -func (d *Driver) Close() error { - if d.tcp == nil { - - return errors.New(ErrConnectionNotOpen) - } - - return d.tcp.close() -} - -// Send sends a request to the icap server -func (d *Driver) Send(data []byte) error { - - _, err := d.tcp.write(data) - - if err != nil { - return err - } - - return nil - -} - -// Receive returns the respone from the tcp socket connection -func (d *Driver) Receive() (*Response, error) { - - msg, err := d.tcp.read() - - if err != nil { - return nil, err - } - - resp, err := ReadResponse(bufio.NewReader(strings.NewReader(msg))) - - if err != nil { - return nil, err - } - - logDebug("The final *ic.Response from tcp messages...") - dumpDebug(resp) - - return resp, nil -} diff --git a/driver_test.go b/driver_test.go deleted file mode 100644 index 25e5f6c..0000000 --- a/driver_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package icapclient - -import ( - "context" - "testing" - "time" -) - -func TestDriver(t *testing.T) { - if !testServerRunning() { - go startTestServer() - } - - t.Run("Driver Connect With Context", func(t *testing.T) { - driver := &Driver{ - Host: "127.0.0.1", - Port: 1344, - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - if err := driver.ConnectWithContext(ctx); err != nil { - t.Log("Driver connection with context failed: ", err.Error()) - t.Fail() - return - } - - if err := driver.Close(); err != nil { - t.Log("Driver connection close failed: ", err.Error()) - t.Fail() - } - - }) - - if testServerRunning() { - defer stopTestServer() - } - -} diff --git a/examples/custom_driver.go b/examples/custom_driver.go deleted file mode 100644 index 9685d1c..0000000 --- a/examples/custom_driver.go +++ /dev/null @@ -1,76 +0,0 @@ -package examples - -import ( - "fmt" - "log" - "net/http" - "time" - - ic "github.com/egirna/icap-client" -) - -func makeRespmodWithCustomDriver() { - - /* preparing the http request required for the RESPMOD */ - httpReq, err := http.NewRequest(http.MethodGet, "http://localhost:8000/sample.pdf", nil) - - if err != nil { - log.Fatal(err) - } - - /* making the http client & making the request call to get the response needed for the icap RESPMOD call */ - httpClient := &http.Client{} - - httpResp, err := httpClient.Do(httpReq) - - if err != nil { - log.Fatal(err) - } - - /* making a icap request with OPTIONS method */ - optReq, err := ic.NewRequest(ic.MethodOPTIONS, "icap://127.0.0.1:1344/respmod", nil, nil) - - if err != nil { - log.Fatal(err) - return - } - - /* making the icap client responsible for making the requests */ - client := &ic.Client{ - Timeout: 5 * time.Second, - } - - /* setting the socket driver for the icap-client with a custom driver in the parameter */ - client.SetDriver(&ic.Driver{ - Host: "127.0.0.1", - Port: 1344, - ReadTimeout: 2 * time.Second, - }) - - /* making the OPTIONS request call */ - optResp, err := client.Do(optReq) - - if err != nil { - log.Fatal(err) - return - } - - /* making a icap request with RESPMOD method */ - req, err := ic.NewRequest(ic.MethodRESPMOD, "icap://127.0.0.1:1344/respmod", httpReq, httpResp) - - if err != nil { - log.Fatal(err) - } - - req.SetPreview(optResp.PreviewBytes) // setting the preview bytes obtained from the OPTIONS call - - /* making the RESPMOD request call */ - resp, err := client.Do(req) - - if err != nil { - log.Fatal(err) - } - - fmt.Println(resp.StatusCode) - -} diff --git a/examples/debug.go b/examples/debug.go deleted file mode 100644 index 61ee1d1..0000000 --- a/examples/debug.go +++ /dev/null @@ -1,47 +0,0 @@ -package examples - -import ( - "fmt" - "log" - "net/http" - "os" - "time" - - ic "github.com/egirna/icap-client" -) - -func reqmodInDebug() { - - /* setting a text file to dump my icap-client Debug logs into */ - f, _ := os.OpenFile("logs.txt", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) - ic.SetDebugMode(true) - ic.SetDebugOutput(f) - - /* making the http request required for the REQMOD */ - httpReq, err := http.NewRequest(http.MethodGet, "http://localhost:8000/sample.pdf", nil) - - if err != nil { - log.Fatal(err) - } - - /* making the icap client & the icap request that'll be made by the client */ - client := &ic.Client{ - Timeout: 500000 * time.Second, - } - - req, err := ic.NewRequest(ic.MethodREQMOD, "icap://127.0.0.1:1344/reqmod", httpReq, nil) - - if err != nil { - log.Fatal(err) - } - - /* making the request call & getting the response */ - resp, err := client.Do(req) - - if err != nil { - log.Fatal(err) - } - - fmt.Println(resp.Status) - -} diff --git a/examples/reqmod.go b/examples/reqmod.go index 887cc94..a6c9934 100644 --- a/examples/reqmod.go +++ b/examples/reqmod.go @@ -1,6 +1,7 @@ package examples import ( + "context" "fmt" "log" "net/http" @@ -10,51 +11,51 @@ import ( ) func makeReqmodCall() { + ctx := context.Background() - /* preparing the http request required for the REQMOD */ + // prepare the http request httpReq, err := http.NewRequest(http.MethodGet, "http://localhost:8000/sample.pdf", nil) - if err != nil { log.Fatal(err) } - /* making a icap request with OPTIONS method */ - optReq, err := ic.NewRequest(ic.MethodOPTIONS, "icap://127.0.0.1:1344/reqmod", nil, nil) - + // create a new icap OPTIONS request + optReq, err := ic.NewRequest(ctx, ic.MethodOPTIONS, "icap://127.0.0.1:1344/reqmod", nil, nil) if err != nil { log.Fatal(err) - return } - /* making the icap client responsible for making the requests */ - client := &ic.Client{ - Timeout: 5 * time.Second, + // create the icap client + client, err := ic.NewClient( + ic.WithICAPConnectionTimeout(5 * time.Second), + ) + if err != nil { + log.Fatal(err) } - /* making the OPTIONS request call */ + // send the OPTIONS request optResp, err := client.Do(optReq) - if err != nil { log.Fatal(err) - return } - /* making a icap request with REQMOD method */ - req, err := ic.NewRequest(ic.MethodREQMOD, "icap://127.0.0.1:1344/reqmod", httpReq, nil) - + // create a new icap REQMOD request + req, err := ic.NewRequest(ctx, ic.MethodREQMOD, "icap://127.0.0.1:1344/reqmod", httpReq, nil) if err != nil { log.Fatal(err) } - req.SetPreview(optResp.PreviewBytes) // setting the preview bytes obtained from the OPTIONS call + // set the preview bytes obtained from the OPTIONS call + err = req.SetPreview(optResp.PreviewBytes) + if err != nil { + log.Fatal(err) + } - /* making the REQMOD request call */ + // send the REQMOD request resp, err := client.Do(req) - if err != nil { log.Fatal(err) } fmt.Println(resp.Status) - } diff --git a/examples/respmod.go b/examples/respmod.go index 2fb8bdb..0af513d 100644 --- a/examples/respmod.go +++ b/examples/respmod.go @@ -1,6 +1,7 @@ package examples import ( + "context" "fmt" "log" "net/http" @@ -10,60 +11,60 @@ import ( ) func makeRespmodCall() { + ctx := context.Background() - /* preparing the http request required for the RESPMOD */ + // prepare the http request httpReq, err := http.NewRequest(http.MethodGet, "http://localhost:8000/sample.pdf", nil) - if err != nil { log.Fatal(err) } - /* making the http client & making the request call to get the response needed for the icap RESPMOD call */ + // create a new http client httpClient := &http.Client{} + // send the http request httpResp, err := httpClient.Do(httpReq) - if err != nil { log.Fatal(err) } - /* making a icap request with OPTIONS method */ - optReq, err := ic.NewRequest(ic.MethodOPTIONS, "icap://127.0.0.1:1344/respmod", nil, nil) - + // create a new icap OPTIONS request + optReq, err := ic.NewRequest(ctx, ic.MethodOPTIONS, "icap://127.0.0.1:1344/respmod", nil, nil) if err != nil { log.Fatal(err) - return } - /* making the icap client responsible for making the requests */ - client := &ic.Client{ - Timeout: 5 * time.Second, + // create the icap client + client, err := ic.NewClient( + ic.WithICAPConnectionTimeout(5 * time.Second), + ) + if err != nil { + log.Fatal(err) } - /* making the OPTIONS request call */ + // send the OPTIONS request optResp, err := client.Do(optReq) - if err != nil { log.Fatal(err) - return } - /* making a icap request with RESPMOD method */ - req, err := ic.NewRequest(ic.MethodRESPMOD, "icap://127.0.0.1:1344/respmod", httpReq, httpResp) - + // create a new icap RESPMOD request + req, err := ic.NewRequest(ctx, ic.MethodRESPMOD, "icap://127.0.0.1:1344/respmod", httpReq, httpResp) if err != nil { log.Fatal(err) } - req.SetPreview(optResp.PreviewBytes) // setting the preview bytes obtained from the OPTIONS call + // set the preview bytes obtained from the OPTIONS call + err = req.SetPreview(optResp.PreviewBytes) + if err != nil { + log.Fatal(err) + } - /* making the RESPMOD request call */ + // send the RESPMOD request resp, err := client.Do(req) - if err != nil { log.Fatal(err) } fmt.Println(resp.StatusCode) - } diff --git a/go.mod b/go.mod index d4ff501..4fa8051 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/egirna/icap-client -go 1.12 +go 1.21 require ( - github.com/davecgh/go-spew v1.1.1 github.com/egirna/icap v0.0.0-20181108071049-d5ee18bd70bc + github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 ) diff --git a/header.go b/header.go deleted file mode 100644 index 67b4d2e..0000000 --- a/header.go +++ /dev/null @@ -1,124 +0,0 @@ -package icapclient - -import ( - "bytes" - "io/ioutil" - "net/http" - "os" - "strconv" -) - -// SetPreview sets the preview bytes in the icap header -func (r *Request) SetPreview(maxBytes int) error { - - bodyBytes := []byte{} - - previewBytes := 0 - - // receiving the body bites to determine the preview bytes depending on the request ICAP method - - if r.Method == MethodREQMOD { - if r.HTTPRequest == nil { - return nil - } - if r.HTTPRequest.Body != nil { - var err error - bodyBytes, err = ioutil.ReadAll(r.HTTPRequest.Body) - - if err != nil { - return err - } - - defer r.HTTPRequest.Body.Close() - } - } - - if r.Method == MethodRESPMOD { - if r.HTTPResponse == nil { - return nil - } - - if r.HTTPResponse.Body != nil { - var err error - bodyBytes, err = ioutil.ReadAll(r.HTTPResponse.Body) - - if err != nil { - return err - } - - defer r.HTTPResponse.Body.Close() - } - } - - previewBytes = len(bodyBytes) - - if previewBytes > 0 { // if the preview byte is 0 or less, there is no question of the body fitting insides - r.bodyFittedInPreview = true - } - - if previewBytes > maxBytes { // if the preview bytes is greater than what was mentioned by the ICAP Server(did not fit in the body) - previewBytes = maxBytes - r.bodyFittedInPreview = false - r.remainingPreviewBytes = bodyBytes[maxBytes:] // storing the rest of the body byte which were not sent as preview for further operations - } - - // returning the body back to the http message depending on the request method - - if r.Method == MethodREQMOD { - r.HTTPRequest.Body = ioutil.NopCloser(bytes.NewReader(bodyBytes)) - } - - if r.Method == MethodRESPMOD { - r.HTTPResponse.Body = ioutil.NopCloser(bytes.NewReader(bodyBytes)) - } - - // finally assinging the preview informations including setting the header - - r.Header.Set("Preview", strconv.Itoa(previewBytes)) - r.PreviewBytes = previewBytes - r.previewSet = true - - return nil - -} - -// SetDefaultRequestHeaders assigns some of the headers with its default value if they are not set already -func (r *Request) SetDefaultRequestHeaders() { - if _, exists := r.Header["Allow"]; !exists { - r.Header.Add("Allow", "204") // assigning 204 by default if Allow not provided - } - if _, exists := r.Header["Host"]; !exists { - hostName, _ := os.Hostname() - r.Header.Add("Host", hostName) - } -} - -// ExtendHeader extends the current ICAP Request header with a new header -func (r *Request) ExtendHeader(hdr http.Header) error { - for header, values := range hdr { - - if header == PreviewHeader && r.previewSet { - continue - } - - if header == EncapsulatedHeader { - continue - } - - for _, value := range values { - if header == PreviewHeader { - pb, err := strconv.Atoi(value) - if err != nil { - return err - } - if err := r.SetPreview(pb); err != nil { - return err - } - continue - } - r.Header.Add(header, value) - } - } - - return nil -} diff --git a/icap_client.go b/icap_client.go new file mode 100644 index 0000000..37cf1f5 --- /dev/null +++ b/icap_client.go @@ -0,0 +1,444 @@ +package icapclient + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httputil" + "regexp" + "strconv" + "strings" +) + +// the icap request methods +const ( + MethodOPTIONS = "OPTIONS" + MethodRESPMOD = "RESPMOD" + MethodREQMOD = "REQMOD" +) + +// shared errors +var ( + // ErrNoContext is used when no context is provided + ErrNoContext = errors.New("no context provided") + + // ErrInvalidScheme is used when the url scheme is not icap:// + ErrInvalidScheme = errors.New("the url scheme must be icap://") + + // ErrMethodNotAllowed is used when the method is not allowed + ErrMethodNotAllowed = errors.New("the requested method is not registered") + + // ErrInvalidHost is used when the host is invalid + ErrInvalidHost = errors.New("the requested host is invalid") + + // ErrInvalidMsg is used when the tcp message is invalid + ErrInvalidMsg = errors.New("invalid message") + + // ErrInvalidConnection is used when the connection is invalid + ErrInvalidConnection = errors.New("invalid connection") + + // ErrREQMODWithoutReq is used when the request is nil for REQMOD method + ErrREQMODWithoutReq = errors.New("http request cannot be nil for method REQMOD") + + // ErrREQMODWithResp is used when the response is not nil for REQMOD method + ErrREQMODWithResp = errors.New("http response must be nil for method REQMOD") + + // ErrRESPMODWithoutResp is used when the response is nil for RESPMOD method + ErrRESPMODWithoutResp = errors.New("http response cannot be nil for method RESPMOD") +) + +// general constants required for the package +const ( + schemeICAP = "icap" + icapVersion = "ICAP/1.0" + httpVersion = "HTTP/1.1" + schemeHTTPReq = "http_request" + schemeHTTPResp = "http_response" + crlf = "\r\n" + doubleCRLF = crlf + crlf + lf = "\n" + bodyEndIndicator = crlf + "0" + crlf + fullBodyEndIndicatorPreviewMode = "; ieof" + doubleCRLF + icap100ContinueMsg = "ICAP/1.0 100 Continue" + doubleCRLF + icap204NoModsMsg = "ICAP/1.0 204 Unmodified" +) + +// Common ICAP headers +const ( + previewHeader = "Preview" + encapsulatedHeader = "Encapsulated" +) + +// Conn represents the connection to the icap server +type Conn interface { + io.Closer + Connect(ctx context.Context, address string) error + Send(in []byte) ([]byte, error) +} + +// Response represents the icap server response data +type Response struct { + StatusCode int + Status string + PreviewBytes int + Header http.Header + ContentRequest *http.Request + ContentResponse *http.Response +} + +// getStatusWithCode prepares the status code and status text from two given strings +func getStatusWithCode(str1, str2 string) (int, string, error) { + statusCode, err := strconv.Atoi(str1) + + if err != nil { + return 0, "", err + } + + status := strings.TrimSpace(str2) + + return statusCode, status, nil +} + +// getHeaderValue parses the header and its value from a tcp message string +func getHeaderValue(str string) (string, string) { + headerValues := strings.SplitN(str, ":", 2) + header := headerValues[0] + + if len(headerValues) >= 2 { + return header, strings.TrimSpace(headerValues[1]) + } + + return header, "" + +} + +// isRequestLine determines if the tcp message string is a request line, i.e., the first line of the message or not +func isRequestLine(str string) bool { + return strings.Contains(str, icapVersion) || strings.Contains(str, httpVersion) +} + +// setEncapsulatedHeaderValue generates the Encapsulated values and assigns to the ICAP request string +func setEncapsulatedHeaderValue(icapReqStr string, httpReqStr, httpRespStr string) string { + encVal := " " + + if strings.HasPrefix(icapReqStr, MethodOPTIONS) { + switch { + // the most common case for OPTIONS method, no Encapsulated body + case httpReqStr == "" && httpRespStr == "": + encVal += "null-body=0" + // if there is an Encapsulated body + default: + encVal += "opt-body=0" + } + } + + if strings.HasPrefix(icapReqStr, MethodREQMOD) || strings.HasPrefix(icapReqStr, MethodRESPMOD) { + // looking for the match of the string \r\n\r\n, + // as that is the expression that separates each block, i.e., headers and bodies + re := regexp.MustCompile(doubleCRLF) + + // getting the offsets of the matches, tells us the starting/ending point of headers and bodies + reqIndices := re.FindAllStringIndex(httpReqStr, -1) + + // is needed to calculate the response headers by adding the last offset of the request block + reqEndsAt := 0 + + if reqIndices != nil { + encVal += "req-hdr=0" + reqEndsAt = reqIndices[0][1] + + switch { + // indicating there is a body present for the request block, as length would have been 1 for a single match of \r\n\r\n + case len(reqIndices) > 1: + encVal += fmt.Sprintf(", req-body=%d", reqIndices[0][1]) // assigning the starting point of the body + reqEndsAt = reqIndices[1][1] + case httpRespStr == "": + encVal += fmt.Sprintf(", null-body=%d", reqIndices[0][1]) + } + + if httpRespStr != "" { + encVal += ", " + } + } + + respIndices := re.FindAllStringIndex(httpRespStr, -1) + + if respIndices != nil { + encVal += fmt.Sprintf("res-hdr=%d", reqEndsAt) + + switch { + case len(respIndices) > 1: + encVal += fmt.Sprintf(", res-body=%d", reqEndsAt+respIndices[0][1]) + default: + encVal += fmt.Sprintf(", null-body=%d", reqEndsAt+respIndices[0][1]) + } + } + + } + + // formatting the ICAP request Encapsulated header with the value + return fmt.Sprintf(icapReqStr, encVal) +} + +// replaceRequestURIWithActualURL replaces just the escaped portion of the url with the entire URL in the dumped request message +func replaceRequestURIWithActualURL(str string, uri, url string) string { + if uri == "" { + uri = "/" + } + + return strings.Replace(str, uri, url, 1) +} + +// addFullBodyInPreviewIndicator adds 0; ieof\r\n\r\n which indicates the entire body fitted in the preview +func addFullBodyInPreviewIndicator(str string) string { + return strings.TrimSuffix(str, doubleCRLF) + fullBodyEndIndicatorPreviewMode +} + +// splitBodyAndHeader separates header and body from a http message +func splitBodyAndHeader(str string) (string, string, bool) { + ss := strings.SplitN(str, doubleCRLF, 2) + + if len(ss) < 2 || ss[1] == "" { + return "", "", false + } + + headerStr := ss[0] + bodyStr := ss[1] + + return headerStr, bodyStr, true +} + +// bodyIsChunked determines if the http body is already chunked from the origin server or not +func bodyIsChunked(str string) bool { + _, bodyStr, ok := splitBodyAndHeader(str) + + if !ok { + return false + } + + return regexp.MustCompile(`\r\n0(\r\n)+$`).MatchString(bodyStr) +} + +// parsePreviewBodyBytes parses the preview portion of the body and only keeps that in the message +func parsePreviewBodyBytes(str string, pb int) string { + headerStr, bodyStr, ok := splitBodyAndHeader(str) + if !ok { + return str + } + + return headerStr + doubleCRLF + bodyStr[:pb] +} + +// addHexBodyByteNotations adds the hexadecimal byte notations to the string, +// for example, Hello World, becomes +// b +// Hello World +// 0 +func addHexBodyByteNotations(str string) string { + return fmt.Sprintf("%x%s%s%s", len([]byte(str)), crlf, str, bodyEndIndicator) +} + +// addHeaderAndBody merges the header and body of the http message +func addHeaderAndBody(headerStr, bodyStr string) string { + return headerStr + doubleCRLF + bodyStr +} + +// toICAPRequest returns the given request in its ICAP/1.x wire +func toICAPRequest(req Request) ([]byte, error) { + // Making the ICAP message block + reqStr := fmt.Sprintf("%s %s %s%s", req.Method, req.URL.String(), icapVersion, crlf) + + for headerName, values := range req.Header { + for _, value := range values { + reqStr += fmt.Sprintf("%s: %s%s", headerName, value, crlf) + } + } + + // will populate the Encapsulated header value after making the http Request & Response messages + reqStr += "Encapsulated: %s" + crlf + reqStr += crlf + + // build the HTTP Request message block + httpReqStr := "" + if req.HTTPRequest != nil { + b, err := httputil.DumpRequestOut(req.HTTPRequest, true) + + if err != nil { + return nil, err + } + + httpReqStr += string(b) + httpReqStr = replaceRequestURIWithActualURL(httpReqStr, req.HTTPRequest.URL.EscapedPath(), req.HTTPRequest.URL.String()) + + if req.Method == MethodREQMOD { + if req.previewSet { + httpReqStr = parsePreviewBodyBytes(httpReqStr, req.PreviewBytes) + } + + if !bodyIsChunked(httpReqStr) { + headerStr, bodyStr, ok := splitBodyAndHeader(httpReqStr) + if ok { + bodyStr = addHexBodyByteNotations(bodyStr) + httpReqStr = addHeaderAndBody(headerStr, bodyStr) + } + } + + } + + // if the HTTP Request message block doesn't end with a \r\n\r\n, + // then going to add one by force for better calculation of byte offsets + if httpReqStr != "" { + for !strings.HasSuffix(httpReqStr, doubleCRLF) { + httpReqStr += crlf + } + } + + } + + // build the HTTP Response message block + httpRespStr := "" + if req.HTTPResponse != nil { + b, err := httputil.DumpResponse(req.HTTPResponse, true) + + if err != nil { + return nil, err + } + + httpRespStr += string(b) + + if req.previewSet { + httpRespStr = parsePreviewBodyBytes(httpRespStr, req.PreviewBytes) + } + + if !bodyIsChunked(httpRespStr) { + headerStr, bodyStr, ok := splitBodyAndHeader(httpRespStr) + if ok { + bodyStr = addHexBodyByteNotations(bodyStr) + httpRespStr = addHeaderAndBody(headerStr, bodyStr) + } + } + + if httpRespStr != "" && !strings.HasSuffix(httpRespStr, doubleCRLF) { // if the HTTP Response message block doesn't end with a \r\n\r\n, then going to add one by force for better calculation of byte offsets + httpRespStr += crlf + } + + } + + if encVal := req.Header.Get(encapsulatedHeader); encVal != "" { + reqStr = fmt.Sprintf(reqStr, encVal) + } else { + //populating the Encapsulated header of the ICAP message portion + reqStr = setEncapsulatedHeaderValue(reqStr, httpReqStr, httpRespStr) + } + + // determining if the http message needs the full body fitted in the preview portion indicator or not + if httpRespStr != "" && req.previewSet && req.bodyFittedInPreview { + httpRespStr = addFullBodyInPreviewIndicator(httpRespStr) + } + + if req.Method == MethodREQMOD && req.previewSet && req.bodyFittedInPreview { + httpReqStr = addFullBodyInPreviewIndicator(httpReqStr) + } + + data := []byte(reqStr + httpReqStr + httpRespStr) + + return data, nil +} + +// toClientResponse reads an ICAP message and returns a Response +func toClientResponse(b *bufio.Reader) (Response, error) { + resp := Response{ + Header: make(map[string][]string), + } + + scheme := "" + httpMsg := "" + for currentMsg, err := b.ReadString('\n'); err == nil || currentMsg != ""; currentMsg, err = b.ReadString('\n') { // keep reading the buffer message which is the http response message + + // if the current message line if the first line of the message portion(request line) + if isRequestLine(currentMsg) { + ss := strings.Split(currentMsg, " ") + + // must contain 3 words, for example, "ICAP/1.0 200 OK" or "GET /something HTTP/1.1" + if len(ss) < 3 { + return Response{}, fmt.Errorf("%w: %s", ErrInvalidMsg, currentMsg) + } + + // preparing the scheme below + if ss[0] == icapVersion { + scheme = schemeICAP + + resp.StatusCode, resp.Status, err = getStatusWithCode(ss[1], strings.Join(ss[2:], " ")) + if err != nil { + return Response{}, err + } + + continue + } + + if ss[0] == httpVersion { + scheme = schemeHTTPResp + httpMsg = "" + } + + // http request message scheme version should always be at the end, + // for example, GET /something HTTP/1.1 + if strings.TrimSpace(ss[2]) == httpVersion { + scheme = schemeHTTPReq + httpMsg = "" + } + } + + // preparing the header for ICAP & contents for the HTTP messages below + if scheme == schemeICAP { + // ignore the CRLF and the LF, shouldn't be counted + if currentMsg == lf || currentMsg == crlf { + continue + } + + header, val := getHeaderValue(currentMsg) + if header == previewHeader { + pb, _ := strconv.Atoi(val) + resp.PreviewBytes = pb + } + + resp.Header.Add(header, val) + } + + if scheme == schemeHTTPReq { + httpMsg += strings.TrimSpace(currentMsg) + crlf + bufferEmpty := b.Buffered() == 0 + + // a crlf indicates the end of the HTTP message and the buffer check is just in case the buffer ended with one last message instead of a crlf + if currentMsg == crlf || bufferEmpty { + request, err := http.ReadRequest(bufio.NewReader(strings.NewReader(httpMsg))) + if err != nil { + return Response{}, err + } + resp.ContentRequest = request + + continue + } + } + + if scheme == schemeHTTPResp { + httpMsg += strings.TrimSpace(currentMsg) + crlf + bufferEmpty := b.Buffered() == 0 + + if currentMsg == crlf || bufferEmpty { + response, err := http.ReadResponse(bufio.NewReader(strings.NewReader(httpMsg)), resp.ContentRequest) + if err != nil { + return Response{}, err + } + resp.ContentResponse = response + + continue + } + } + } + + return resp, nil +} diff --git a/icap_client_test.go b/icap_client_test.go new file mode 100644 index 0000000..7f57247 --- /dev/null +++ b/icap_client_test.go @@ -0,0 +1,526 @@ +package icapclient + +import ( + "bufio" + "bytes" + "context" + "io" + "net/http" + "reflect" + "strings" + "testing" +) + +// export private members for testing +const ( + ICAP100ContinueMsg = icap100ContinueMsg + DoubleCRLF = doubleCRLF + ICAP204NoModsMsg = icap204NoModsMsg +) + +func TestSetEncapsulatedHeaderValue(t *testing.T) { + type testSample struct { + icapReqStr string + httpReqStr string + httpRespStr string + result string + } + + sampleTable := []testSample{ + { + icapReqStr: "REQMOD\r\nEncapsulated: %s\r\n\r\n", + httpReqStr: "GET / HTTP/1.1\r\n" + + "Host: www.origin-server.com\r\n" + + "Accept: text/html, text/plain\r\n" + + "Accept-Encoding: compress\r\n" + + "Cookie: ff39fk3jur@4ii0e02i\r\n" + + "If-None-Match: \"xyzzy\", \"r2d2xxxx\"\r\n\r\n", + httpRespStr: "", + result: "REQMOD\r\nEncapsulated: req-hdr=0, null-body=170\r\n\r\n", + }, + { + icapReqStr: "REQMOD\r\nEncapsulated: %s\r\n\r\n", + httpReqStr: "POST /origin-resource/form.pl HTTP/1.1\r\n" + + "Host: www.origin-server.com\r\n" + + "Accept: text/html, text/plain\r\n" + + "Accept-Encoding: compress\r\n" + + "Pragma: no-cache\r\n\r\n" + + "1e\r\n" + + "I am posting this information.\r\n" + + "0\r\n\r\n", + result: "REQMOD\r\nEncapsulated: req-hdr=0, req-body=147\r\n\r\n", + }, + { + icapReqStr: "RESPMOD\r\nEncapsulated: %s\r\n\r\n", + httpReqStr: "GET /origin-resource HTTP/1.1\r\n" + + "Host: www.origin-server.com\r\n" + + "Accept: text/html, text/plain, image/gif\r\n" + + "Accept-Encoding: gzip, compress\r\n\r\n", + httpRespStr: "HTTP/1.1 200 OK\r\n" + + "Date: Mon, 10 Jan 2000 09:52:22 GMT\r\n" + + "Server: Apache/1.3.6 (Unix)\r\n" + + "ETag: \"63840-1ab7-378d415b\"\r\n" + + "Content-Type: text/html\r\n" + + "Content-Length: 51\r\n\r\n" + + "33\r\n" + + "This is data that was returned by an origin server.\r\n" + + "0\r\n\r\n", + result: "RESPMOD\r\nEncapsulated: req-hdr=0, res-hdr=137, res-body=296\r\n\r\n", + }, + { + icapReqStr: "RESPMOD\r\nEncapsulated: %s\r\n\r\n", + httpReqStr: "POST /origin-resource/form.pl HTTP/1.1\r\n" + + "Host: www.origin-server.com\r\n" + + "Accept: text/html, text/plain\r\n" + + "Accept-Encoding: compress\r\n" + + "Pragma: no-cache\r\n\r\n" + + "1e\r\n" + + "I am posting this information.\r\n" + + "0\r\n\r\n", + httpRespStr: "HTTP/1.1 200 OK\r\n" + + "Date: Mon, 10 Jan 2000 09:52:22 GMT\r\n" + + "Server: Apache/1.3.6 (Unix)\r\n" + + "ETag: \"63840-1ab7-378d415b\"\r\n" + + "Content-Type: text/html\r\n" + + "Content-Length: 51\r\n\r\n" + + "33\r\n" + + "This is data that was returned by an origin server.\r\n" + + "0\r\n\r\n", + result: "RESPMOD\r\nEncapsulated: req-hdr=0, req-body=147, res-hdr=188, res-body=347\r\n\r\n", + }, + { + icapReqStr: "RESPMOD\r\nEncapsulated: %s\r\n\r\n", + httpReqStr: "POST /origin-resource/form.pl HTTP/1.1\r\n" + + "Host: www.origin-server.com\r\n" + + "Accept: text/html, text/plain\r\n" + + "Accept-Encoding: compress\r\n" + + "Pragma: no-cache\r\n\r\n" + + "1e\r\n" + + "I am posting this information.\r\n" + + "0\r\n\r\n", + httpRespStr: "HTTP/1.1 200 OK\r\n" + + "Date: Mon, 10 Jan 2000 09:52:22 GMT\r\n" + + "Server: Apache/1.3.6 (Unix)\r\n" + + "ETag: \"63840-1ab7-378d415b\"\r\n" + + "Content-Type: text/html\r\n" + + "Content-Length: 51\r\n\r\n", + result: "RESPMOD\r\nEncapsulated: req-hdr=0, req-body=147, res-hdr=188, null-body=347\r\n\r\n", + }, + { + icapReqStr: "OPTIONS\r\nEncapsulated: %s\r\n\r\n", + httpReqStr: "", + httpRespStr: "", + result: "OPTIONS\r\nEncapsulated: null-body=0\r\n\r\n", + }, + { + icapReqStr: "OPTIONS\r\nEncapsulated: %s\r\n\r\n", + httpReqStr: "GET /origin-resource HTTP/1.1\r\n" + + "Host: www.origin-server.com\r\n" + + "Accept: text/html, text/plain, image/gif\r\n" + + "Accept-Encoding: gzip, compress\r\n\r\n", + httpRespStr: "", + result: "OPTIONS\r\nEncapsulated: opt-body=0\r\n\r\n", + }, + } + + for _, sample := range sampleTable { + icapReqStr := setEncapsulatedHeaderValue(sample.icapReqStr, sample.httpReqStr, sample.httpRespStr) + if icapReqStr != sample.result { + t.Logf("Wanted icap message after setting encapsulation: %s , got:%s", sample.result, icapReqStr) + t.Fail() + } + } +} + +func TestAddHexaBodyByteNotations(t *testing.T) { + type testSample struct { + msg string + result string + } + + sampleTable := []testSample{ + { + msg: "Hello World!", + result: "c\r\nHello World!\r\n0\r\n", + }, + { + msg: "This is another message. Alright bye!", + result: "25\r\nThis is another message. Alright bye!\r\n0\r\n", + }, + } + + for _, sample := range sampleTable { + msg := addHexBodyByteNotations(sample.msg) + if msg != sample.result { + t.Logf("Wanted message after adding hexa body notations: %s, got:%s", sample.result, msg) + t.Fail() + } + } +} + +func TestParsePreviewBodyBytes(t *testing.T) { + type testSample struct { + previewBytes int + httpMsg string + result string + } + + sampleTable := []testSample{ + { + previewBytes: 10, + httpMsg: "HTTP/1.1 200 OK\r\n" + + "Date: Mon, 10 Jan 2000 09:52:22 GMT\r\n" + + "Server: Apache/1.3.6 (Unix)\r\n" + + "ETag: \"63840-1ab7-378d415b\"\r\n" + + "Content-Type: text/html\r\n" + + "Content-Length: 51\r\n\r\n" + + "This is data that was returned by an origin server.\r\n\r\n", + result: "HTTP/1.1 200 OK\r\n" + + "Date: Mon, 10 Jan 2000 09:52:22 GMT\r\n" + + "Server: Apache/1.3.6 (Unix)\r\n" + + "ETag: \"63840-1ab7-378d415b\"\r\n" + + "Content-Type: text/html\r\n" + + "Content-Length: 51\r\n\r\n" + + "This is da", + }, + { + previewBytes: 10, + httpMsg: "POST /origin-resource/form.pl HTTP/1.1\r\n" + + "Host: www.origin-server.com\r\n" + + "Accept: text/html, text/plain\r\n" + + "Accept-Encoding: compress\r\n" + + "Pragma: no-cache\r\n\r\n" + + "I am posting this information.\r\n", + result: "POST /origin-resource/form.pl HTTP/1.1\r\n" + + "Host: www.origin-server.com\r\n" + + "Accept: text/html, text/plain\r\n" + + "Accept-Encoding: compress\r\n" + + "Pragma: no-cache\r\n\r\n" + + "I am posti", + }, + } + + for _, sample := range sampleTable { + httpMsg := parsePreviewBodyBytes(sample.httpMsg, sample.previewBytes) + if httpMsg != sample.result { + t.Logf("Wanted http message after parsing to be: %s , got: %s", sample.result, httpMsg) + t.Fail() + } + } +} + +func TestToICAPMessage(t *testing.T) { + t.Run("MethodOPTIONS", func(t *testing.T) { + + req, _ := NewRequest(context.Background(), MethodOPTIONS, "icap://localhost:1344/something", nil, nil) + + icapRequest, err := toICAPRequest(req) + + if err != nil { + t.Fatal(err.Error()) + } + + wanted := "OPTIONS icap://localhost:1344/something ICAP/1.0\r\n" + + "Encapsulated: null-body=0\r\n\r\n" + + got := string(icapRequest) + + if wanted != got { + t.Logf("wanted: %s, got: %s\n", wanted, got) + t.Fail() + } + + }) + + t.Run("MethodREQMOD", func(t *testing.T) { // FIXME: add proper wanted string and complete this unit test + httpReq, _ := http.NewRequest(http.MethodGet, "http://someurl.com", nil) + + req, _ := NewRequest(context.Background(), MethodREQMOD, "icap://localhost:1344/something", httpReq, nil) + + icapRequest, err := toICAPRequest(req) + if err != nil { + t.Fatal(err.Error()) + } + + wanted := "REQMOD icap://localhost:1344/something ICAP/1.0\r\n" + + "Encapsulated: req-hdr=0, null-body=109\r\n\r\n" + + "GET http://someurl.com HTTP/1.1\r\n" + + "Host: someurl.com\r\n" + + "User-Agent: Go-http-client/1.1\r\n" + + "Accept-Encoding: gzip\r\n\r\n" + + got := string(icapRequest) + + if wanted != got { + t.Logf("wanted: \n%s\ngot: \n%s\n", wanted, got) + t.Fail() + } + + httpReq, _ = http.NewRequest(http.MethodPost, "http://someurl.com", bytes.NewBufferString("Hello World")) + + req, _ = NewRequest(context.Background(), MethodREQMOD, "icap://localhost:1344/something", httpReq, nil) + + icapRequest, err = toICAPRequest(req) + if err != nil { + t.Fatal(err.Error()) + } + + wanted = "REQMOD icap://localhost:1344/something ICAP/1.0\r\n" + + "Encapsulated: req-hdr=0, req-body=130\r\n\r\n" + + "POST http://someurl.com HTTP/1.1\r\n" + + "Host: someurl.com\r\n" + + "User-Agent: Go-http-client/1.1\r\n" + + "Content-Length: 11\r\n" + + "Accept-Encoding: gzip\r\n\r\n" + + "b\r\n" + + "Hello World\r\n" + + "0\r\n\r\n" + + got = string(icapRequest) + + if wanted != got { + t.Logf("wanted: \n%s\ngot: \n%s\n", wanted, got) + t.Fail() + } + }) + + t.Run("MethodRESPMOD", func(t *testing.T) { + httpReq, _ := http.NewRequest(http.MethodPost, "http://someurl.com", bytes.NewBufferString("Hello World")) + httpResp := &http.Response{ + Status: "200 OK", + StatusCode: http.StatusOK, + Proto: "HTTP/1.0", + ProtoMajor: 1, + ProtoMinor: 0, + Header: http.Header{ + "Content-Type": []string{"plain/text"}, + "Content-Length": []string{"11"}, + }, + ContentLength: 11, + Body: io.NopCloser(strings.NewReader("Hello World")), + } + + req, _ := NewRequest(context.Background(), MethodRESPMOD, "icap://localhost:1344/something", httpReq, httpResp) + + icapRequest, err := toICAPRequest(req) + if err != nil { + t.Fatal(err.Error()) + } + + wanted := "RESPMOD icap://localhost:1344/something ICAP/1.0\r\n" + + "Encapsulated: req-hdr=0, req-body=130, res-hdr=145, res-body=210\r\n\r\n" + + "POST http://someurl.com HTTP/1.1\r\n" + + "Host: someurl.com\r\n" + + "User-Agent: Go-http-client/1.1\r\n" + + "Content-Length: 11\r\n" + + "Accept-Encoding: gzip\r\n\r\n" + + "Hello World\r\n\r\n" + + "HTTP/1.0 200 OK\r\n" + + "Content-Length: 11\r\n" + + "Content-Type: plain/text\r\n\r\n" + + "b\r\n" + + "Hello World\r\n" + + "0\r\n\r\n" + + got := string(icapRequest) + + if wanted != got { + t.Logf("wanted: \n%s\ngot: \n%s\n", wanted, got) + t.Fail() + } + }) +} + +func TestToClientResponse(t *testing.T) { + // FIXME: headers and content request aren't being tested properly + t.Run("REQMOD", func(t *testing.T) { + type testSample struct { + headers http.Header + status string + statusCode int + previewBytes int + respStr string + httpReqStr string + } + + sampleTable := []testSample{ + { + headers: http.Header{ + "Date": []string{"Mon, 10 Jan 2000 09:55:21 GMT"}, + "Server": []string{"ICAP-Server-Software/1.0"}, + "Istag": []string{"\"W3E4R7U9-L2E4-2\""}, + "Encapsulated": []string{"req-hdr=0, null-body=231"}, + }, + status: "OK", + statusCode: 200, + previewBytes: 0, + respStr: "ICAP/1.0 200 OK\r\n" + + "Date: Mon, 10 Jan 2000 09:55:21 GMT\r\n" + + "Server: ICAP-Server-Software/1.0\r\n" + + "Connection: close\r\n" + + "Istag: \"W3E4R7U9-L2E4-2\"\r\n" + + "Encapsulated: req-hdr=0, null-body=231\r\n\r\n", + httpReqStr: "GET /modified-path HTTP/1.1\r\n" + + "Host: www.origin-server.com\r\n" + + "Via: 1.0 icap-server.net (ICAP Example ReqMod Service 1.1)\r\n" + + "Accept: text/html, text/plain, image/gif\r\n" + + "Accept-Encoding: gzip, compress\r\n" + + "If-None-Match: \"xyzzy\", \"r2d2xxxx\"\r\n\r\n", + }, + { + headers: http.Header{ + "Date": []string{"Mon, 10 Jan 2000 09:55:21 GMT"}, + "Server": []string{"ICAP-Server-Software/1.0"}, + "Istag": []string{"\"W3E4R7U9-L2E4-2\""}, + "Encapsulated": []string{"req-hdr=0, req-body=244"}, + }, + status: "OK", + statusCode: 200, + previewBytes: 0, + respStr: "ICAP/1.0 200 OK\r\n" + + "Date: Mon, 10 Jan 2000 09:55:21 GMT\r\n" + + "Server: ICAP-Server-Software/1.0\r\n" + + "Connection: close\r\n" + + "Istag: \"W3E4R7U9-L2E4-2\"\r\n" + + "Encapsulated: req-hdr=0, req-body=244\r\n\r\n", + httpReqStr: "POST /origin-resource/form.pl HTTP/1.1\r\n" + + "Host: www.origin-server.com\r\n" + + "Via: 1.0 icap-server.net (ICAP Example ReqMod Service 1.1)\r\n" + + "Accept: text/html, text/plain, image/gif\r\n" + + "Accept-Encoding: gzip, compress\r\n" + + "Pragma: no-cache\r\n" + + "Content-Length: 45\r\n\r\n" + + "2d\r\n" + + "I am posting this information. ICAP powered!\r\n" + + "0\r\n\r\n", + }, + } + + for _, sample := range sampleTable { + resp, err := toClientResponse(bufio.NewReader(strings.NewReader(sample.respStr + sample.httpReqStr))) + if err != nil { + t.Fatal(err.Error()) + } + + if resp.StatusCode != sample.statusCode { + t.Logf("Wanted ICAP status code: %d , got: %d", sample.statusCode, resp.StatusCode) + t.Fail() + } + if resp.Status != sample.status { + t.Logf("Wanted ICAP status: %s , got: %s", sample.status, resp.Status) + t.Fail() + } + if resp.PreviewBytes != sample.previewBytes { + t.Logf("Wanted preview bytes: %d, got: %d", sample.previewBytes, resp.PreviewBytes) + t.Fail() + } + + for k, v := range sample.headers { + if val, exists := resp.Header[k]; !exists || !reflect.DeepEqual(val, v) { + t.Logf("Wanted Header: %s with value: %v, got: %v", k, v, val) + t.Fail() + break + } + } + if resp.ContentRequest == nil { + t.Log("ContentRequest is nil") + t.Fail() + } + + wantedHTTPReq, err := http.ReadRequest(bufio.NewReader(strings.NewReader(sample.httpReqStr))) + if err != nil { + t.Fatal(err.Error()) + } + + if !reflect.DeepEqual(resp.ContentRequest, wantedHTTPReq) { + t.Logf("Wanted http request: %v, got: %v", wantedHTTPReq, resp.ContentRequest) + t.Fail() + } + + } + + }) + + t.Run("RESPMOD", func(t *testing.T) { + type testSample struct { + headers http.Header + status string + statusCode int + previewBytes int + respStr string + httpRespStr string + } + + sampleTable := []testSample{ + { + headers: http.Header{ + "Date": []string{"Mon, 10 Jan 2000 09:55:21 GMT"}, + "Server": []string{"ICAP-Server-Software/1.0"}, + "Istag": []string{"\"W3E4R7U9-L2E4-2\""}, + "Encapsulated": []string{"req-hdr=0, res-body=222"}, + }, + status: "OK", + statusCode: 200, + previewBytes: 0, + respStr: "ICAP/1.0 200 OK\r\n" + + "Date: Mon, 10 Jan 2000 09:55:21 GMT\r\n" + + "Server: ICAP-Server-Software/1.0\r\n" + + "Connection: close\r\n" + + "ISTag: \"W3E4R7U9-L2E4-2\"\r\n" + + "Encapsulated: req-hdr=0, res-body=222\r\n\r\n", + httpRespStr: "HTTP/1.1 200 OK\r\n" + + "Date: Mon, 10 Jan 2000 09:55:21 GMT\r\n" + + "Via: 1.0 icap.example.org (ICAP Example RespMod Service 1.1)\r\n" + + "Server: Apache/1.3.6 (Unix)\r\n" + + "ETag: \"63840-1ab7-378d415b\"\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Length: 92\r\n\r\n" + + "5c\r\n" + + "This is data that was returned by an origin server, but with value added by an ICAP server.\r\n" + + "0\r\n\r\n", + }, + } + + for _, sample := range sampleTable { + resp, err := toClientResponse(bufio.NewReader(strings.NewReader(sample.respStr + sample.httpRespStr))) + if err != nil { + t.Fatal(err.Error()) + } + + if resp.StatusCode != sample.statusCode { + t.Logf("Wanted ICAP status code: %d , got: %d", sample.statusCode, resp.StatusCode) + t.Fail() + } + if resp.Status != sample.status { + t.Logf("Wanted ICAP status: %s , got: %s", sample.status, resp.Status) + t.Fail() + } + if resp.PreviewBytes != sample.previewBytes { + t.Logf("Wanted preview bytes: %d, got: %d", sample.previewBytes, resp.PreviewBytes) + t.Fail() + } + + for k, v := range sample.headers { + if val, exists := resp.Header[k]; !exists || !reflect.DeepEqual(val, v) { + t.Logf("Wanted Header: %s with value: %v, got: %v", k, v, val) + t.Fail() + break + } + } + if resp.ContentResponse == nil { + t.Log("ContentResponse is nil") + t.Fail() + } + + wantedHTTPResp, err := http.ReadResponse(bufio.NewReader(strings.NewReader(sample.httpRespStr)), nil) + if err != nil { + t.Fatal(err.Error()) + } + + if !reflect.DeepEqual(resp.ContentResponse, wantedHTTPResp) { + t.Logf("Wanted http response: %v, got: %v", wantedHTTPResp, resp.ContentResponse) + t.Fail() + } + } + }) +} diff --git a/methods.go b/methods.go deleted file mode 100644 index e1a4bf5..0000000 --- a/methods.go +++ /dev/null @@ -1,9 +0,0 @@ -package icapclient - -var ( - registeredMethods = map[string]bool{ - MethodOPTIONS: true, - MethodRESPMOD: true, - MethodREQMOD: true, - } -) diff --git a/parser.go b/parser.go deleted file mode 100644 index 8f2d21c..0000000 --- a/parser.go +++ /dev/null @@ -1,179 +0,0 @@ -package icapclient - -import ( - "fmt" - "regexp" - "strconv" - "strings" -) - -// getStatusWithCode prepares the status code and status text from two given strings -func getStatusWithCode(str1, str2 string) (int, string, error) { - - statusCode, err := strconv.Atoi(str1) - - if err != nil { - return 0, "", err - } - - status := strings.TrimSpace(str2) - - return statusCode, status, nil -} - -// getHeaderVal parses the header and its value from a tcp message string -func getHeaderVal(str string) (string, string) { - - headerVals := strings.SplitN(str, ":", 2) - header := headerVals[0] - val := "" - - if len(headerVals) >= 2 { - val = strings.TrimSpace(headerVals[1]) - } - - return header, val - -} - -// isRequestLine determines if the tcp message string is a request line, i.e the first line of the message or not -func isRequestLine(str string) bool { - return strings.Contains(str, ICAPVersion) || strings.Contains(str, HTTPVersion) -} - -// setEncapsulatedHeaderValue generates the Encapsulated values and assigns to the ICAP request string -func setEncapsulatedHeaderValue(icapReqStr *string, httpReqStr, httpRespStr string) { - encpVal := " " - - if strings.HasPrefix(*icapReqStr, MethodOPTIONS) { // if the request method is OPTIONS - if httpReqStr == "" && httpRespStr == "" { // the most common case for OPTIONS method, no Encapsulated body - encpVal += "null-body=0" - } else { - encpVal += "opt-body=0" // if there is an Encapsulated body - } - } - - if strings.HasPrefix(*icapReqStr, MethodREQMOD) || strings.HasPrefix(*icapReqStr, MethodRESPMOD) { // if the request method is RESPMOD or REQMOD - re := regexp.MustCompile(DoubleCRLF) // looking for the match of the string \r\n\r\n, as that is the expression that seperates each blocks, i.e headers and bodies - reqIndices := re.FindAllStringIndex(httpReqStr, -1) // getting the offsets of the matches, tells us the starting/ending point of headers and bodies - - reqEndsAt := 0 // this is needed to calculate the response headers by adding the last offset of the request block - if reqIndices != nil { - encpVal += "req-hdr=0" - reqEndsAt = reqIndices[0][1] - if len(reqIndices) > 1 { // indicating there is a body present for the request block, as length would have been 1 for a single match of \r\n\r\n - encpVal += fmt.Sprintf(", req-body=%d", reqIndices[0][1]) // assigning the starting point of the body - reqEndsAt = reqIndices[1][1] - } else if httpRespStr == "" { - encpVal += fmt.Sprintf(", null-body=%d", reqIndices[0][1]) - } - if httpRespStr != "" { - encpVal += ", " - } - } - - respIndices := re.FindAllStringIndex(httpRespStr, -1) - - if respIndices != nil { - encpVal += fmt.Sprintf("res-hdr=%d", reqEndsAt) - if len(respIndices) > 1 { - encpVal += fmt.Sprintf(", res-body=%d", reqEndsAt+respIndices[0][1]) - } else { - encpVal += fmt.Sprintf(", null-body=%d", reqEndsAt+respIndices[0][1]) - } - } - - } - - *icapReqStr = fmt.Sprintf(*icapReqStr, encpVal) // formatting the ICAP request Encapsulated header with the value -} - -// replaceRequestURIWithActualURL replaces the just the escaped portion of the url with the entire URL in the dumped request message -func replaceRequestURIWithActualURL(str *string, uri, url string) { - if uri == "" { - uri = "/" - } - *str = strings.Replace(*str, uri, url, 1) -} - -// addFullBodyInPreviewIndicator adds 0; ieof\r\n\r\n which indicates the entire body fitted in preview -func addFullBodyInPreviewIndicator(str *string) { - *str = strings.TrimSuffix(*str, DoubleCRLF) - *str += fullBodyEndIndicatorPreviewMode -} - -// splitBodyAndHeader separates header and body from a http message -func splitBodyAndHeader(str string) (string, string, bool) { - ss := strings.SplitN(str, DoubleCRLF, 2) - - if len(ss) < 2 || ss[1] == "" { - return "", "", false - } - - headerStr := ss[0] - bodyStr := ss[1] - - return headerStr, bodyStr, true -} - -// bodyAlreadyChunked determines if the http body is already chunked from the origin server or not -func bodyAlreadyChunked(str string) bool { - _, bodyStr, ok := splitBodyAndHeader(str) - - if !ok { - return false - } - - r := regexp.MustCompile("\\r\\n0(\\r\\n)+$") - return r.MatchString(bodyStr) - -} - -// parsePreviewBodyBytes parses the preview portion of the body and only keeps that in the message -func parsePreviewBodyBytes(str *string, pb int) { - - headerStr, bodyStr, ok := splitBodyAndHeader(*str) - - if !ok { - return - } - - bodyStr = bodyStr[:pb] - - *str = headerStr + DoubleCRLF + bodyStr -} - -// addHexaBodyByteNotations adds the hexadecimal byte notaions in the messages -// for example: Hello World, becomes -// b -// Hello World -// 0 -func addHexaBodyByteNotations(bodyStr *string) { - - bodyBytes := []byte(*bodyStr) - - *bodyStr = fmt.Sprintf("%x%s%s%s", len(bodyBytes), CRLF, *bodyStr, bodyEndIndicator) -} - -// mergeHeaderAndBody merges the header and body of the http message togather -func mergeHeaderAndBody(src *string, headerStr, bodyStr string) { - *src = headerStr + DoubleCRLF + bodyStr -} - -func chunkBodyByBytes(bdyByte []byte, cl int) []byte { - - newBytes := []byte{} - - for i := 0; i < len(bdyByte); i += cl { - end := i + cl - if end > len(bdyByte) { - end = len(bdyByte) - } - - newBytes = append(newBytes, []byte(fmt.Sprintf("%x\r\n", len(bdyByte[i:end]))+string(bdyByte[i:end]))...) - } - - newBytes = append(newBytes, []byte(bodyEndIndicator)...) - - return newBytes -} diff --git a/parser_test.go b/parser_test.go deleted file mode 100644 index 892be04..0000000 --- a/parser_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package icapclient - -import ( - "testing" -) - -func TestParser(t *testing.T) { - - t.Run("setEncapsulatedHeaderValue", func(t *testing.T) { - - type testSample struct { - icapReqStr string - httpReqStr string - httpRespStr string - result string - } - - sampleTable := []testSample{ - { - icapReqStr: "REQMOD\r\nEncapsulated: %s\r\n\r\n", - httpReqStr: "GET / HTTP/1.1\r\n" + - "Host: www.origin-server.com\r\n" + - "Accept: text/html, text/plain\r\n" + - "Accept-Encoding: compress\r\n" + - "Cookie: ff39fk3jur@4ii0e02i\r\n" + - "If-None-Match: \"xyzzy\", \"r2d2xxxx\"\r\n\r\n", - httpRespStr: "", - result: "REQMOD\r\nEncapsulated: req-hdr=0, null-body=170\r\n\r\n", - }, - { - icapReqStr: "REQMOD\r\nEncapsulated: %s\r\n\r\n", - httpReqStr: "POST /origin-resource/form.pl HTTP/1.1\r\n" + - "Host: www.origin-server.com\r\n" + - "Accept: text/html, text/plain\r\n" + - "Accept-Encoding: compress\r\n" + - "Pragma: no-cache\r\n\r\n" + - "1e\r\n" + - "I am posting this information.\r\n" + - "0\r\n\r\n", - result: "REQMOD\r\nEncapsulated: req-hdr=0, req-body=147\r\n\r\n", - }, - { - icapReqStr: "RESPMOD\r\nEncapsulated: %s\r\n\r\n", - httpReqStr: "GET /origin-resource HTTP/1.1\r\n" + - "Host: www.origin-server.com\r\n" + - "Accept: text/html, text/plain, image/gif\r\n" + - "Accept-Encoding: gzip, compress\r\n\r\n", - httpRespStr: "HTTP/1.1 200 OK\r\n" + - "Date: Mon, 10 Jan 2000 09:52:22 GMT\r\n" + - "Server: Apache/1.3.6 (Unix)\r\n" + - "ETag: \"63840-1ab7-378d415b\"\r\n" + - "Content-Type: text/html\r\n" + - "Content-Length: 51\r\n\r\n" + - "33\r\n" + - "This is data that was returned by an origin server.\r\n" + - "0\r\n\r\n", - result: "RESPMOD\r\nEncapsulated: req-hdr=0, res-hdr=137, res-body=296\r\n\r\n", - }, - { - icapReqStr: "RESPMOD\r\nEncapsulated: %s\r\n\r\n", - httpReqStr: "POST /origin-resource/form.pl HTTP/1.1\r\n" + - "Host: www.origin-server.com\r\n" + - "Accept: text/html, text/plain\r\n" + - "Accept-Encoding: compress\r\n" + - "Pragma: no-cache\r\n\r\n" + - "1e\r\n" + - "I am posting this information.\r\n" + - "0\r\n\r\n", - httpRespStr: "HTTP/1.1 200 OK\r\n" + - "Date: Mon, 10 Jan 2000 09:52:22 GMT\r\n" + - "Server: Apache/1.3.6 (Unix)\r\n" + - "ETag: \"63840-1ab7-378d415b\"\r\n" + - "Content-Type: text/html\r\n" + - "Content-Length: 51\r\n\r\n" + - "33\r\n" + - "This is data that was returned by an origin server.\r\n" + - "0\r\n\r\n", - result: "RESPMOD\r\nEncapsulated: req-hdr=0, req-body=147, res-hdr=188, res-body=347\r\n\r\n", - }, - { - icapReqStr: "RESPMOD\r\nEncapsulated: %s\r\n\r\n", - httpReqStr: "POST /origin-resource/form.pl HTTP/1.1\r\n" + - "Host: www.origin-server.com\r\n" + - "Accept: text/html, text/plain\r\n" + - "Accept-Encoding: compress\r\n" + - "Pragma: no-cache\r\n\r\n" + - "1e\r\n" + - "I am posting this information.\r\n" + - "0\r\n\r\n", - httpRespStr: "HTTP/1.1 200 OK\r\n" + - "Date: Mon, 10 Jan 2000 09:52:22 GMT\r\n" + - "Server: Apache/1.3.6 (Unix)\r\n" + - "ETag: \"63840-1ab7-378d415b\"\r\n" + - "Content-Type: text/html\r\n" + - "Content-Length: 51\r\n\r\n", - result: "RESPMOD\r\nEncapsulated: req-hdr=0, req-body=147, res-hdr=188, null-body=347\r\n\r\n", - }, - { - icapReqStr: "OPTIONS\r\nEncapsulated: %s\r\n\r\n", - httpReqStr: "", - httpRespStr: "", - result: "OPTIONS\r\nEncapsulated: null-body=0\r\n\r\n", - }, - { - icapReqStr: "OPTIONS\r\nEncapsulated: %s\r\n\r\n", - httpReqStr: "GET /origin-resource HTTP/1.1\r\n" + - "Host: www.origin-server.com\r\n" + - "Accept: text/html, text/plain, image/gif\r\n" + - "Accept-Encoding: gzip, compress\r\n\r\n", - httpRespStr: "", - result: "OPTIONS\r\nEncapsulated: opt-body=0\r\n\r\n", - }, - } - - for _, sample := range sampleTable { - setEncapsulatedHeaderValue(&sample.icapReqStr, sample.httpReqStr, sample.httpRespStr) - if sample.icapReqStr != sample.result { - t.Logf("Wanted icap message after setting encapsulation: %s , got:%s", sample.result, sample.icapReqStr) - t.Fail() - } - } - - }) - - t.Run("addHexaBodyByteNotations", func(t *testing.T) { - - type testSample struct { - msg string - result string - } - - sampleTable := []testSample{ - { - msg: "Hello World!", - result: "c\r\nHello World!\r\n0\r\n", - }, - { - msg: "This is another message. Alright bye!", - result: "25\r\nThis is another message. Alright bye!\r\n0\r\n", - }, - } - - for _, sample := range sampleTable { - addHexaBodyByteNotations(&sample.msg) - if sample.msg != sample.result { - t.Logf("Wanted message after adding hexa body notations: %s, got:%s", sample.result, sample.msg) - t.Fail() - } - } - - }) - - t.Run("parsePreviewBodyBytes", func(t *testing.T) { - - type testSample struct { - previewBytes int - httpMsg string - result string - } - - sampleTable := []testSample{ - { - previewBytes: 10, - httpMsg: "HTTP/1.1 200 OK\r\n" + - "Date: Mon, 10 Jan 2000 09:52:22 GMT\r\n" + - "Server: Apache/1.3.6 (Unix)\r\n" + - "ETag: \"63840-1ab7-378d415b\"\r\n" + - "Content-Type: text/html\r\n" + - "Content-Length: 51\r\n\r\n" + - "This is data that was returned by an origin server.\r\n\r\n", - result: "HTTP/1.1 200 OK\r\n" + - "Date: Mon, 10 Jan 2000 09:52:22 GMT\r\n" + - "Server: Apache/1.3.6 (Unix)\r\n" + - "ETag: \"63840-1ab7-378d415b\"\r\n" + - "Content-Type: text/html\r\n" + - "Content-Length: 51\r\n\r\n" + - "This is da", - }, - { - previewBytes: 10, - httpMsg: "POST /origin-resource/form.pl HTTP/1.1\r\n" + - "Host: www.origin-server.com\r\n" + - "Accept: text/html, text/plain\r\n" + - "Accept-Encoding: compress\r\n" + - "Pragma: no-cache\r\n\r\n" + - "I am posting this information.\r\n", - result: "POST /origin-resource/form.pl HTTP/1.1\r\n" + - "Host: www.origin-server.com\r\n" + - "Accept: text/html, text/plain\r\n" + - "Accept-Encoding: compress\r\n" + - "Pragma: no-cache\r\n\r\n" + - "I am posti", - }, - } - - for _, sample := range sampleTable { - parsePreviewBodyBytes(&sample.httpMsg, sample.previewBytes) - if sample.httpMsg != sample.result { - t.Logf("Wanted http message after parsing to be: %s , got: %s", sample.result, sample.httpMsg) - t.Fail() - } - } - - }) - -} diff --git a/request.go b/request.go index 021da3b..82907c9 100644 --- a/request.go +++ b/request.go @@ -1,11 +1,15 @@ package icapclient import ( + "bytes" "context" - "fmt" + "errors" + "io" "net/http" - "net/http/httputil" "net/url" + "os" + "slices" + "strconv" "strings" ) @@ -18,143 +22,201 @@ type Request struct { HTTPResponse *http.Response ChunkLength int PreviewBytes int - ctx *context.Context + ctx context.Context previewSet bool bodyFittedInPreview bool remainingPreviewBytes []byte } -// NewRequest is the factory function for Request -func NewRequest(method, urlStr string, httpReq *http.Request, httpResp *http.Response) (*Request, error) { - - method = strings.ToUpper(method) - +// NewRequest returns a new Request given a context, method, url, http request and http response +// todo: method iota +func NewRequest(ctx context.Context, method, urlStr string, httpReq *http.Request, httpResp *http.Response) (Request, error) { u, err := url.Parse(urlStr) if err != nil { - return nil, err + return Request{}, err } - req := &Request{ - Method: method, + req := Request{ + Method: strings.ToUpper(method), URL: u, Header: make(map[string][]string), HTTPRequest: httpReq, HTTPResponse: httpResp, + ctx: ctx, } - if err := req.Validate(); err != nil { - return nil, err + if err := req.validate(); err != nil { + return Request{}, err } return req, nil } -// DumpRequest returns the given request in its ICAP/1.x wire -// representation. -func DumpRequest(req *Request) ([]byte, error) { +// SetPreview sets the preview bytes in the icap header +// todo: defer close error +func (r *Request) SetPreview(maxBytes int) (err error) { + var bodyBytes []byte + var previewBytes int - // Making the ICAP message block + // receiving the body bites to determine the preview bytes depending on the request ICAP method + if r.Method == MethodREQMOD { + if r.HTTPRequest == nil { + return nil + } - reqStr := fmt.Sprintf("%s %s %s%s", req.Method, req.URL.String(), ICAPVersion, CRLF) + if r.HTTPRequest.Body != nil { + b, err := io.ReadAll(r.HTTPRequest.Body) + if err != nil { + return err + } + bodyBytes = b - for headerName, vals := range req.Header { - for _, val := range vals { - reqStr += fmt.Sprintf("%s: %s%s", headerName, val, CRLF) + defer func() { + err = errors.Join(err, r.HTTPRequest.Body.Close()) + }() } } - reqStr += "Encapsulated: %s" + CRLF // will populate the Encapsulated header value after making the http Request & Response messages - reqStr += CRLF - - // Making the HTTP Request message block + if r.Method == MethodRESPMOD { + if r.HTTPResponse == nil { + return nil + } - httpReqStr := "" - if req.HTTPRequest != nil { - b, err := httputil.DumpRequestOut(req.HTTPRequest, true) + if r.HTTPResponse.Body != nil { + b, err := io.ReadAll(r.HTTPResponse.Body) + if err != nil { + return err + } + bodyBytes = b - if err != nil { - return nil, err + defer func() { + err = errors.Join(err, r.HTTPResponse.Body.Close()) + }() } + } - httpReqStr += string(b) - replaceRequestURIWithActualURL(&httpReqStr, req.HTTPRequest.URL.EscapedPath(), req.HTTPRequest.URL.String()) + previewBytes = len(bodyBytes) - if req.Method == MethodREQMOD { - if req.previewSet { - parsePreviewBodyBytes(&httpReqStr, req.PreviewBytes) - } + // if the preview byte is 0 or less, there is no question of the body-fitting insides + if previewBytes > 0 { + r.bodyFittedInPreview = true + } - if !bodyAlreadyChunked(httpReqStr) { - headerStr, bodyStr, ok := splitBodyAndHeader(httpReqStr) - if ok { - addHexaBodyByteNotations(&bodyStr) - mergeHeaderAndBody(&httpReqStr, headerStr, bodyStr) - } - } + // if the preview bytes are greater than what was mentioned by the ICAP Server (did not fit in the body) + if previewBytes > maxBytes { + previewBytes = maxBytes + r.bodyFittedInPreview = false + // storing the rest of the body byte which was not sent as preview for further operations + r.remainingPreviewBytes = bodyBytes[maxBytes:] + } - } + // set the body to the http message depending on the request method + if r.Method == MethodREQMOD { + r.HTTPRequest.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } - if httpReqStr != "" { // if the HTTP Request message block doesn't end with a \r\n\r\n, then going to add one by force for better calculation of byte offsets - for !strings.HasSuffix(httpReqStr, DoubleCRLF) { - httpReqStr += CRLF - } - } + if r.Method == MethodRESPMOD { + r.HTTPResponse.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + + // assign preview byte information to the header + r.Header.Set("Preview", strconv.Itoa(previewBytes)) + r.PreviewBytes = previewBytes + r.previewSet = true + + return err +} +// setDefaultRequestHeaders is called by the client before sending the request +// to the ICAP server to ensure all required headers are set +func (r *Request) setDefaultRequestHeaders() { + if _, exists := r.Header["Allow"]; !exists { + r.Header.Add("Allow", "204") // assigning 204 by default if Allow not provided } - // Making the HTTP Response message block + if _, exists := r.Header["Host"]; !exists { + hostName, _ := os.Hostname() + r.Header.Add("Host", hostName) + } +} - httpRespStr := "" - if req.HTTPResponse != nil { - b, err := httputil.DumpResponse(req.HTTPResponse, true) +// extendHeader extends the current ICAP Request header with a new header +func (r *Request) extendHeader(hdr http.Header) error { + for header, values := range hdr { - if err != nil { - return nil, err + if header == previewHeader && r.previewSet { + continue } - httpRespStr += string(b) - - if req.previewSet { - parsePreviewBodyBytes(&httpRespStr, req.PreviewBytes) + if header == encapsulatedHeader { + continue } - if !bodyAlreadyChunked(httpRespStr) { - headerStr, bodyStr, ok := splitBodyAndHeader(httpRespStr) - if ok { - addHexaBodyByteNotations(&bodyStr) - mergeHeaderAndBody(&httpRespStr, headerStr, bodyStr) + for _, value := range values { + if header == previewHeader { + pb, err := strconv.Atoi(value) + if err != nil { + return err + } + + err = r.SetPreview(pb) + if err != nil { + return err + } + + continue } + r.Header.Add(header, value) } + } - if httpRespStr != "" && !strings.HasSuffix(httpRespStr, DoubleCRLF) { // if the HTTP Response message block doesn't end with a \r\n\r\n, then going to add one by force for better calculation of byte offsets - httpRespStr += CRLF - } + return nil +} - } +// validate checks if the ICAP request is valid or not +func (r *Request) validate() error { + var err error - if encpVal := req.Header.Get(EncapsulatedHeader); encpVal != "" { - reqStr = fmt.Sprintf(reqStr, encpVal) - } else { - //populating the Encapsulated header of the ICAP message portion - setEncapsulatedHeaderValue(&reqStr, httpReqStr, httpRespStr) + // check if the ICAP request has a context + if r.ctx == nil { + err = errors.Join(err, ErrNoContext) } - // determining if the http message needs the full body fitted in the preview portion indicator or not - if httpRespStr != "" && req.previewSet && req.bodyFittedInPreview { - addFullBodyInPreviewIndicator(&httpRespStr) + // check if the ICAP request method is allowed + if methodAllowed := slices.Contains([]string{ + MethodOPTIONS, + MethodRESPMOD, + MethodREQMOD, + }, r.Method); !methodAllowed { + err = errors.Join(err, ErrMethodNotAllowed) } - if req.Method == MethodREQMOD && req.previewSet && req.bodyFittedInPreview { - addFullBodyInPreviewIndicator(&httpReqStr) + // check if the ICAP url is valid and contains all required fields + { + if r.URL.Scheme != schemeICAP { + err = errors.Join(err, ErrInvalidScheme) + } + + if r.URL.Host == "" { + err = errors.Join(err, ErrInvalidHost) + } } - data := []byte(reqStr + httpReqStr + httpRespStr) + // check if the ICAP request method is aligned with the http messages + { + if r.Method == MethodREQMOD && r.HTTPRequest == nil { + err = errors.Join(err, ErrREQMODWithoutReq) + } + + if r.Method == MethodREQMOD && r.HTTPResponse != nil { + err = errors.Join(err, ErrREQMODWithResp) + } - return data, nil -} + if r.Method == MethodRESPMOD && r.HTTPResponse == nil { + err = errors.Join(err, ErrRESPMODWithoutResp) + } + } -// SetContext sets a context for the ICAP request -func (r *Request) SetContext(ctx context.Context) { // TODO: make context take control over the whole operation - r.ctx = &ctx + return err } diff --git a/request_test.go b/request_test.go index 63c2fd0..d69be99 100644 --- a/request_test.go +++ b/request_test.go @@ -2,8 +2,9 @@ package icapclient import ( "bytes" + "context" "errors" - "io/ioutil" + "io" "net/http" "os" "reflect" @@ -13,7 +14,6 @@ import ( ) func TestRequest(t *testing.T) { - t.Run("Request Factory", func(t *testing.T) { type testSample struct { @@ -22,7 +22,6 @@ func TestRequest(t *testing.T) { httpReq *http.Request httpResp *http.Response err error - errStr string } sampleTable := []testSample{ @@ -32,7 +31,6 @@ func TestRequest(t *testing.T) { httpReq: nil, httpResp: nil, err: nil, - errStr: "", }, { urlStr: "icap://localhost:1344/something", @@ -40,7 +38,6 @@ func TestRequest(t *testing.T) { httpReq: nil, httpResp: &http.Response{}, err: nil, - errStr: "", }, { urlStr: "icap://localhost:1344/something", @@ -48,61 +45,53 @@ func TestRequest(t *testing.T) { httpReq: &http.Request{}, httpResp: nil, err: nil, - errStr: "", }, { urlStr: "icap://localhost:1344/something", reqMethod: "invalid", httpReq: nil, httpResp: nil, - err: errors.New(ErrMethodNotRegistered), - errStr: ErrMethodNotRegistered, + err: ErrMethodNotAllowed, }, { urlStr: "http://localhost:1344/something", reqMethod: MethodOPTIONS, httpReq: nil, httpResp: nil, - err: errors.New(ErrInvalidScheme), - errStr: ErrInvalidScheme, + err: ErrInvalidScheme, }, { urlStr: "icap://", reqMethod: MethodOPTIONS, httpReq: nil, httpResp: nil, - err: errors.New(ErrInvalidHost), - errStr: ErrInvalidHost, + err: ErrInvalidHost, }, { urlStr: "icap://localhost:1344/something", reqMethod: MethodREQMOD, httpReq: nil, httpResp: nil, - err: errors.New(ErrREQMODWithNoReq), - errStr: ErrREQMODWithNoReq, + err: ErrREQMODWithoutReq, }, { urlStr: "icap://localhost:1344/something", reqMethod: MethodREQMOD, httpReq: &http.Request{}, httpResp: &http.Response{}, - err: errors.New(ErrREQMODWithResp), - errStr: ErrREQMODWithResp, + err: ErrREQMODWithResp, }, { urlStr: "icap://localhost:1344/something", reqMethod: MethodRESPMOD, httpReq: &http.Request{}, httpResp: nil, - err: errors.New(ErrRESPMODWithNoResp), - errStr: ErrRESPMODWithNoResp, + err: ErrRESPMODWithoutResp, }, } for _, sample := range sampleTable { - if _, err := NewRequest(sample.reqMethod, sample.urlStr, sample.httpReq, sample.httpResp); !reflect.DeepEqual(err, - sample.err) { + if _, err := NewRequest(context.Background(), sample.reqMethod, sample.urlStr, sample.httpReq, sample.httpResp); !errors.Is(err, sample.err) { t.Logf("Wanted error: %v, got: %v", sample.err, err) t.Fail() } @@ -110,131 +99,9 @@ func TestRequest(t *testing.T) { }) - t.Run("DumpRequest OPTIONS", func(t *testing.T) { - - req, _ := NewRequest(MethodOPTIONS, "icap://localhost:1344/something", nil, nil) - - b, err := DumpRequest(req) - - if err != nil { - t.Fatal(err.Error()) - } - - wanted := "OPTIONS icap://localhost:1344/something ICAP/1.0\r\n" + - "Encapsulated: null-body=0\r\n\r\n" - - got := string(b) - - if wanted != got { - t.Logf("wanted: %s, got: %s\n", wanted, got) - t.Fail() - } - - }) - - t.Run("DumpRequest REQMOD", func(t *testing.T) { // FIXME: add proper wanted string and complete this unit test - httpReq, _ := http.NewRequest(http.MethodGet, "http://someurl.com", nil) - - req, _ := NewRequest(MethodREQMOD, "icap://localhost:1344/something", httpReq, nil) - - b, err := DumpRequest(req) - if err != nil { - t.Fatal(err.Error()) - } - - wanted := "REQMOD icap://localhost:1344/something ICAP/1.0\r\n" + - "Encapsulated: req-hdr=0, null-body=109\r\n\r\n" + - "GET http://someurl.com HTTP/1.1\r\n" + - "Host: someurl.com\r\n" + - "User-Agent: Go-http-client/1.1\r\n" + - "Accept-Encoding: gzip\r\n\r\n" - - got := string(b) - - if wanted != got { - t.Logf("wanted: \n%s\ngot: \n%s\n", wanted, got) - t.Fail() - } - - httpReq, _ = http.NewRequest(http.MethodPost, "http://someurl.com", bytes.NewBufferString("Hello World")) - - req, _ = NewRequest(MethodREQMOD, "icap://localhost:1344/something", httpReq, nil) - - b, err = DumpRequest(req) - if err != nil { - t.Fatal(err.Error()) - } - - wanted = "REQMOD icap://localhost:1344/something ICAP/1.0\r\n" + - "Encapsulated: req-hdr=0, req-body=130\r\n\r\n" + - "POST http://someurl.com HTTP/1.1\r\n" + - "Host: someurl.com\r\n" + - "User-Agent: Go-http-client/1.1\r\n" + - "Content-Length: 11\r\n" + - "Accept-Encoding: gzip\r\n\r\n" + - "b\r\n" + - "Hello World\r\n" + - "0\r\n\r\n" - - got = string(b) - - if wanted != got { - t.Logf("wanted: \n%s\ngot: \n%s\n", wanted, got) - t.Fail() - } - - }) - - t.Run("DumpRequest RESPMOD", func(t *testing.T) { - httpReq, _ := http.NewRequest(http.MethodPost, "http://someurl.com", bytes.NewBufferString("Hello World")) - httpResp := &http.Response{ - Status: "200 OK", - StatusCode: http.StatusOK, - Proto: "HTTP/1.0", - ProtoMajor: 1, - ProtoMinor: 0, - Header: http.Header{ - "Content-Type": []string{"plain/text"}, - "Content-Length": []string{"11"}, - }, - ContentLength: 11, - Body: ioutil.NopCloser(strings.NewReader("Hello World")), - } - - req, _ := NewRequest(MethodRESPMOD, "icap://localhost:1344/something", httpReq, httpResp) - - b, err := DumpRequest(req) - if err != nil { - t.Fatal(err.Error()) - } - - wanted := "RESPMOD icap://localhost:1344/something ICAP/1.0\r\n" + - "Encapsulated: req-hdr=0, req-body=130, res-hdr=145, res-body=210\r\n\r\n" + - "POST http://someurl.com HTTP/1.1\r\n" + - "Host: someurl.com\r\n" + - "User-Agent: Go-http-client/1.1\r\n" + - "Content-Length: 11\r\n" + - "Accept-Encoding: gzip\r\n\r\n" + - "Hello World\r\n\r\n" + - "HTTP/1.0 200 OK\r\n" + - "Content-Length: 11\r\n" + - "Content-Type: plain/text\r\n\r\n" + - "b\r\n" + - "Hello World\r\n" + - "0\r\n\r\n" - - got := string(b) - - if wanted != got { - t.Logf("wanted: \n%s\ngot: \n%s\n", wanted, got) - t.Fail() - } - - }) - - t.Run("SetDefaultRequestHeaders", func(t *testing.T) { - req, _ := NewRequest(MethodOPTIONS, "icap://localhost:1344/something", nil, nil) - req.SetDefaultRequestHeaders() + t.Run("setDefaultRequestHeaders", func(t *testing.T) { + req, _ := NewRequest(context.Background(), MethodOPTIONS, "icap://localhost:1344/something", nil, nil) + req.setDefaultRequestHeaders() if val, exists := req.Header["Allow"]; !exists || len(val) < 1 || val[0] != "204" { t.Log("Must have Allow header with 204 as value") @@ -247,9 +114,9 @@ func TestRequest(t *testing.T) { t.Fail() } - req, _ = NewRequest(MethodOPTIONS, "icap://localhost:1344/something", nil, nil) + req, _ = NewRequest(context.Background(), MethodOPTIONS, "icap://localhost:1344/something", nil, nil) req.Header.Set("Host", "somehost") - req.SetDefaultRequestHeaders() + req.setDefaultRequestHeaders() if val, exists := req.Header["Host"]; !exists || len(val) < 1 || val[0] != "somehost" { t.Logf("Must have Host header with %s as value", "somehost") @@ -258,8 +125,7 @@ func TestRequest(t *testing.T) { }) - t.Run("ExtendHeader", func(t *testing.T) { - + t.Run("extendHeader", func(t *testing.T) { type testSample struct { extendingHeader http.Header nameValue []string @@ -294,12 +160,12 @@ func TestRequest(t *testing.T) { } for _, sample := range sampleTable { - req, _ := NewRequest(MethodOPTIONS, "icap://localhost:1344/something", nil, nil) + req, _ := NewRequest(context.Background(), MethodOPTIONS, "icap://localhost:1344/something", nil, nil) if sample.defaultHeaders { - req.SetDefaultRequestHeaders() + req.setDefaultRequestHeaders() } - if err := req.ExtendHeader(sample.extendingHeader); err != nil { + if err := req.extendHeader(sample.extendingHeader); err != nil { t.Fatal(err.Error()) } @@ -376,9 +242,9 @@ func TestRequest(t *testing.T) { for _, sample := range sampleTable { bodyData := bytes.NewBufferString(sample.bodyStr) httpReq, _ := http.NewRequest(http.MethodPost, "http://someurl.com", bodyData) - var req *Request + var req Request if sample.reqMethod == MethodREQMOD { - req, _ = NewRequest(sample.reqMethod, "icap://localhost:1344/something", httpReq, nil) + req, _ = NewRequest(context.Background(), sample.reqMethod, "icap://localhost:1344/something", httpReq, nil) } if sample.reqMethod == MethodRESPMOD { httpResp := &http.Response{ @@ -392,9 +258,9 @@ func TestRequest(t *testing.T) { "Content-Length": []string{strconv.Itoa(bodyData.Len())}, }, ContentLength: int64(bodyData.Len()), - Body: ioutil.NopCloser(strings.NewReader(sample.bodyStr)), + Body: io.NopCloser(strings.NewReader(sample.bodyStr)), } - req, _ = NewRequest(sample.reqMethod, "icap://localhost:1344/something", httpReq, httpResp) + req, _ = NewRequest(context.Background(), sample.reqMethod, "icap://localhost:1344/something", httpReq, httpResp) } if err := req.SetPreview(sample.previewBytes); err != nil { @@ -409,11 +275,11 @@ func TestRequest(t *testing.T) { var bdyBytes []byte if sample.reqMethod == MethodREQMOD { - bdyBytes, _ = ioutil.ReadAll(req.HTTPRequest.Body) + bdyBytes, _ = io.ReadAll(req.HTTPRequest.Body) } if sample.reqMethod == MethodRESPMOD { - bdyBytes, _ = ioutil.ReadAll(req.HTTPResponse.Body) + bdyBytes, _ = io.ReadAll(req.HTTPResponse.Body) } if string(bdyBytes) != sample.bodyStr { diff --git a/response.go b/response.go deleted file mode 100644 index fbb6df3..0000000 --- a/response.go +++ /dev/null @@ -1,123 +0,0 @@ -package icapclient - -import ( - "bufio" - "errors" - "net/http" - "strconv" - "strings" -) - -// Response represents the icap server response data -type Response struct { - StatusCode int - Status string - PreviewBytes int - Header http.Header - ContentRequest *http.Request - ContentResponse *http.Response -} - -var ( - optionValues = map[string]bool{ - PreviewHeader: true, - MethodsHeader: true, - AllowHeader: true, - TransferPreviewHeader: true, - ServiceHeader: true, - ISTagHeader: true, - OptBodyTypeHeader: true, - MaxConnectionsHeader: true, - OptionsTTLHeader: true, - ServiceIDHeader: true, - TransferIgnoreHeader: true, - TransferCompleteHeader: true, - } -) - -// ReadResponse converts a Reader to a icapclient Response -func ReadResponse(b *bufio.Reader) (*Response, error) { - - resp := &Response{ - Header: make(map[string][]string), - } - - scheme := "" - httpMsg := "" - for currentMsg, err := b.ReadString('\n'); err == nil || currentMsg != ""; currentMsg, err = b.ReadString('\n') { // keep reading the buffer message which is the http response message - - if isRequestLine(currentMsg) { // if the current message line if the first line of the message portion(request line) - ss := strings.Split(currentMsg, " ") - - if len(ss) < 3 { // must contain 3 words, for example: "ICAP/1.0 200 OK" or "GET /something HTTP/1.1" - return nil, errors.New(ErrInvalidTCPMsg + ":" + currentMsg) - } - - // preparing the scheme below - - if ss[0] == ICAPVersion { - scheme = SchemeICAP - resp.StatusCode, resp.Status, err = getStatusWithCode(ss[1], strings.Join(ss[2:], " ")) - if err != nil { - return nil, err - } - continue - } - - if ss[0] == HTTPVersion { - scheme = SchemeHTTPResp - httpMsg = "" - } - - if strings.TrimSpace(ss[2]) == HTTPVersion { // for a http request message if the scheme version is always at last, for example: GET /something HTTP/1.1 - scheme = SchemeHTTPReq - httpMsg = "" - } - } - - // preparing the header for ICAP & contents for HTTP messages below - - if scheme == SchemeICAP { - if currentMsg == LF || currentMsg == CRLF { // don't want to count the Line Feed as header - continue - } - header, val := getHeaderVal(currentMsg) - if header == PreviewHeader { - pb, _ := strconv.Atoi(val) - resp.PreviewBytes = pb - } - resp.Header.Add(header, val) - } - - if scheme == SchemeHTTPReq { - httpMsg += strings.TrimSpace(currentMsg) + CRLF - bufferEmpty := b.Buffered() == 0 - if currentMsg == CRLF || bufferEmpty { // a CRLF indicates the end of a http message and the buffer check is just in case the buffer eneded with one last message instead of a CRLF - var erR error - resp.ContentRequest, erR = http.ReadRequest(bufio.NewReader(strings.NewReader(httpMsg))) - if erR != nil { - return nil, erR - } - continue - } - } - - if scheme == SchemeHTTPResp { - httpMsg += strings.TrimSpace(currentMsg) + CRLF - bufferEmpty := b.Buffered() == 0 - if currentMsg == CRLF || bufferEmpty { - var erR error - resp.ContentResponse, erR = http.ReadResponse(bufio.NewReader(strings.NewReader(httpMsg)), resp.ContentRequest) - if erR != nil { - return nil, erR - } - continue - } - - } - - } - - return resp, nil - -} diff --git a/response_test.go b/response_test.go deleted file mode 100644 index 201cb0a..0000000 --- a/response_test.go +++ /dev/null @@ -1,208 +0,0 @@ -package icapclient - -import ( - "bufio" - "net/http" - "reflect" - "strings" - "testing" -) - -func TestResponse(t *testing.T) { - - t.Run("ReadResponse REQMOD", func(t *testing.T) { // FIXME: headers and content request aren't being tested properly - - type testSample struct { - headers http.Header - status string - statusCode int - previewBytes int - respStr string - httpReqStr string - } - - sampleTable := []testSample{ - { - headers: http.Header{ - "Date": []string{"Mon, 10 Jan 2000 09:55:21 GMT"}, - "Server": []string{"ICAP-Server-Software/1.0"}, - "Istag": []string{"\"W3E4R7U9-L2E4-2\""}, - "Encapsulated": []string{"req-hdr=0, null-body=231"}, - }, - status: "OK", - statusCode: 200, - previewBytes: 0, - respStr: "ICAP/1.0 200 OK\r\n" + - "Date: Mon, 10 Jan 2000 09:55:21 GMT\r\n" + - "Server: ICAP-Server-Software/1.0\r\n" + - "Connection: close\r\n" + - "Istag: \"W3E4R7U9-L2E4-2\"\r\n" + - "Encapsulated: req-hdr=0, null-body=231\r\n\r\n", - httpReqStr: "GET /modified-path HTTP/1.1\r\n" + - "Host: www.origin-server.com\r\n" + - "Via: 1.0 icap-server.net (ICAP Example ReqMod Service 1.1)\r\n" + - "Accept: text/html, text/plain, image/gif\r\n" + - "Accept-Encoding: gzip, compress\r\n" + - "If-None-Match: \"xyzzy\", \"r2d2xxxx\"\r\n\r\n", - }, - { - headers: http.Header{ - "Date": []string{"Mon, 10 Jan 2000 09:55:21 GMT"}, - "Server": []string{"ICAP-Server-Software/1.0"}, - "Istag": []string{"\"W3E4R7U9-L2E4-2\""}, - "Encapsulated": []string{"req-hdr=0, req-body=244"}, - }, - status: "OK", - statusCode: 200, - previewBytes: 0, - respStr: "ICAP/1.0 200 OK\r\n" + - "Date: Mon, 10 Jan 2000 09:55:21 GMT\r\n" + - "Server: ICAP-Server-Software/1.0\r\n" + - "Connection: close\r\n" + - "Istag: \"W3E4R7U9-L2E4-2\"\r\n" + - "Encapsulated: req-hdr=0, req-body=244\r\n\r\n", - httpReqStr: "POST /origin-resource/form.pl HTTP/1.1\r\n" + - "Host: www.origin-server.com\r\n" + - "Via: 1.0 icap-server.net (ICAP Example ReqMod Service 1.1)\r\n" + - "Accept: text/html, text/plain, image/gif\r\n" + - "Accept-Encoding: gzip, compress\r\n" + - "Pragma: no-cache\r\n" + - "Content-Length: 45\r\n\r\n" + - "2d\r\n" + - "I am posting this information. ICAP powered!\r\n" + - "0\r\n\r\n", - }, - } - - for _, sample := range sampleTable { - resp, err := ReadResponse(bufio.NewReader(strings.NewReader(sample.respStr + sample.httpReqStr))) - if err != nil { - t.Fatal(err.Error()) - } - - if resp.StatusCode != sample.statusCode { - t.Logf("Wanted ICAP status code: %d , got: %d", sample.statusCode, resp.StatusCode) - t.Fail() - } - if resp.Status != sample.status { - t.Logf("Wanted ICAP status: %s , got: %s", sample.status, resp.Status) - t.Fail() - } - if resp.PreviewBytes != sample.previewBytes { - t.Logf("Wanted preview bytes: %d, got: %d", sample.previewBytes, resp.PreviewBytes) - t.Fail() - } - - for k, v := range sample.headers { - if val, exists := resp.Header[k]; !exists || !reflect.DeepEqual(val, v) { - t.Logf("Wanted Header: %s with value: %v, got: %v", k, v, val) - t.Fail() - break - } - } - if resp.ContentRequest == nil { - t.Log("ContentRequest is nil") - t.Fail() - } - - wantedHTTPReq, err := http.ReadRequest(bufio.NewReader(strings.NewReader(sample.httpReqStr))) - if err != nil { - t.Fatal(err.Error()) - } - - if !reflect.DeepEqual(resp.ContentRequest, wantedHTTPReq) { - t.Logf("Wanted http request: %v, got: %v", wantedHTTPReq, resp.ContentRequest) - t.Fail() - } - - } - - }) - - t.Run("ReadResponse RESPMOD", func(t *testing.T) { - type testSample struct { - headers http.Header - status string - statusCode int - previewBytes int - respStr string - httpRespStr string - httpReqStr string - } - - sampleTable := []testSample{ - { - headers: http.Header{ - "Date": []string{"Mon, 10 Jan 2000 09:55:21 GMT"}, - "Server": []string{"ICAP-Server-Software/1.0"}, - "Istag": []string{"\"W3E4R7U9-L2E4-2\""}, - "Encapsulated": []string{"req-hdr=0, res-body=222"}, - }, - status: "OK", - statusCode: 200, - previewBytes: 0, - respStr: "ICAP/1.0 200 OK\r\n" + - "Date: Mon, 10 Jan 2000 09:55:21 GMT\r\n" + - "Server: ICAP-Server-Software/1.0\r\n" + - "Connection: close\r\n" + - "ISTag: \"W3E4R7U9-L2E4-2\"\r\n" + - "Encapsulated: req-hdr=0, res-body=222\r\n\r\n", - httpRespStr: "HTTP/1.1 200 OK\r\n" + - "Date: Mon, 10 Jan 2000 09:55:21 GMT\r\n" + - "Via: 1.0 icap.example.org (ICAP Example RespMod Service 1.1)\r\n" + - "Server: Apache/1.3.6 (Unix)\r\n" + - "ETag: \"63840-1ab7-378d415b\"\r\n" + - "Content-Type: text/plain\r\n" + - "Content-Length: 92\r\n\r\n" + - "5c\r\n" + - "This is data that was returned by an origin server, but with value added by an ICAP server.\r\n" + - "0\r\n\r\n", - }, - } - - for _, sample := range sampleTable { - resp, err := ReadResponse(bufio.NewReader(strings.NewReader(sample.respStr + sample.httpRespStr))) - if err != nil { - t.Fatal(err.Error()) - } - - if resp.StatusCode != sample.statusCode { - t.Logf("Wanted ICAP status code: %d , got: %d", sample.statusCode, resp.StatusCode) - t.Fail() - } - if resp.Status != sample.status { - t.Logf("Wanted ICAP status: %s , got: %s", sample.status, resp.Status) - t.Fail() - } - if resp.PreviewBytes != sample.previewBytes { - t.Logf("Wanted preview bytes: %d, got: %d", sample.previewBytes, resp.PreviewBytes) - t.Fail() - } - - for k, v := range sample.headers { - if val, exists := resp.Header[k]; !exists || !reflect.DeepEqual(val, v) { - t.Logf("Wanted Header: %s with value: %v, got: %v", k, v, val) - t.Fail() - break - } - } - if resp.ContentResponse == nil { - t.Log("ContentResponse is nil") - t.Fail() - } - - wantedHTTPResp, err := http.ReadResponse(bufio.NewReader(strings.NewReader(sample.httpRespStr)), nil) - if err != nil { - t.Fatal(err.Error()) - } - - if !reflect.DeepEqual(resp.ContentResponse, wantedHTTPResp) { - t.Logf("Wanted http response: %v, got: %v", wantedHTTPResp, resp.ContentResponse) - t.Fail() - } - - } - - }) - -} diff --git a/test_server.go b/test_server.go index 882fa9a..fae4aa4 100644 --- a/test_server.go +++ b/test_server.go @@ -36,7 +36,7 @@ func startTestServer() { log.Println("Starting ICAP test server...") - signal.Notify(stop, syscall.SIGKILL, syscall.SIGINT, syscall.SIGQUIT) + signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT) go func() { if err := icap.ListenAndServe(fmt.Sprintf(":%d", port), nil); err != nil { @@ -79,8 +79,6 @@ func respmodHandler(w icap.ResponseWriter, req *icap.Request) { return } - // log.Println("The preview data: ", string(req.Preview)) - buf := &bytes.Buffer{} if _, err := io.Copy(buf, req.Response.Body); err != nil { @@ -124,8 +122,6 @@ func reqmodHandler(w icap.ResponseWriter, req *icap.Request) { return } - // log.Println("The preview data: ", string(req.Preview)) - fileURL := req.Request.RequestURI status := 0 diff --git a/transport.go b/transport.go deleted file mode 100644 index d78b293..0000000 --- a/transport.go +++ /dev/null @@ -1,123 +0,0 @@ -package icapclient - -import ( - "context" - "io" - "net" - "strings" - "time" -) - -// transport represents the transport layer data -type transport struct { - network string - addr string - timeout time.Duration - readTimeout time.Duration - writeTimeout time.Duration - sckt net.Conn -} - -// dial fires up a tcp socket -func (t *transport) dial() error { - sckt, err := net.DialTimeout(t.network, t.addr, t.timeout) - - if err != nil { - return err - } - - if err := sckt.SetReadDeadline(time.Now().UTC().Add(t.readTimeout)); err != nil { - return err - } - - if err := sckt.SetWriteDeadline(time.Now().UTC().Add(t.writeTimeout)); err != nil { - return err - } - - t.sckt = sckt - - return nil -} - -// dialWithContext fires up a tcp socket -func (t *transport) dialWithContext(ctx context.Context) error { - sckt, err := (&net.Dialer{ - Timeout: t.timeout, - }).DialContext(ctx, t.network, t.addr) - - if err != nil { - return err - } - - if err := sckt.SetReadDeadline(time.Now().UTC().Add(t.readTimeout)); err != nil { - return err - } - - if err := sckt.SetWriteDeadline(time.Now().UTC().Add(t.writeTimeout)); err != nil { - return err - } - - t.sckt = sckt - - return nil -} - -// Write writes data to the server -func (t *transport) write(data []byte) (int, error) { - logDebug("Dumping the message being sent to the server...") - dumpDebug(string(data)) - return t.sckt.Write(data) -} - -// Read reads data from server -func (t *transport) read() (string, error) { - - data := make([]byte, 0) - - logDebug("Dumping messages received from the server...") - - for { - tmp := make([]byte, 1096) - - n, err := t.sckt.Read(tmp) - - if err != nil { - if err == io.EOF { - logDebug("End of file detected from EOF error") - break - } - return "", err - } - - if n == 0 { - logDebug("End of file detected by 0 bytes") - break - } - - data = append(data, tmp[:n]...) - if string(data) == icap100ContinueMsg { // explicitly breaking because the Read blocks for 100 continue message // TODO: find out why - logDebug("Stopping because got 100 Continue from the server") - break - } - - if strings.HasSuffix(string(data), "0\r\n\r\n") { - logDebug("End of the file detected by 0 Double CRLF indicator") - break - } - - if strings.Contains(string(data), icap204NoModsMsg) { - logDebug("End of file detected by 204 no modifications and Double CRLF at the end") - break - } - - dumpDebug(string(tmp)) - - } - - return string(data), nil -} - -// close closes the tcp connection -func (t *transport) close() error { - return t.sckt.Close() -} diff --git a/validate.go b/validate.go deleted file mode 100644 index 42c410c..0000000 --- a/validate.go +++ /dev/null @@ -1,63 +0,0 @@ -package icapclient - -import ( - "errors" - "net/http" - "net/url" -) - -// validMethod validates the ICAP method -func validMethod(method string) (bool, error) { - if _, registered := registeredMethods[method]; !registered { - return false, errors.New(ErrMethodNotRegistered) - } - - return true, nil -} - -// validURL validates the Server URL provided -func validURL(url *url.URL) (bool, error) { - - if url.Scheme != SchemeICAP { - return false, errors.New(ErrInvalidScheme) - } - - if url.Host == "" { - return false, errors.New(ErrInvalidHost) - } - - return true, nil -} - -// validMethodWithHTTP validates if the ICAP request method and the http messages are alligned or not -func validMethodWithHTTP(httpReq *http.Request, httpResp *http.Response, method string) (bool, error) { - if method == MethodREQMOD && httpReq == nil { - return false, errors.New(ErrREQMODWithNoReq) - } - if method == MethodREQMOD && httpResp != nil { - return false, errors.New(ErrREQMODWithResp) - } - if method == MethodRESPMOD && httpResp == nil { - return false, errors.New(ErrRESPMODWithNoResp) - } - - return true, nil -} - -// Validate validates the ICAP request -func (r *Request) Validate() error { - - if valid, err := validMethod(r.Method); !valid { - return err - } - - if valid, err := validURL(r.URL); !valid { - return err - } - - if valid, err := validMethodWithHTTP(r.HTTPRequest, r.HTTPResponse, r.Method); !valid { - return err - } - - return nil -}