diff --git a/README.md b/README.md index aa74534..c93d6f2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Build http requests and parse their responses with fluent syntax and wit. This package aims to quickly configure http roundtrips by covering common scenarios, while leaving all details -of http requests and responses open for the user to allow maximun flexibility. +of http requests and responses open for developers to allow maximum flexibility. Supported underlying http implementations are: @@ -12,19 +12,48 @@ Supported underlying http implementations are: #### Query Restful endpoints +```go +type GithubRepoInfo struct { + ID int `json:"id"` + URL string `json:"html_url"` +} + +func GetRepoInfo(user, repo string) (GithubRepoInfo, error) { + + call := withttp.NewCall[GithubRepoInfo](withttp.NewDefaultFastHttpHttpClientAdapter()). + WithURL(fmt.Sprintf("https://api.github.com/repos/%s/%s", user, repo)). + WithMethod(http.MethodGet). + WithHeader("User-Agent", "withttp/0.1.0 See https://github.com/sonirico/withttp", false). + WithParseJSON(). + WithExpectedStatusCodes(http.StatusOK) + + err := call.Call(context.Background()) + + return call.BodyParsed, err +} + +func main() { + info, _ := GetRepoInfo("sonirico", "withttp") + log.Println(info) +} +``` + +In case of a wide range catalog of endpoints, predefined parameters and behaviours can be +defined by employing an endpoint definition. + ```go var ( githubApi = withttp.NewEndpoint("GithubAPI"). - Request(withttp.WithURL("https://api.github.com/")) + Request(withttp.WithBaseURL("https://api.github.com/")) ) -type githubRepoInfo struct { +type GithubRepoInfo struct { ID int `json:"id"` URL string `json:"html_url"` } -func GetRepoInfo(user, repo string) (githubRepoInfo, error) { - call := withttp.NewCall[githubRepoInfo](withttp.NewDefaultFastHttpHttpClientAdapter()). +func GetRepoInfo(user, repo string) (GithubRepoInfo, error) { + call := withttp.NewCall[GithubRepoInfo](withttp.NewDefaultFastHttpHttpClientAdapter()). WithURI(fmt.Sprintf("repos/%s/%s", user, repo)). WithMethod(http.MethodGet). WithHeader("User-Agent", "withttp/0.1.0 See https://github.com/sonirico/withttp", false). @@ -34,17 +63,63 @@ func GetRepoInfo(user, repo string) (githubRepoInfo, error) { override = true return }). - WithJSON(). + WithParseJSON(). WithExpectedStatusCodes(http.StatusOK) - err := call.Call(context.Background(), githubApi) + err := call.CallEndpoint(context.Background(), githubApi) + + return call.BodyParsed, err +} + +type GithubCreateIssueResponse struct { + ID int `json:"id"` + URL string `json:"url"` +} + +func CreateRepoIssue(user, repo, title, body, assignee string) (GithubCreateIssueResponse, error) { + type payload struct { + Title string `json:"title"` + Body string `json:"body"` + Assignee string `json:"assignee"` + } + + p := payload{ + Title: title, + Body: body, + Assignee: assignee, + } + + call := withttp.NewCall[GithubCreateIssueResponse]( + withttp.NewDefaultFastHttpHttpClientAdapter(), + ). + WithURI(fmt.Sprintf("repos/%s/%s/issues", user, repo)). + WithMethod(http.MethodPost). + WithContentType("application/vnd+github+json"). + WithBody(p). + WithHeaderFunc(func() (key, value string, override bool) { + key = "Authorization" + value = fmt.Sprintf("Bearer %s", "S3cret") + override = true + return + }). + WithExpectedStatusCodes(http.StatusCreated) + + err := call.CallEndpoint(context.Background(), githubApi) + + log.Println("req body", string(call.Req.Body())) return call.BodyParsed, err } func main() { + // Fetch repo info info, _ := GetRepoInfo("sonirico", "withttp") log.Println(info) + + // Create an issue + res, err := CreateRepoIssue("sonirico", "withttp", "test", + "This is a test", "sonirico") + log.Println(res, err) } ``` @@ -79,12 +154,6 @@ func main() { WithURL("https://github.com/"). WithMethod(http.MethodGet). WithHeader("User-Agent", "withttp/0.1.0 See https://github.com/sonirico/withttp", false). - WithHeaderFunc(func() (key, value string, override bool) { - key = "X-Date" - value = time.Now().String() - override = true - return - }). WithJSONEachRowChan(res). WithExpectedStatusCodes(http.StatusOK) diff --git a/adapter_fasthttp.go b/adapter_fasthttp.go index 371a358..879c242 100644 --- a/adapter_fasthttp.go +++ b/adapter_fasthttp.go @@ -67,6 +67,14 @@ func (a *fastHttpReqAdapter) SetBody(body []byte) { a.req.SetBody(body) } +func (a *fastHttpReqAdapter) Body() []byte { + return a.req.Body() +} + +func (a *fastHttpReqAdapter) BodyStream() io.ReadCloser { + return io.NopCloser(bytes.NewReader(a.req.Body())) +} + func (a *fastHttpReqAdapter) URL() *url.URL { uri := a.req.URI() diff --git a/adapter_native.go b/adapter_native.go index b12c5d8..5a3d660 100644 --- a/adapter_native.go +++ b/adapter_native.go @@ -47,6 +47,15 @@ func (a *nativeReqAdapter) SetBody(payload []byte) { a.req.Body = io.NopCloser(bytes.NewReader(payload)) } +func (a *nativeReqAdapter) Body() []byte { + bts, _ := io.ReadAll(a.req.Body) + return bts +} + +func (a *nativeReqAdapter) BodyStream() io.ReadCloser { + return a.req.Body +} + func (a *nativeReqAdapter) URL() *url.URL { return a.req.URL } diff --git a/call.go b/call.go index 71da3d0..9eff18d 100644 --- a/call.go +++ b/call.go @@ -12,12 +12,18 @@ type ( Parse(c *Call[T], r Response) error } + CalReqOption[T any] interface { + Configure(c *Call[T], r Request) error + } + + CallReqOptionFunc[T any] func(c *Call[T], res Request) error + CallResOptionFunc[T any] func(c *Call[T], res Response) error Call[T any] struct { client client - reqOptions []ReqOption + reqOptions []ReqOption // TODO: Linked Lists resOptions []ResOption Req Request @@ -26,7 +32,8 @@ type ( BodyRaw []byte BodyParsed T - ReqBodyRaw []byte + ReqContentType ContentType + ReqBodyRaw []byte } ) @@ -34,6 +41,10 @@ func (f CallResOptionFunc[T]) Parse(c *Call[T], res Response) error { return f(c, res) } +func (f CallReqOptionFunc[T]) Configure(c *Call[T], req Request) error { + return f(c, req) +} + func NewCall[T any](client client) *Call[T] { return &Call[T]{client: client} } @@ -57,10 +68,12 @@ func (c *Call[T]) withRes(fn CallResOption[T]) *Call[T] { return c } -func (c *Call[T]) withReq(fn ReqOption) *Call[T] { +func (c *Call[T]) withReq(fn CallReqOptionFunc[T]) *Call[T] { c.reqOptions = append( c.reqOptions, - fn, + ReqOptionFunc(func(req Request) error { + return fn.Configure(c, req) + }), ) return c } @@ -84,9 +97,7 @@ func (c *Call[T]) configureReq(req Request) error { } func (c *Call[T]) Request(opts ...ReqOption) *Call[T] { - for _, opt := range opts { - c.withReq(opt) - } + c.reqOptions = append(c.reqOptions, opts...) return c } @@ -95,8 +106,36 @@ func (c *Call[T]) Response(opts ...ResOption) *Call[T] { return c } -func (c *Call[T]) Call(ctx context.Context, e *Endpoint) (err error) { +func (c *Call[T]) Call(ctx context.Context) (err error) { + req, err := c.client.Request() + defer func() { c.Req = req }() + + if err != nil { + return + } + + if err = c.configureReq(req); err != nil { + return + } + + res, err := c.client.Do(ctx, req) + + if err != nil { + return + } + + defer func() { c.Res = res }() + + if err = c.parseRes(res); err != nil { + return + } + + return +} + +func (c *Call[T]) CallEndpoint(ctx context.Context, e *Endpoint) (err error) { req, err := c.client.Request() + defer func() { c.Req = req }() if err != nil { return @@ -118,6 +157,8 @@ func (c *Call[T]) Call(ctx context.Context, e *Endpoint) (err error) { return } + defer func() { c.Res = res }() + for _, opt := range e.responseOpts { if err = opt.Parse(res); err != nil { return @@ -132,40 +173,29 @@ func (c *Call[T]) Call(ctx context.Context, e *Endpoint) (err error) { } func (c *Call[T]) WithURL(raw string) *Call[T] { - return c.withReq(WithURL(raw)) + return c.withReq(WithURL[T](raw)) } func (c *Call[T]) WithURI(raw string) *Call[T] { - return c.withReq(WithURI(raw)) + return c.withReq(WithURI[T](raw)) } func (c *Call[T]) WithMethod(method string) *Call[T] { - return c.withReq( - ReqOptionFunc(func(req Request) error { - req.SetMethod(method) - return nil - }), - ) + return c.withReq(WithMethod[T](method)) } // WithBodyStream receives a stream of data to set on the request. Second parameter `bodySize` indicates // the estimated content-length of this stream. Required when employing fasthttp http client. func (c *Call[T]) WithBodyStream(rc io.ReadCloser, bodySize int) *Call[T] { - return c.withReq( - ReqOptionFunc(func(req Request) error { - req.SetBodyStream(rc, bodySize) - return nil - }), - ) + return c.withReq(WithBodyStream[T](rc, bodySize)) +} + +func (c *Call[T]) WithBody(payload any) *Call[T] { + return c.withReq(WithBody[T](payload)) } func (c *Call[T]) WithRawBody(payload []byte) *Call[T] { - return c.withReq( - ReqOptionFunc(func(req Request) error { - req.SetBody(payload) - return nil - }), - ) + return c.withReq(WithRawBody[T](payload)) } func (c *Call[T]) WithContentLength(length int) *Call[T] { @@ -173,24 +203,19 @@ func (c *Call[T]) WithContentLength(length int) *Call[T] { } func (c *Call[T]) WithHeader(key, value string, override bool) *Call[T] { - return c.withReq( - ReqOptionFunc(func(req Request) error { - return ConfigureHeader(req, key, value, override) - }), - ) + return c.withReq(WithHeader[T](key, value, override)) } func (c *Call[T]) WithHeaderFunc(fn func() (key, value string, override bool)) *Call[T] { - return c.withReq( - ReqOptionFunc(func(req Request) error { - key, value, override := fn() - return ConfigureHeader(req, key, value, override) - }), - ) + return c.withReq(WithHeaderFunc[T](fn)) +} + +func (c *Call[T]) WithContentType(ct ContentType) *Call[T] { + return c.withReq(WithContentType[T](ct)) } func (c *Call[T]) WithReadBody() *Call[T] { - return c.withRes(WithRawBody[T]()) + return c.withRes(WithParseBodyRaw[T]()) } func (c *Call[T]) WithStreamChan(factory StreamFactory[T], ch chan<- T) *Call[T] { @@ -213,7 +238,7 @@ func (c *Call[T]) WithIgnoreBody() *Call[T] { return c.withRes(WithIgnoredBody[T]()) } -func (c *Call[T]) WithJSON() *Call[T] { +func (c *Call[T]) WithParseJSON() *Call[T] { return c.withRes(WithJSON[T]()) } diff --git a/codec/constants.go b/codec/constants.go new file mode 100644 index 0000000..50b6127 --- /dev/null +++ b/codec/constants.go @@ -0,0 +1,5 @@ +package codec + +var ( + NativeJSONCodec = NewNativeJsonCodec() +) diff --git a/content_types.go b/content_types.go new file mode 100644 index 0000000..12b7a06 --- /dev/null +++ b/content_types.go @@ -0,0 +1,39 @@ +package withttp + +import ( + "github.com/pkg/errors" + "github.com/sonirico/withttp/codec" + "strings" +) + +type ( + ContentType string +) + +var ( + ContentTypeJSON = "application/json" +) + +var ( + ErrUnknownContentType = errors.New("unknown content type") +) + +func (c ContentType) String() string { + return string(c) +} + +func (c ContentType) IsJSON() bool { + lower := strings.ToLower(strings.TrimSpace(c.String())) + hasApp := strings.Contains(lower, "application") + hasJSon := strings.Contains(lower, "json") + return hasApp && hasJSon +} + +func (c ContentType) Codec() (codec.Codec, error) { + switch { + case c.IsJSON(): + return codec.NativeJSONCodec, nil + default: + return nil, errors.Wrapf(ErrUnknownContentType, "got: '%s'", c.String()) + } +} diff --git a/endpoint.go b/endpoint.go index 7b07ba6..4bab4c4 100644 --- a/endpoint.go +++ b/endpoint.go @@ -17,6 +17,9 @@ type ( SetBodyStream(rc io.ReadCloser, bodySize int) SetBody([]byte) + Body() []byte + BodyStream() io.ReadCloser + URL() *url.URL } diff --git a/examples/fasthttp/main.go b/examples/fasthttp/main.go index 89193d4..5e056a2 100644 --- a/examples/fasthttp/main.go +++ b/examples/fasthttp/main.go @@ -7,14 +7,12 @@ import ( "net/http" "time" - "github.com/sonirico/withttp/codec" - "github.com/sonirico/withttp" ) var ( githubApi = withttp.NewEndpoint("GithubAPI"). - Request(withttp.WithURL("https://api.github.com/")) + Request(withttp.WithBaseURL("https://api.github.com/")) ) type GithubRepoInfo struct { @@ -33,10 +31,10 @@ func GetRepoInfo(user, repo string) (GithubRepoInfo, error) { override = true return }). - WithJSON(). + WithParseJSON(). WithExpectedStatusCodes(http.StatusOK) - err := call.Call(context.Background(), githubApi) + err := call.CallEndpoint(context.Background(), githubApi) return call.BodyParsed, err } @@ -59,35 +57,34 @@ func CreateRepoIssue(user, repo, title, body, assignee string) (GithubCreateIssu Assignee: assignee, } - data, err := codec.NewNativeJsonCodec().Encode(p) - if err != nil { - panic(err) - } - call := withttp.NewCall[GithubCreateIssueResponse]( withttp.NewDefaultFastHttpHttpClientAdapter(), ). - //WithURI(fmt.Sprintf("repos/%s/%s/issues", user, repo)). - WithURL("https://webhook.site/24e84e8f-75cf-4239-828e-8bed244c0afb"). + WithURI(fmt.Sprintf("repos/%s/%s/issues", user, repo)). WithMethod(http.MethodPost). - WithRawBody(data). - WithHeader("User-Agent", "withttp/0.1.0 See https://github.com/sonirico/withttp", false). + WithContentType("application/vnd+github+json"). + WithBody(p). WithHeaderFunc(func() (key, value string, override bool) { - key = "X-Date" - value = time.Now().String() + key = "Authorization" + value = fmt.Sprintf("Bearer %s", "S3cret") override = true return }). - WithExpectedStatusCodes(http.StatusOK) + WithExpectedStatusCodes(http.StatusCreated) - err = call.Call(context.Background(), githubApi) + err := call.CallEndpoint(context.Background(), githubApi) + + log.Println("req body", string(call.Req.Body())) return call.BodyParsed, err } func main() { - //info, _ := GetRepoInfo("sonirico", "withttp") - //log.Println(info) + // Fetch repo info + info, _ := GetRepoInfo("sonirico", "withttp") + log.Println(info) + + // Create an issue res, err := CreateRepoIssue("sonirico", "withttp", "test", "This is a test", "sonirico") log.Println(res, err) diff --git a/examples/mock/main.go b/examples/mock/main.go index 32f12ad..e8f013d 100644 --- a/examples/mock/main.go +++ b/examples/mock/main.go @@ -14,7 +14,7 @@ import ( var ( exchangeListOrders = withttp.NewEndpoint("ListOrders"). - Request(withttp.WithURL("http://example.com")). + Request(withttp.WithBaseURL("http://example.com")). Response( withttp.WithMockedRes(func(res withttp.Response) { res.SetBody(io.NopCloser(bytes.NewReader(mockResponse))) @@ -53,7 +53,7 @@ func main() { } }() - err := call.Call(context.Background(), exchangeListOrders) + err := call.CallEndpoint(context.Background(), exchangeListOrders) if err != nil { panic(err) diff --git a/examples/singlecall/main.go b/examples/singlecall/main.go new file mode 100644 index 0000000..f06b8a9 --- /dev/null +++ b/examples/singlecall/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + + "github.com/sonirico/withttp" +) + +type GithubRepoInfo struct { + ID int `json:"id"` + URL string `json:"html_url"` +} + +func GetRepoInfo(user, repo string) (GithubRepoInfo, error) { + + call := withttp.NewCall[GithubRepoInfo](withttp.NewDefaultFastHttpHttpClientAdapter()). + WithURL(fmt.Sprintf("https://api.github.com/repos/%s/%s", user, repo)). + WithMethod(http.MethodGet). + WithHeader("User-Agent", "withttp/0.1.0 See https://github.com/sonirico/withttp", false). + WithParseJSON(). + WithExpectedStatusCodes(http.StatusOK) + + err := call.Call(context.Background()) + + return call.BodyParsed, err +} + +func main() { + info, _ := GetRepoInfo("sonirico", "withttp") + log.Println(info) +} diff --git a/with.go b/with.go index 1c28f46..6d8e2d2 100644 --- a/with.go +++ b/with.go @@ -24,7 +24,7 @@ func WithIgnoredBody[T any]() CallResOptionFunc[T] { } } -func WithRawBody[T any]() CallResOptionFunc[T] { +func WithParseBodyRaw[T any]() CallResOptionFunc[T] { return func(c *Call[T], res Response) (err error) { rc := c.bodyReader(res) defer func() { _ = rc.Close() }() @@ -87,6 +87,26 @@ func WithMockedRes(fn func(response Response)) ResOption { }) } +func WithHeader[T any](k, v string, override bool) CallReqOptionFunc[T] { + return func(_ *Call[T], req Request) error { + return ConfigureHeader(req, k, v, override) + } +} + +func WithHeaderFunc[T any](fn func() (string, string, bool)) CallReqOptionFunc[T] { + return func(_ *Call[T], req Request) error { + k, v, override := fn() + return ConfigureHeader(req, k, v, override) + } +} + +func WithContentType[T any](ct ContentType) CallReqOptionFunc[T] { + return func(c *Call[T], req Request) error { + c.ReqContentType = ct + return ConfigureHeader(req, "content-type", ct.String(), true) + } +} + func ConfigureHeader(req Request, key, value string, override bool) error { if override { req.SetHeader(key, value) @@ -96,8 +116,15 @@ func ConfigureHeader(req Request, key, value string, override bool) error { return nil } -func WithURL(raw string) ReqOption { - return ReqOptionFunc(func(req Request) (err error) { +func WithMethod[T any](method string) CallReqOptionFunc[T] { + return func(_ *Call[T], req Request) (err error) { + req.SetMethod(method) + return + } +} + +func WithURL[T any](raw string) CallReqOptionFunc[T] { + return func(_ *Call[T], req Request) (err error) { u, err := url.Parse(raw) if err != nil { return err @@ -106,14 +133,48 @@ func WithURL(raw string) ReqOption { req.SetURL(u) return - }) + } } -func WithURI(raw string) ReqOption { - return ReqOptionFunc(func(req Request) (err error) { +func WithURI[T any](raw string) CallReqOptionFunc[T] { + return func(_ *Call[T], req Request) (err error) { req.SetURL(req.URL().JoinPath(raw)) return - }) + } +} + +func WithRawBody[T any](payload []byte) CallReqOptionFunc[T] { + return func(_ *Call[T], req Request) (err error) { + req.SetBody(payload) + return nil + } +} + +func WithBody[T any](payload any) CallReqOptionFunc[T] { + return func(c *Call[T], req Request) (err error) { + data, err := EncodeBody(payload, c.ReqContentType) + if err != nil { + return err + } + req.SetBody(data) + return nil + } +} + +func WithBodyStream[T any](rc io.ReadCloser, bodySize int) CallReqOptionFunc[T] { + return func(c *Call[T], req Request) (err error) { + req.SetBodyStream(rc, bodySize) + return nil + } +} + +func EncodeBody(payload any, contentType ContentType) (bts []byte, err error) { + codec, err := contentType.Codec() + if err != nil { + return + } + bts, err = codec.Encode(payload) + return } func ReadStreamChan[T any](rc io.ReadCloser, factory StreamFactory[T], out chan<- T) (err error) { diff --git a/with_endpoint.go b/with_endpoint.go new file mode 100644 index 0000000..235c627 --- /dev/null +++ b/with_endpoint.go @@ -0,0 +1,16 @@ +package withttp + +import "net/url" + +func WithBaseURL(raw string) ReqOption { + return ReqOptionFunc(func(req Request) (err error) { + u, err := url.Parse(raw) + if err != nil { + return err + } + + req.SetURL(u) + + return + }) +}