Skip to content

Commit

Permalink
ezhttp: CURL equivalent command helper, make request preparation more…
Browse files Browse the repository at this point in the history
… modular from callsite
  • Loading branch information
joonas-fi committed Mar 21, 2024
1 parent 3fd96a2 commit c46456b
Show file tree
Hide file tree
Showing 3 changed files with 98 additions and 18 deletions.
53 changes: 35 additions & 18 deletions net/http/ezhttp/ezhttp.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// This package aims to wrap Go HTTP Client's request-response with sane defaults:
//
// - You are forced to consider timeouts by having to specify Context
// - Instead of not considering non-2xx status codes as a failure, check that by default
// (unless explicitly asked to)
// - Sending and receiving JSON requires much less boilerplate, and on receiving JSON you
// are forced to think whether to "allowUnknownFields"
// - You are forced to consider timeouts by having to specify Context
// - Instead of not considering non-2xx status codes as a failure, check that by default
// (unless explicitly asked to)
// - Sending and receiving JSON requires much less boilerplate, and on receiving JSON you
// are forced to think whether to "allowUnknownFields"
package ezhttp

import (
Expand Down Expand Up @@ -58,33 +58,46 @@ func (e ResponseStatusError) StatusCode() int {
return e.statusCode
}

// returns *ResponseStatusError as error if non-2xx response (unless TolerateNon2xxResponse()).
// error is not *ResponseStatusError for transport-level errors, content (JSON) marshaling errors etc
func Get(ctx context.Context, url string, confPieces ...ConfigPiece) (*http.Response, error) {
return do(ctx, http.MethodGet, url, confPieces...)
return newRequest(ctx, http.MethodGet, url, confPieces...).Send()
}

// returns *ResponseStatusError as error if non-2xx response (unless TolerateNon2xxResponse()).
// error is not *ResponseStatusError for transport-level errors, content (JSON) marshaling errors etc
func Post(ctx context.Context, url string, confPieces ...ConfigPiece) (*http.Response, error) {
return do(ctx, http.MethodPost, url, confPieces...)
return newRequest(ctx, http.MethodPost, url, confPieces...).Send()
}

// returns *ResponseStatusError as error if non-2xx response (unless TolerateNon2xxResponse()).
// error is not *ResponseStatusError for transport-level errors, content (JSON) marshaling errors etc
func Put(ctx context.Context, url string, confPieces ...ConfigPiece) (*http.Response, error) {
return do(ctx, http.MethodPut, url, confPieces...)
return newRequest(ctx, http.MethodPut, url, confPieces...).Send()
}

// returns *ResponseStatusError as error if non-2xx response (unless TolerateNon2xxResponse()).
// error is not *ResponseStatusError for transport-level errors, content (JSON) marshaling errors etc
func Head(ctx context.Context, url string, confPieces ...ConfigPiece) (*http.Response, error) {
return do(ctx, http.MethodHead, url, confPieces...)
return newRequest(ctx, http.MethodHead, url, confPieces...).Send()
}

// returns *ResponseStatusError as error if non-2xx response (unless TolerateNon2xxResponse()).
// error is not *ResponseStatusError for transport-level errors, content (JSON) marshaling errors etc
func Del(ctx context.Context, url string, confPieces ...ConfigPiece) (*http.Response, error) {
return do(ctx, http.MethodDelete, url, confPieces...)
return newRequest(ctx, http.MethodDelete, url, confPieces...).Send()
}

// returns *ResponseStatusError as error if non-2xx response (unless TolerateNon2xxResponse()).
// error is not *ResponseStatusError for transport-level errors, content (JSON) marshaling errors etc
func do(ctx context.Context, method string, url string, confPieces ...ConfigPiece) (*http.Response, error) {
func newRequest(ctx context.Context, method string, url string, confPieces ...ConfigPiece) *Config {
conf := &Config{
Client: http.DefaultClient,
}

withErr := func(err error) *Config {
conf.Abort = err // will be early-error-returned in `Send()`
return conf
}

for _, configure := range confPieces {
if configure.BeforeInit == nil {
continue
Expand All @@ -93,7 +106,7 @@ func do(ctx context.Context, method string, url string, confPieces ...ConfigPiec
}

if conf.Abort != nil {
return nil, conf.Abort
return withErr(conf.Abort)
}

// "Request has body = No" for:
Expand All @@ -102,15 +115,15 @@ func do(ctx context.Context, method string, url string, confPieces ...ConfigPiec
if conf.RequestBody != nil && (method == http.MethodGet || method == http.MethodHead) {
// Technically, these can have body, but it's usually a mistake so if we need it we'll
// make it an opt-in flag.
return nil, fmt.Errorf("ezhttp: %s with non-nil body is usually a mistake", method)
return withErr(fmt.Errorf("ezhttp: %s with non-nil body is usually a mistake", method))
}

req, err := http.NewRequest(
method,
url,
conf.RequestBody)
if err != nil {
return nil, err
return withErr(err)
}

req = req.WithContext(ctx)
Expand All @@ -124,17 +137,21 @@ func do(ctx context.Context, method string, url string, confPieces ...ConfigPiec
configure.AfterInit(conf)
}

return conf
}

func (conf *Config) Send() (*http.Response, error) {
if conf.Abort != nil {
return nil, conf.Abort
}

resp, err := conf.Client.Do(req)
resp, err := conf.Client.Do(conf.Request)
if err != nil {
return resp, err // this is a transport-level error
}

// 304 is an error unless caller is expecting such response by sending caching headers
if resp.StatusCode == http.StatusNotModified && req.Header.Get("If-None-Match") != "" {
if resp.StatusCode == http.StatusNotModified && conf.Request.Header.Get("If-None-Match") != "" {
return resp, nil
}

Expand Down
47 changes: 47 additions & 0 deletions net/http/ezhttp/helpers.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package ezhttp

import (
"context"
"crypto/tls"
"fmt"
"net/http"
)

Expand All @@ -26,3 +28,48 @@ func ErrorIs(err error, statusCode int) bool {
return false
}
}

// same as the corresponding without "New" prefix, but just prepared the request configuration without sending it yet
func NewGet(ctx context.Context, url string, confPieces ...ConfigPiece) *Config {
return newRequest(ctx, http.MethodGet, url, confPieces...)
}

// same as the corresponding without "New" prefix, but just prepared the request configuration without sending it yet
func NewPost(ctx context.Context, url string, confPieces ...ConfigPiece) *Config {
return newRequest(ctx, http.MethodPost, url, confPieces...)
}

// same as the corresponding without "New" prefix, but just prepared the request configuration without sending it yet
func NewPut(ctx context.Context, url string, confPieces ...ConfigPiece) *Config {
return newRequest(ctx, http.MethodPut, url, confPieces...)
}

// same as the corresponding without "New" prefix, but just prepared the request configuration without sending it yet
func NewHead(ctx context.Context, url string, confPieces ...ConfigPiece) *Config {
return newRequest(ctx, http.MethodHead, url, confPieces...)
}

// same as the corresponding without "New" prefix, but just prepared the request configuration without sending it yet
func NewDel(ctx context.Context, url string, confPieces ...ConfigPiece) *Config {
return newRequest(ctx, http.MethodDelete, url, confPieces...)
}

// for `method` please use `net/http` "enum" (quotes because it's not declared as such)
func (c *Config) CURLEquivalent() ([]string, error) {
if err := c.Abort; err != nil {
return nil, err
}

req := c.Request // shorthand

cmd := []string{"curl", "--request=" + req.Method}

for key, values := range req.Header {
// FIXME: doesn't take into account multiple values
cmd = append(cmd, fmt.Sprintf("--header=%s=%s", key, values[0]))
}

cmd = append(cmd, req.URL.String())

return cmd, nil
}
16 changes: 16 additions & 0 deletions net/http/ezhttp/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package ezhttp

import (
"context"
"strings"
"testing"

. "github.com/function61/gokit/builtin"
"github.com/function61/gokit/testing/assert"
)

func TestCURLEquivalent(t *testing.T) {
curlCmd := Must(NewPost(context.Background(), "https://example.net/hello", Header("x-correlation-id", "123")).CURLEquivalent())

assert.Equal(t, strings.Join(curlCmd, " "), "curl --request=POST --header=X-Correlation-Id=123 https://example.net/hello")
}

0 comments on commit c46456b

Please sign in to comment.