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 ValidatorHandler; ErrorKind; URL #63

Merged
merged 27 commits into from
Jan 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
78560a7
WIP Add OnError (still needs docs, tests)
earthboundkid Sep 25, 2022
a0c528c
Add ErrorKind tests, tweak names
earthboundkid Sep 30, 2022
f4b5d8d
Fix race
earthboundkid Sep 30, 2022
4abc279
Naming consistency
earthboundkid Oct 10, 2022
8f9c113
Rename to OnValidatorError
earthboundkid Oct 24, 2022
51a747c
Change ErrorHandler signature
earthboundkid Oct 31, 2022
27a1cc4
Remove HasKindErr because it's redundant now
earthboundkid Oct 31, 2022
710e700
Run error handlers in reverse order
earthboundkid Oct 31, 2022
5cb0a52
ValidatorHandler should wrap the error kind
earthboundkid Oct 31, 2022
8e5a5fb
ExampleBuilder_OnError: show error wrapping ability
earthboundkid Nov 7, 2022
e0431c2
Add OnErrorParams type and Builder.URL()
earthboundkid Dec 9, 2022
5161466
ErrorKind: Make errors.Is testable
earthboundkid Dec 12, 2022
b9d2493
Add OnErrorParams.Context()
earthboundkid Dec 12, 2022
8606a18
Docs: Note that ep.Kind is immutable
earthboundkid Dec 12, 2022
4664414
ErrorKind: Add errors.As support
earthboundkid Dec 12, 2022
52275eb
ErrorKind: Simplify .Is checking
earthboundkid Dec 12, 2022
c143030
ErrorKind: Names for parameters
earthboundkid Dec 12, 2022
fbc516a
builder_core: Remove extra wrapper around URL error
earthboundkid Dec 12, 2022
7f4b6f8
Use ValidatorHandlers instead of ErrorHandlers
earthboundkid Jan 20, 2023
0004467
Require Go 1.20 for multierrors
earthboundkid Jan 20, 2023
47c4a48
Go.mod: Upgrade x/net
earthboundkid Jan 20, 2023
f73eba5
Builder.Transport: Simplify Client usages
earthboundkid Jan 20, 2023
35f493e
ErrorJSON: Add example
earthboundkid Jan 20, 2023
4897915
Docs: Rewrite Builder doc
earthboundkid Jan 20, 2023
5d40fe9
Docs: Better Builder.URL()
earthboundkid Jan 20, 2023
a75eb8f
Backport to Go 1.19
earthboundkid Jan 24, 2023
61e2e84
builder_core.go: Use generics to simplify
earthboundkid Jan 24, 2023
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
16 changes: 5 additions & 11 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,12 @@ jobs:
name: Build
runs-on: ubuntu-latest
steps:

- name: Set up Go 1.x
uses: actions/setup-go@v2
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: ^1.18
id: go

- name: Check out code into the Go module directory
uses: actions/checkout@v2

go-version: '1.20.0-rc.3'
cache: true
- name: Get dependencies
run: go mod download

- name: Test
run: go test -v ./...
run: go test -race -v ./...
150 changes: 94 additions & 56 deletions builder_core.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package requests

import (
"context"
"fmt"
"io"
"net/http"
"net/url"
Expand All @@ -12,39 +11,61 @@ import (
// Builder has a fluent API with methods returning a pointer to the same
// struct, which allows for declaratively describing a request by method chaining.
//
// Builder can be thought of as having the following phases:
// Builder can build a url.URL,
// build an http.Request,
// or handle a full http.Client request and response with validation.
//
// Set the base URL for a request with requests.URL then customize it with
// Scheme, Host, Hostf, Path, Pathf, and Param.
// # Build a url.URL with Builder.URL
//
// Set the method for a request with Method or use the Delete, Head, Patch,
// and Put methods. By default, requests without a body are GET and those with
// a body are POST.
// Set the base URL by creating a new Builder with [requests.URL] then customize it with
// [Builder.Scheme], [Builder.Host], [Builder.Hostf], [Builder.Path],
// [Builder.Pathf], [Builder.Param], and [Builder.ParamInt].
//
// Set headers with Header or set conventional header keys with Accept,
// CacheControl, ContentType, Cookie, UserAgent, BasicAuth, and Bearer.
// # Build an http.Request with Builder.Request
//
// Set the http.Client to use for a request with Client and/or set an
// http.RoundTripper with Transport.
// Set the method for a request with [Builder.Method]
// or use the [Builder.Delete], [Builder.Head], [Builder.Patch], and [Builder.Put] methods.
// By default, requests without a body are GET,
// and those with a body are POST.
//
// Set the body of the request, if any, with Body or use built in BodyBytes,
// BodyFile, BodyForm, BodyJSON, BodyReader, or BodyWriter.
// Set headers with [Builder.Header]
// or set conventional header keys with
// [Builder.Accept], [Builder.BasicAuth], [Builder.Bearer], [Builder.CacheControl],
// [Builder.ContentType], [Builder.Cookie], and [Builder.UserAgent].
//
// Add a response validator to the Builder with AddValidator or use the built
// in CheckStatus, CheckContentType, CopyHeaders, and Peek.
// Set the body of the request, if any, with [Builder.Body]
// or use built in [Builder.BodyBytes], [Builder.BodyFile], [Builder.BodyForm],
// [Builder.BodyJSON], [Builder.BodyReader], or [Builder.BodyWriter].
//
// Set a handler for a response with Handle or use the built in ToHeaders,
// ToJSON, ToString, ToBytesBuffer, or ToWriter.
// # Handle a request and response with Builder.Do or Builder.Fetch
//
// Fetch creates an http.Request with Request and sends it via the underlying
// http.Client with Do.
// Set the http.Client to use for a request with [Builder.Client]
// and/or set an http.RoundTripper with [Builder.Transport].
//
// Config can be used to set several options on a Builder at once.
// Add a response validator to the Builder with [Builder.AddValidator]
// or use the built in [Builder.CheckStatus], [Builder.CheckContentType],
// [Builder.CheckPeek], [Builder.CopyHeaders], and [Builder.ErrorJSON].
// If no validator has been added, Builder will use [DefaultValidator].
//
// Set a handler for a response with [Builder.Handle]
// or use the built in [Builder.ToHeaders], [Builder.ToJSON], [Builder.ToString],
// [Builder.ToBytesBuffer], or [Builder.ToWriter].
//
// [Builder.Fetch] creates an http.Request with [Builder.Request]
// and validates and handles it with [Builder.Do].
//
// # Other methods
//
// [Builder.Config] can be used to set several options on a Builder at once.
//
// In many cases, it will be possible to set most options for an API endpoint
// in a Builder at the package or struct level and then call Clone in a
// function to add request specific details for the URL, parameters, headers,
// body, or handler. The zero value of Builder is usable.
// in a Builder at the package or struct level
// and then call [Builder.Clone] in a function
// to add request specific details for the URL, parameters, headers, body, or handler.
//
// Errors returned by Builder methods will have an [ErrorKind] indicating their origin.
//
// The zero value of Builder is usable.
type Builder struct {
baseurl string
scheme, host string
Expand Down Expand Up @@ -129,6 +150,8 @@ func (rb *Builder) Cookie(name, value string) *Builder {
}

// Method sets the HTTP method for a request.
// By default, requests without a body are GET,
// and those with a body are POST.
func (rb *Builder) Method(method string) *Builder {
rb.method = method
return rb
Expand Down Expand Up @@ -181,21 +204,28 @@ func (rb *Builder) Clone() *Builder {
return &rb2
}

// Request builds a new http.Request with its context set.
func (rb *Builder) Request(ctx context.Context) (req *http.Request, err error) {
u, err := url.Parse(rb.baseurl)
if err != nil {
return nil, fmt.Errorf("could not initialize with base URL %q: %w", u, err)
func cond[T any](val bool, a, b T) T {
if val {
return a
}
if u.Scheme == "" {
u.Scheme = "https"
}
if rb.scheme != "" {
u.Scheme = rb.scheme
}
if rb.host != "" {
u.Host = rb.host
return b
}

func first[T comparable](a, b T) T {
return cond(a != *new(T), a, b)
}

// URL builds a *url.URL from the base URL and options set on the Builder.
// If a valid url.URL cannot be built,
// URL() nevertheless returns a new url.URL,
// so it is always safe to call u.String().
func (rb *Builder) URL() (u *url.URL, err error) {
u, err = url.Parse(rb.baseurl)
if err != nil {
return new(url.URL), ekwrapper{ErrURL, err}
}
u.Scheme = first(rb.scheme, first(u.Scheme, "https"))
u.Host = first(rb.host, u.Host)
for _, p := range rb.paths {
u.Path = u.ResolveReference(&url.URL{Path: p}).Path
}
Expand All @@ -206,25 +236,37 @@ func (rb *Builder) Request(ctx context.Context) (req *http.Request, err error) {
}
u.RawQuery = q.Encode()
}
// Reparsing, in case the path rewriting broke the URL
u, err = url.Parse(u.String())
if err != nil {
return new(url.URL), ekwrapper{ErrURL, err}
}
return u, nil
}

// Request builds a new http.Request with its context set.
func (rb *Builder) Request(ctx context.Context) (req *http.Request, err error) {
u, err := rb.URL()
if err != nil {
return nil, err
}
var body io.Reader
if rb.getBody != nil {
if body, err = rb.getBody(); err != nil {
return nil, err
return nil, ekwrapper{ErrRequest, err}
}
if nopper, ok := body.(nopCloser); ok {
body = nopper.Reader
}
}
method := http.MethodGet
if rb.getBody != nil {
method = http.MethodPost
}
if rb.method != "" {
method = rb.method
}
method := first(rb.method,
cond(rb.getBody != nil,
http.MethodPost,
http.MethodGet))

req, err = http.NewRequestWithContext(ctx, method, u.String(), body)
if err != nil {
return nil, err
return nil, ekwrapper{ErrRequest, err}
}
req.GetBody = rb.getBody

Expand All @@ -242,18 +284,15 @@ func (rb *Builder) Request(ctx context.Context) (req *http.Request, err error) {

// Do calls the underlying http.Client and validates and handles any resulting response. The response body is closed after all validators and the handler run.
func (rb *Builder) Do(req *http.Request) (err error) {
cl := http.DefaultClient
if rb.cl != nil {
cl = rb.cl
}
cl := first(rb.cl, http.DefaultClient)
if rb.rt != nil {
cl2 := *cl
cl2.Transport = rb.rt
cl = &cl2
}
res, err := cl.Do(req)
if err != nil {
return err
return ekwrapper{ErrTransport, err}
}
defer res.Body.Close()

Expand All @@ -262,14 +301,13 @@ func (rb *Builder) Do(req *http.Request) (err error) {
validators = []ResponseHandler{DefaultValidator}
}
if err = ChainHandlers(validators...)(res); err != nil {
return err
}
h := consumeBody
if rb.handler != nil {
h = rb.handler
return ekwrapper{ErrValidator, err}
}
h := cond(rb.handler != nil,
rb.handler,
consumeBody)
if err = h(res); err != nil {
return err
return ekwrapper{ErrHandler, err}
}
return nil
}
Expand Down
43 changes: 43 additions & 0 deletions builder_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,3 +515,46 @@ func ExampleBuilder_Transport() {
// Output:
// true
}

func ExampleBuilder_ErrorJSON() {
goodRes := requests.ReplayString(`HTTP/1.1 200 OK

{"x": 1}`)

var goodJSON1 struct{ X int }
var errJSON1 struct{ Error string }
err := requests.
URL("http://example.com/").
Transport(goodRes).
ToJSON(&goodJSON1).
ErrorJSON(&errJSON1).
Fetch(context.Background())
if err != nil {
fmt.Println("problem", err)
} else {
fmt.Println("X", goodJSON1.X)
}

badRes := requests.ReplayString(`HTTP/1.1 418 OK

{"error": "brewing"}`)

var goodJSON2 struct{ X int }
var errJSON2 struct{ Error string }
err = requests.
URL("http://example.com/").
Transport(badRes).
ToJSON(&goodJSON2).
ErrorJSON(&errJSON2).
Fetch(context.Background())
if err != nil {
fmt.Println(errors.Is(err, requests.ErrInvalidHandled))
fmt.Println(errJSON2.Error)
} else {
fmt.Println("unexpected success")
}
// Output:
// X 1
// true
// brewing
}
7 changes: 7 additions & 0 deletions builder_extras.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,10 @@ func (rb *Builder) ToHeaders(h map[string][]string) *Builder {
Head().
Handle(ChainHandlers(CopyHeaders(h), consumeBody))
}

// ErrorJSON adds a validator that applies DefaultValidator
// and decodes the response as a JSON object
// if the DefaultValidator check fails.
func (rb *Builder) ErrorJSON(v any) *Builder {
return rb.AddValidator(ErrorJSON(v))
}
4 changes: 1 addition & 3 deletions builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,7 @@ An example response.`
err := requests.
URL("example").
Scheme("string").
Client(&http.Client{
Transport: &trans,
}).
Transport(&trans).
ToString(&s).
Fetch(context.Background())
be.NilErr(t, err)
Expand Down
41 changes: 41 additions & 0 deletions errorkind.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package requests

// ErrorKind indicates where an error was returned in the process of building, validating, and handling a request.
// Errors returned by Builder can be tested for their ErrorKind using errors.Is or errors.As.
type ErrorKind int8

//go:generate stringer -type=ErrorKind

// Enum values for type ErrorKind
const (
ErrURL ErrorKind = iota // error building URL
ErrRequest // error building the request
ErrTransport // error connecting
ErrValidator // validator error
ErrHandler // handler error
)

func (ek ErrorKind) Error() string {
return ek.String()
}

type ekwrapper struct {
kind ErrorKind
error
}

func (ekw ekwrapper) Is(target error) bool {
return ekw.kind == target
}

func (ekw ekwrapper) As(target any) bool {
if ekp, ok := target.(*ErrorKind); ok {
*ekp = ekw.kind
return true
}
return false
}

func (ekw ekwrapper) Unwrap() error {
return ekw.error
}
27 changes: 27 additions & 0 deletions errorkind_string.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading