Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a dispatchhttp package #11

Merged
merged 1 commit into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions dispatchhttp/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package dispatchhttp

import (
"bytes"
"context"
"net/http"
)

// Client wraps an http.Client to accept Request instances
// and return Response instances.
type Client struct{ Client *http.Client }

// DefaultClient is the default client.
var DefaultClient = &Client{Client: http.DefaultClient}

// Get makes an HTTP GET request to the specified URL and returns
// its Response.
func (c *Client) Get(ctx context.Context, url string) (*Response, error) {
req := &Request{Method: "GET", URL: url}
return c.Do(ctx, req)
}

// Get makes an HTTP GET request to the specified URL and returns
// its Response.
func Get(ctx context.Context, url string) (*Response, error) {
return DefaultClient.Get(ctx, url)
}

// Do makes a HTTP Request and returns its Response.
func (c *Client) Do(ctx context.Context, r *Request) (*Response, error) {
httpReq, err := http.NewRequestWithContext(ctx, r.Method, r.URL, bytes.NewReader(r.Body))
if err != nil {
return nil, err
}
copyHeader(httpReq.Header, r.Header)

httpRes, err := c.Client.Do(httpReq)
if err != nil {
return nil, err
}
return FromResponse(httpRes)
}
18 changes: 18 additions & 0 deletions dispatchhttp/header.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package dispatchhttp

import (
"net/http"
"slices"
)

func cloneHeader(h http.Header) http.Header {
c := make(http.Header, len(h))
copyHeader(c, h)
return c
}

func copyHeader(dst, src http.Header) {
for name, values := range src {
dst[name] = slices.Clone(values)
}
}
159 changes: 159 additions & 0 deletions dispatchhttp/http_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package dispatchhttp_test

import (
"net/http"
"strconv"
"testing"

"github.com/dispatchrun/dispatch-go/dispatchhttp"
"github.com/dispatchrun/dispatch-go/dispatchproto"
"github.com/google/go-cmp/cmp"
)

func TestSerializable(t *testing.T) {
t.Run("request", func(t *testing.T) {
req := &dispatchhttp.Request{
Method: "GET",
URL: "http://example.com",
Header: http.Header{"X-Foo": []string{"bar"}},
Body: []byte("abc"),
}
boxed, err := dispatchproto.Marshal(req)
if err != nil {
t.Fatal(err)
}
var req2 *dispatchhttp.Request
if err := boxed.Unmarshal(&req2); err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(req, req2); diff != "" {
t.Errorf("invalid request: %v", diff)
}
})

t.Run("response", func(t *testing.T) {
res := &dispatchhttp.Response{
StatusCode: 200,
Header: http.Header{"X-Foo": []string{"bar"}},
Body: []byte("abc"),
}
boxed, err := dispatchproto.Marshal(res)
if err != nil {
t.Fatal(err)
}
var res2 *dispatchhttp.Response
if err := boxed.Unmarshal(&res2); err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(res, res2); diff != "" {
t.Errorf("invalid response: %v", diff)
}
})
}

func TestStatusCodeStatus(t *testing.T) {
for _, test := range []struct {
code int
want dispatchproto.Status
}{
// 1xx
{
code: http.StatusContinue,
want: dispatchproto.PermanentErrorStatus,
},

// 2xx
{
code: http.StatusOK,
want: dispatchproto.OKStatus,
},
{
code: http.StatusAccepted,
want: dispatchproto.OKStatus,
},
{
code: http.StatusCreated,
want: dispatchproto.OKStatus,
},

// 3xx
{
code: http.StatusTemporaryRedirect,
want: dispatchproto.PermanentErrorStatus,
},
{
code: http.StatusPermanentRedirect,
want: dispatchproto.PermanentErrorStatus,
},

// 4xx
{
code: http.StatusBadRequest,
want: dispatchproto.InvalidArgumentStatus,
},
{
code: http.StatusUnauthorized,
want: dispatchproto.UnauthenticatedStatus,
},
{
code: http.StatusForbidden,
want: dispatchproto.PermissionDeniedStatus,
},
{
code: http.StatusNotFound,
want: dispatchproto.NotFoundStatus,
},
{
code: http.StatusMethodNotAllowed,
want: dispatchproto.PermanentErrorStatus,
},
{
code: http.StatusRequestTimeout,
want: dispatchproto.TimeoutStatus,
},
{
code: http.StatusTooManyRequests,
want: dispatchproto.ThrottledStatus,
},

// 5xx
{
code: http.StatusInternalServerError,
want: dispatchproto.TemporaryErrorStatus,
},
{
code: http.StatusNotImplemented,
want: dispatchproto.PermanentErrorStatus,
},
{
code: http.StatusBadGateway,
want: dispatchproto.TemporaryErrorStatus,
},
{
code: http.StatusServiceUnavailable,
want: dispatchproto.TemporaryErrorStatus,
},
{
code: http.StatusGatewayTimeout,
want: dispatchproto.TemporaryErrorStatus,
},

// invalid
{
code: 0,
want: dispatchproto.UnspecifiedStatus,
},
{
code: 9999,
want: dispatchproto.UnspecifiedStatus,
},
} {
t.Run(strconv.Itoa(test.code), func(t *testing.T) {
res := &dispatchhttp.Response{StatusCode: test.code}
got := dispatchproto.StatusOf(res)
if got != test.want {
t.Errorf("unexpected status for code %d: got %v, want %v", test.code, got, test.want)
}
})
}
}
43 changes: 43 additions & 0 deletions dispatchhttp/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package dispatchhttp

import (
"encoding/json"
"net/http"
)

// Request is an HTTP request.
type Request struct {
Method string
URL string
Header http.Header
Body []byte
}

func (r *Request) MarshalJSON() ([]byte, error) {
// Indirection is required to avoid an infinite loop.
return json.Marshal(jsonRequest{
Method: r.Method,
URL: r.URL,
Header: r.Header,
Body: r.Body,
})
}

func (r *Request) UnmarshalJSON(b []byte) error {
var jr jsonRequest
if err := json.Unmarshal(b, &jr); err != nil {
return err
}
r.Method = jr.Method
r.URL = jr.URL
r.Header = jr.Header
r.Body = jr.Body
return nil
}

type jsonRequest struct {
Method string `json:"method,omitempty"`
URL string `json:"url,omitempty"`
Header http.Header `json:"header,omitempty"`
Body []byte `json:"body,omitempty"`
}
104 changes: 104 additions & 0 deletions dispatchhttp/response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package dispatchhttp

import (
"encoding/json"
"io"
"net/http"

"github.com/dispatchrun/dispatch-go/dispatchproto"
)

// Response is an HTTP response.
type Response struct {
StatusCode int
Header http.Header
Body []byte
}

// FromResponse creates a Response from an http.Response.
//
// The http.Response.Body is consumed and closed by this
// operation.
func FromResponse(r *http.Response) (*Response, error) {
if r == nil {
return nil, nil
}

defer r.Body.Close()
b, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}

return &Response{
StatusCode: r.StatusCode,
Header: cloneHeader(r.Header),
Body: b,
}, nil
}

func (r *Response) MarshalJSON() ([]byte, error) {
// Indirection is required to avoid an infinite loop.
return json.Marshal(jsonResponse{
StatusCode: r.StatusCode,
Header: r.Header,
Body: r.Body,
})
}

func (r *Response) UnmarshalJSON(b []byte) error {
var jr jsonResponse
if err := json.Unmarshal(b, &jr); err != nil {
return err
}
r.StatusCode = jr.StatusCode
r.Header = jr.Header
r.Body = jr.Body
return nil
}

type jsonResponse struct {
StatusCode int `json:"status_code,omitempty"`
Header http.Header `json:"header,omitempty"`
Body []byte `json:"body,omitempty"`
}

// Status is the status for the response.
func (r *Response) Status() dispatchproto.Status {
return statusCodeStatus(r.StatusCode)
}

func statusCodeStatus(statusCode int) dispatchproto.Status {
// Keep in sync with https://github.com/dispatchrun/dispatch-py/blob/main/src/dispatch/integrations/http.py
switch statusCode {
case http.StatusBadRequest: // 400
return dispatchproto.InvalidArgumentStatus
case http.StatusUnauthorized: // 401
return dispatchproto.UnauthenticatedStatus
case http.StatusForbidden: // 403
return dispatchproto.PermissionDeniedStatus
case http.StatusNotFound: // 404
return dispatchproto.NotFoundStatus
case http.StatusRequestTimeout: // 408
return dispatchproto.TimeoutStatus
case http.StatusTooManyRequests: // 429
return dispatchproto.ThrottledStatus
case http.StatusNotImplemented: // 501
return dispatchproto.PermanentErrorStatus
}

switch statusCode / 100 {
case 1: // 1xx informational
return dispatchproto.PermanentErrorStatus
case 2: // 2xx success
return dispatchproto.OKStatus
case 3: // 3xx redirect
return dispatchproto.PermanentErrorStatus
case 4: // 4xx client error
return dispatchproto.PermanentErrorStatus
case 5: // 5xx server error
return dispatchproto.TemporaryErrorStatus
}

return dispatchproto.UnspecifiedStatus
}
Loading