diff --git a/README.md b/README.md index 0813051..eec63d0 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ --- -A pure functional style to express communication behavior by hiding the networking complexity using combinators. This construction decorates http i/o pipeline(s) with "programmable commas", allowing to make http requests with few interesting properties such as composition and laziness. +The library implements a **pure functional style** to express communication behavior by hiding the networking complexity using combinators. This construction decorates http i/o pipeline(s) with "programmable commas", allowing to make http requests with few interesting properties such as composition and laziness. [User Guide](./doc/user-guide.md) | [Playground](https://play.golang.org/p/hPTgNhoJM2-) | @@ -41,9 +41,9 @@ A pure functional style to express communication behavior by hiding the networki ## Inspiration -Microservices have become a design style to evolve system architecture in parallel, implement stable and consistent interfaces. An expressive language is required to design the variety of network communication use-cases. Pure functional languages fit very well to express communication behavior. These languages give rich abstractions to hide the networking complexity and help us to compose a chain of network operations and represent them as pure computation, building new things from small reusable elements. This library is implemented after Erlang's [m_http](https://github.com/fogfish/m_http) +Microservices have become a design style to evolve system architecture in parallel, implement stable and consistent interfaces. An expressive language is required to design the variety of network communication use-cases. Pure functional languages fit very well to express intent of communication behavior. These languages give rich abstractions to hide the networking complexity and help us to compose a chain of network operations and represent them as pure computation, building new things from small reusable elements. This library is implemented after Erlang's [m_http](https://github.com/fogfish/m_http) -The library attempts to adapt a human-friendly logging syntax of HTTP I/O used by curl and Behavior as a Code paradigm. It connects cause-and-effect (Given/When/Then) with the networking (Input/Process/Output). +The library attempts to adapt a human-friendly logging syntax of HTTP I/O used by curl and Behavior as a Code paradigm, which connects cause-and-effect (Given/When/Then) with the networking (Input/Process/Output). ``` > GET / HTTP/1.1 @@ -57,7 +57,7 @@ The library attempts to adapt a human-friendly logging syntax of HTTP I/O used b < ... ``` -This semantic provides an intuitive approach to specify HTTP requests and expected responses. Adoption of this syntax as Go native code provides a rich capabilities for network programming. +Given semantic provides an intuitive approach to specify HTTP requests and expected responses. Adoption of this syntax as Go native code provides a rich capabilities for network programming. ## Key features @@ -75,42 +75,53 @@ Standard Golang packages implement a low-level HTTP interface, which requires kn The library requires **Go 1.18** or later -The latest version of the library is available at its `master` branch. All development, including new features and bug fixes, take place on the `master` branch using forking and pull requests as described in contribution guidelines. +The latest version of the library is available at its `main` branch. All development, including new features and bug fixes, take place on the `main` branch using forking and pull requests as described in contribution guidelines. The stable version is available via Golang modules. -The following code snippet demonstrates a typical usage scenario. See runnable [http request example](examples/request/main.go). +Use `go get` to retrieve the library and add it as dependency to your application. + +```bash +go get -u github.com/fogfish/gurl +``` + +### Quick Example + +The following code snippet demonstrates a typical usage scenario. See runnable [http request example](examples/http-request/main.go). ```go import ( "context" - "github.com/fogfish/gurl/http" - ø "github.com/fogfish/gurl/http/send" - ƒ "github.com/fogfish/gurl/http/recv" + "github.com/fogfish/gurl/v2/http" + ø "github.com/fogfish/gurl/v2/http/send" + ƒ "github.com/fogfish/gurl/v2/http/recv" ) -// You can declare any types and use them as part of networking I/O. +// Declare the type, used for networking I/O. type Payload struct { Origin string `json:"origin"` Url string `json:"url"` } -// the variable holds results of network I/O +// Define the variable holds results of network I/O var data Payload -// instance of http client -cat := http.New() - -// lazy HTTP I/O specification -err := cat.IO(context.TODO(), - // HTTP request +// Declare HTTP I/O specification +lazy := http.GET( + // specify HTTP request ø.GET.URL("http://httpbin.org/get"), ø.Accept.JSON, - // HTTP response and "recv" JSON to the variable + // assert HTTP response and "recv" JSON to the variable ƒ.Status.OK, ƒ.ContentType.JSON, ƒ.Recv(&data), ) + +// instance of HTTP stack +stack := http.New() + +// evaluate HTTP I/O specification +err := stack.IO(context.Background(), lazy) ``` ## Next steps diff --git a/doc/user-guide.md b/doc/user-guide.md index b3d3b7a..81e5227 100644 --- a/doc/user-guide.md +++ b/doc/user-guide.md @@ -28,19 +28,16 @@ ᵍ🆄🆁🅻 is a "combinator" library for network I/O. Combinators open up an opportunity to depict computation problems in terms of fundamental elements like physics talks about universe in terms of particles. The only definite purpose of combinators are building blocks for composition of "atomic" functions into computational structures. ᵍ🆄🆁🅻 combinators provide a powerful symbolic expressions in networking domain. -Standard Golang packages implements a low-level HTTP interface, which requires knowledge about protocol itself, understanding of Golang implementation aspects, and a bit of boilerplate coding. It also misses standardized chaining (composition) of individual requests. ᵍ🆄🆁🅻 inherits an ability of pure functional languages to express communication behavior by hiding the networking complexity using combinators. The composition becomes a fundamental operation in the library: the codomain of `𝒇` be the domain of `𝒈` so that the composite operation `𝒇 ◦ 𝒈` is defined. +Standard Golang packages implements a low-level HTTP interface, which requires knowledge about protocol itself, understanding of Golang implementation aspects, and a bit of boilerplate coding. It also misses standardized chaining (composition) of individual requests. ᵍ🆄🆁🅻 inherits an ability of pure functional languages to express communication behavior by hiding the networking complexity using combinators. The combinator becomes a fundamental operator in the library: the codomain of `𝒇` be the domain of `𝒈` so that the composite operation `𝒇 ◦ 𝒈` is defined. -The library uses `Arrow` as a key abstraction of combinators. It is a *pure function* that takes an abstraction of the protocol context, so called IO category and applies morphism as an "invisible" side-effect of the composition. +The library uses `Arrow` as a key abstraction of combinators. It is a *pure function* that takes an abstraction of the protocol context, so called *IO category* and applies morphism as an "invisible" side-effect of the composition. ```go -/* - -Arrow: IO ⟼ IO -*/ +// Arrow: IO ⟼ IO type Arrow func(*Context) error ``` -There are two classes of arrows. The first class is a writer morphism that focuses inside and reshapes HTTP protocol requests. The writer morphism is used to declare HTTP method, destination URL, request headers and payload. Second one is a reader morphism that focuses on the side-effect of HTTP protocol. The reader morphism is a pattern matcher, and is used to match HTTP response code, headers and response payload. +There are two classes of arrows. The first class is a writer (emitter) morphism that focuses inside and reshapes HTTP protocol requests. The writer morphism is used to declare HTTP method, destination URL, request headers and payload. Second one is a reader (matcher) morphism that focuses on the side-effect of HTTP protocol. The reader morphism is a pattern matcher, and is used to match HTTP response code, headers and response payload. Example of HTTP I/O visualization made by curl give **naive** perspective about arrows. @@ -57,16 +54,13 @@ Example of HTTP I/O visualization made by curl give **naive** perspective about ``` -## Compose HoF +## Compose High-Order Functions -`Arrow` can be composed with another `Arrow` into new `Arrow` and so on. The library supports only "and-then" style. It builds a strict product Arrow: `A × B × C × ... ⟼ D`. The product type takes a protocol context and applies "morphism" sequentially unless some step fails. Use variadic function `http.Join` to compose HTTP primitives: +`Arrow` can be composed with another `Arrow` into new `Arrow` and so on. The library supports only "and-then" style. It builds a strict product Arrow: `A × B × C × ... ⟼ D`. The product type takes a protocol context and applies "morphism" sequentially unless some step fails. Use variadic function `http.Join`, `http.GET`, `http.POST`, `http.PUT` and so on to compose HTTP primitives: ```go -/* - -Join composes HTTP arrows to high-order function -(a ⟼ b, b ⟼ c, c ⟼ d) ⤇ a ⟼ d -*/ +// Join composes HTTP arrows to high-order function +// (a ⟼ b, b ⟼ c, c ⟼ d) ⤇ a ⟼ d func http.Join(arrows ...http.Arrow) http.Arrow // @@ -83,7 +77,7 @@ Ease of the composition is one of major intent why ᵍ🆄🆁🅻 library has d ## Life-cycle ```go -lazy := http.Join(/* ... */) +lazy := http.GET(/* ... */) ``` The instance of `Arrow` produced by one of `Join` functions does not hold a result of HTTP I/O. It only builds a composable "promise" ("lazy I/O") - a pure computation. The computation needs to be evaluated by applying it over the protocol context. The library provides a simple interface to create and customize the environment. @@ -95,9 +89,6 @@ cat := http.New() // apply the computation over the environment err := cat.IO(context.TODO(), lazy) - -// alternatively, inline the request -err := cat.IO(context.TODO(), a, b, c) ``` Usage of the library for production workload requires a careful configuration of HTTP protocol timeouts, TLS policies, etc. @@ -120,19 +111,16 @@ The library consists of multiple packages, import them all ```go import ( - // core types - "github.com/fogfish/gurl" - - // support for http protocol + // core http protocol types "github.com/fogfish/gurl/http" - // writer morphism is used to declare HTTP method, - // destination URL, request headers and payload + // writer (emitter) morphism is used to declare HTTP method, + // destination URL, request headers and payload. // single letter alias (e.g. ø) makes the code less verbose ø "github.com/fogfish/gurl/http/send" - // reader morphism is a pattern matcher for HTTP response code, - // headers and response payload + // reader (matcher) morphism is a pattern matcher for HTTP response code, + // headers and response payload. // single letter alias (e.g. ƒ) makes the code less verbose ƒ "github.com/fogfish/gurl/http/recv" ) @@ -147,37 +135,52 @@ import ( Writer morphism focuses inside and reshapes HTTP requests. The writer morphism is used to declare HTTP method, destination URL, request headers and payload. +#### Method + +The library insists usage of combinators `http.GET`, `http.HEAD`, `http.POST`, `http.PUT`, `http.DELETE` and `http.PATCH` to declare the verb of HTTP request. -#### Method and URL +```go +http.GET(/* ... */) +http.PUT(/* ... */) +``` -Method and URL are only mandatory writer morphism in I/O declaration. Use `type Method string` to declare the verb of HTTP request. It's received method `URL` allows to specify a destination endpoint. +Use `ø.Method` combinator to declare other verbs ```go http.Join( - ø.Method("GET").URL("http://example.com"), + ø.Method("OPTIONS") + /* ... */ ) +``` -// The library implements a syntax sugar for mostly used HTTP Verbs -http.Join( - ø.GET.URL("http://example.com"), +#### Target URI + +Target URI is only mandatory writer morphism in I/O declaration. Use `ø.URI` to specify a destination endpoint. + +```go +http.GET( + ø.URI("http://example.com"), ) ``` -The `URL` receiver is equivalent to `fmt.Sprintf`. It uses [percent encoding](https://golang.org/pkg/fmt/) to format and escape values. +The `ø.URI` combinator is equivalent to `fmt.Sprintf`. It uses [percent encoding](https://golang.org/pkg/fmt/) to format and escape values. ```go -http.Join( - ø.GET.URL("http://%s/%s", "example.com", "foo"), +http.GET( + ø.URI("http://example.com/%s", "foo"), ) -// All path segments are escaped by default, use ø.Authority or ø.Segment +// All path segments are escaped by default, use ø.Authority or ø.Path // types to disable escaping -http.Join( - // this does not work - ø.GET.URL("%s/%s", "http://example.com", "foo/bar"), - // this works - ø.GET.URL("%s/%s", ø.Authority("http://example.com"), ø.Segment("foo/bar")), +// BAD, DOES NOT WORK +http.GET( + ø.URI("%s/%s", "http://example.com", "foo/bar"), +) + +// GOOD, IT WORKS +http.GET( + ø.URI("%s/%s", ø.Authority("http://example.com"), ø.PATH("foo/bar")), ) ``` @@ -187,8 +190,8 @@ http.Join( It is possible to inline query parameters into URL. However, this is not a type-safe approach. ```go -http.Join( - ø.GET.URL("http://example.com/?tag=%s", "foo"), +http.GET( + ø.URI("http://example.com/?tag=%s", "foo"), ) ``` @@ -200,32 +203,40 @@ type MyParam struct { Host string `json:"host,omitempty"` } -http.Join( +http.GET( // ... - ø.Params(MyParam{Site: "site", Host: "host"}), + ø.Params(MyParam{Site: "example.com", Host: "127.1"}), ), ``` +It is possible to declare individual parameters + +```go +http.GET( + ø.Param("site", "example.com"), + ø.Param("host", "127.1"), +) +``` #### Request Headers -Use `type Header string` to declare headers and its values. Each request might contain declaration of multiple headers. +Use `func Header[T http.ReadableHeaderValues](header string, value T) http.Arrow` combinator to declare headers and its values. Each request might contain declaration of multiple headers. ```go -http.Join( +http.GET( // ... - ø.Header("Content-Type").Is("application/json"), + ø.Header("Content-Type", "application/json"), ) // The library implements a syntax sugar for mostly used HTTP headers // https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Request_fields http.Join( // ... - ø.Authorization.Is("Bearer eyJhbGciOiJIU...adQssw5c"), + ø.Authorization.Set("Bearer eyJhbGciOiJIU...adQssw5c"), ) -// The library implements a syntax sugar for content negotiation headers -http.Join( +// The library implements a syntax sugar possible enumeration values +http.GET( // ... ø.Accept.JSON, ø.ContentType.HTML, @@ -234,7 +245,7 @@ http.Join( #### Request payload -The `func Send(data interface{}) http.Arrow` transmits the payload to the destination URL. The function takes Go data types (e.g. maps, struct, etc) and encodes it to binary using `Content-Type` header as a hint. The function fails if content type is not defined or not supported by the library. +The `func Send(data any) http.Arrow` transmits the payload to the destination URL. The function takes Go data types (e.g. maps, struct, etc) and encodes it to binary using `Content-Type` header as a hint. The function fails if content type is not defined or not supported by the library. ```go type MyType struct { @@ -243,31 +254,38 @@ type MyType struct { } // Encode struct to JSON -http.Join( +http.GET( // ... ø.ContentType.JSON, - ø.Send(MyType{Site: "site", Host: "host"}), + ø.Send(MyType{Site: "example.com", Host: "127.1"}), ) // Encode map to www-form-urlencoded -http.Join( +http.GET( // ... ø.ContentType.Form, ø.Send(map[string]string{ - "site": "site", - "host": "host", + "site": "example.com", + "host": "127.1", }) ) // Send string, []byte or io.Reader. Just define the right Content-Type -http.Join( +http.GET( // ... ø.ContentType.Form, - ø.Send([]byte{"site=site&host=host"}), + ø.Send([]byte{"site=example.com&host=127.1"}), ) ``` -The combinator supports: `string`, `[]byte`, `io.Reader` and any arbitrary `struct`. +The combinator supports a raw data types: +- `string` +- `*strings.Reader` +- `[]byte` +- `*bytes.Buffer` +- `*bytes.Reader` +- `io.Reader` +- any arbitrary `struct`. ### Reader morphism @@ -279,14 +297,14 @@ Reader morphism focuses on the side-effect of HTTP protocol. It does a pattern m Status code validation is only mandatory reader morphism in I/O declaration. The status code "arrow" checks the code in HTTP response and fails with error if the status code does not match the expected one. The library defines a `type StatusCode int` and constants (e.g. `Status.OK`) for all known HTTP status codes. ```go -http.Join( +http.GET( // ... ƒ.Status.OK, ) // Sometime a multiple HTTP status codes has to be accepted // `ƒ.Code` arrow is variadic function that does it -http.Join( +http.GET( // ... ƒ.Code(http.StatusOK, http.StatusCreated, http.StatusAccepted), ) @@ -294,35 +312,52 @@ http.Join( #### Response Headers -Use `type Header string` to pattern match presence of HTTP header and its value in the response. The matching fails if the response is missing the header or its value do not equal. +Use `func Header[T http.MatchableHeaderValues](header string, value T) http.Arrow` combinator to pattern match presence of HTTP header and its value in the response. The matching fails if the response is missing the header or its value do not equal. ```go -http.Join( +http.GET( // ... - ƒ.Header("Content-Type").Is("application/json"), + ƒ.Header("Content-Type", "application/json"), ) // The library implements a syntax sugar for mostly used HTTP headers // https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Response_fields -http.Join( +http.GET( // ... ƒ.Authorization.Is("Bearer eyJhbGciOiJIU...adQssw5c"), ) // The library implements a syntax sugar for content negotiation headers -http.Join( +http.GET( // ... ƒ.ContentType.JSON, ) -// Any arrow is a syntax sugar of Header("Content-Type").Is("*") -http.Join( +// Any arrow is a syntax sugar of Header("Content-Type", "*") +http.GET( // ... ƒ.Server.Any, ƒ.ContentType.Any, ) ``` +The combinator support "lifting" of header value into the variable: + +```go +var ( + date time.Time + mime string + some string +) + +http.GET( + // ... + ƒ.Date.To(&date), + ƒ.ContentType.To(&mime), + ƒ.Header("X-Some", &some), +) +``` + #### Response Payload The `func Recv[T any](out *T) http.Arrow` decodes the response payload to Golang native data structure using Content-Type header as a hint. @@ -334,7 +369,7 @@ type MyType struct { } var data MyType -http.Join( +http.GET( // ... ƒ.Recv(&data), // Note: pointer to data structure is required ) @@ -348,7 +383,7 @@ It also receives raw binaries in case data type is not supported. ```go var data []byte -http.Join( +http.GET( // ... ƒ.Bytes(&data), // Note: pointer to data buffer is required ) @@ -362,20 +397,20 @@ A pure functional style of development does not have variables or assignment sta type MyClient http.Stack func (cli MyClient) Request(host, token string, req T) (*T, error) { - return http.IO[T](cat.WithContext(context.TODO()), - // - ø.GET.URL("https://%s", host), - ø.Authorization.Is(token), - ø.Send(req), - // - ƒ.Status.OK, + return http.IO[T](cat.WithContext(context.Background()), + http.GET( + ø.GET.URL("https://%s", host), + ø.Authorization.Set(token), + ø.Send(req), + ƒ.Status.OK, + ) ) } ``` ## Assert Protocol Payload -ᵍ🆄🆁🅻 library is not only about networking I/O. It also allows to assert the response. It defines a few helper functions that combine assert logic with I/O chain. These functions act as lense that are focused inside the structure, fetching values and asserts them. These helpers abort the evaluation of “program” if expectations do not match actual response. The `func FMap(f func() error) Arrow` lifts any function/closure to composable `Arrow`, allowing to implement assert procedure. +ᵍ🆄🆁🅻 library is not only about networking I/O. It also allows to assert the response. The `type Arrow func(*http.Context) error` is "open" interface to combine assert logic with I/O chain. These functions act as lense that are focused inside the structure, fetching values and asserts them. These helpers abort the evaluation of “program” if expectations do not match actual response. ```go type T struct { @@ -392,7 +427,7 @@ func (t *T) CheckValue(*http.Context) error { } func (t *T) SomeIO() gurl.Arrow { - return http.Join( + return http.GET( // ... ƒ.Recv(t), // compose the assertion into I/O chain @@ -409,7 +444,6 @@ Ease of the composition is one of major feature in ᵍ🆄🆁🅻 library. It a ```go // declare a product type to depict IO context type HoF struct { - http.Stack Token AccessToken User User Org Org @@ -417,37 +451,41 @@ type HoF struct { // Declare set of independent HTTP I/O. // Each operation either reads or writes the context -func (hof *HoF) FetchAccessToken() error { - return hof.IO(context.TODO(), +func (hof *HoF) FetchAccessToken() http.Arrow { + return http.GET( // ... ƒ.Recv(&hof.Token), ) } func (hof *HoF) FetchUser() error { - return hof.IO(context.TODO(), - ø.POST.URL(/* ... */), - ø.Authorization().Val(hof.Token), + return http.POST( + ø.URI(/* ... */), + ø.Authorization.Set(hof.Token), // ... ƒ.Recv(&hof.User), ) } func (hof *HoF) FetchContribution() error { - return hof.IO(context.TODO(), - ø.POST(/* ... */), - ø.Authorization().Val(hof.Token), + return http.POST( + ø.URI(/* ... */), + ø.Authorization.Set(hof.Token), // ... ƒ.Recv(&hof.Org), ) } +stack := http.New() + // Combine HTTP I/O to sequential chain of execution -api := &HoF{Stack: http.New()} -err := gurl.Join( - api.FetchAccessToken, - api.FetchUser, - api.FetchContribution, +api := &HoF{} +err := stack.IO(context.Background(), + http.Join( + api.FetchAccessToken(), + api.FetchUser(), + api.FetchContribution(), + ), ) ``` diff --git a/examples/README.md b/examples/README.md index cdb7d00..88bc90d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,6 +5,10 @@ --- +- [Making a simple HTTP request](./http-request/main.go) +- [Parsing and Validating HTTP response](./http-response/main.go) +- []() + 1. [Making a HTTP request](./request) and receiving response and parsing it into Golang structure. 2. [Making a chain of HTTP I/O](./hof), building a high order function to support complex I/O scenario. 3. [Making recursion](./loop) or looping through multiple HTTP links. \ No newline at end of file diff --git a/examples/loop/main.go b/examples/http-recursion/main.go similarity index 58% rename from examples/loop/main.go rename to examples/http-recursion/main.go index 12dcd3a..d811380 100644 --- a/examples/loop/main.go +++ b/examples/http-recursion/main.go @@ -1,36 +1,30 @@ -// // Copyright (C) 2019 Dmitry Kolesnikov // // This file may be modified and distributed under the terms // of the MIT license. See the LICENSE file for details. // https://github.com/fogfish/gurl -// -package main - -/* - -The example shows recursion of HTTP. The recurion is demonstarted as -sequential retrival of content until EOF. -In pure functional environment the recursion can be defined as - -lookup(Page) -> - [m_state || - Head <- request(Token, Url, Page), - Tail <- untilEOF(Head, Token, Url, Page), - cats:unit(Head ++ Tail) - ]. +package main -*/ +// The example shows recursion of HTTP. The recurion is demonstarted as +// sequential retrieval of content until EOF. +// +// In pure functional environment the recursion can be defined as +// +// lookup(Page) -> +// [m_state || +// Head <- request(Token, Url, Page), +// Tail <- untilEOF(Head, Token, Url, Page), +// cats:unit(Head ++ Tail) +// ]. import ( "context" "fmt" - "strconv" - "github.com/fogfish/gurl/http" - ƒ "github.com/fogfish/gurl/http/recv" - ø "github.com/fogfish/gurl/http/send" + "github.com/fogfish/gurl/v2/http" + ƒ "github.com/fogfish/gurl/v2/http/recv" + ø "github.com/fogfish/gurl/v2/http/send" ) // repo is a payload returned by api @@ -42,12 +36,15 @@ type repo struct { type seq []repo // request declares HTTP I/O that fetches a portion (page) from api -func request(cat http.Stack, page int) (*seq, error) { - return http.IO[seq](cat.WithContext(context.TODO()), - ø.GET.URL("https://api.github.com/users/fogfish/repos"), - ø.Params(map[string]string{"type": "all", "page": strconv.Itoa(page)}), +func request(page int) (*seq, http.Arrow) { + var seq seq + return &seq, http.GET( + ø.URI("https://api.github.com/users/fogfish/repos"), + ø.Param("type", "all"), + ø.Param("page", page), ø.Accept.JSON, ƒ.Status.OK, + ƒ.Recv(&seq), ) } @@ -59,7 +56,8 @@ func lookup(cat http.Stack, page int) (seq, error) { pid := page for { - h, err := request(cat, pid) + h, lazy := request(pid) + err := cat.IO(context.Background(), lazy) if err != nil { return nil, err } diff --git a/examples/http-request/main.go b/examples/http-request/main.go new file mode 100644 index 0000000..9a1a4b4 --- /dev/null +++ b/examples/http-request/main.go @@ -0,0 +1,53 @@ +// +// Copyright (C) 2019 - 2023 Dmitry Kolesnikov +// +// This file may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// https://github.com/fogfish/gurl +// + +package main + +import ( + "context" + + "github.com/fogfish/gurl/v2/http" + ƒ "github.com/fogfish/gurl/v2/http/recv" + ø "github.com/fogfish/gurl/v2/http/send" +) + +// Declare the type, used for networking I/O. +type Payload struct { + Origin string `json:"origin"` + Url string `json:"url"` +} + +// declares http I/O +func request() http.Arrow { + var data Payload + + return http.GET( + // specify specify the request + ø.URI("https://httpbin.org/get"), + ø.Accept.ApplicationJSON, + + // specify requirements to the response + ƒ.Status.OK, + ƒ.ContentType.JSON, + ƒ.Recv(&data), + ) +} + +func main() { + // instance of http stack + stack := http.New(http.LogPayload()) + + // declares http i/o + lazy := request() + + // executes http I/O + err := stack.IO(context.Background(), lazy) + if err != nil { + panic(err) + } +} diff --git a/examples/hof/main.go b/examples/http-response-chain/main.go similarity index 51% rename from examples/hof/main.go rename to examples/http-response-chain/main.go index c25728d..dfb816d 100644 --- a/examples/hof/main.go +++ b/examples/http-response-chain/main.go @@ -18,89 +18,82 @@ import ( "context" "fmt" - "github.com/fogfish/gurl" - "github.com/fogfish/gurl/http" - ƒ "github.com/fogfish/gurl/http/recv" - ø "github.com/fogfish/gurl/http/send" + "github.com/fogfish/gurl/v2/http" + ƒ "github.com/fogfish/gurl/v2/http/recv" + ø "github.com/fogfish/gurl/v2/http/send" ) // id implements payload for https://httpbin.org/uuid -type tID struct { +type ID struct { UUID string `json:"uuid,omitempty"` } // httpbin implements payload for https://httpbin.org/post -type tHTTPBin struct { +type HTTPBin struct { URL string `json:"url,omitempty"` Data string `json:"data,omitempty"` } -type tHoF struct { - http.Stack - tID - tHTTPBin +// context for HTTP I/O +type Heap struct { + ID + HTTPBin } -// // uuid declares HTTP I/O. Its result is returned via id variable. -func (hof *tHoF) uuid() error { - return hof.IO(context.TODO(), - ø.GET.URL("https://httpbin.org/uuid"), +func (hof *Heap) uuid() http.Arrow { + return http.GET( + ø.URI("https://httpbin.org/uuid"), ø.Accept.JSON, ƒ.Status.OK, ƒ.ContentType.JSON, - ƒ.Recv(&hof.tID), + ƒ.Recv(&hof.ID), ) } -// // post declares HTTP I/O. The HTTP request requires uuid. // Its result is returned via doc variable. -func (hof *tHoF) post() error { - return hof.IO(context.TODO(), - ø.POST.URL("https://httpbin.org/post"), +func (hof *Heap) post() http.Arrow { + return http.POST( + ø.URI("https://httpbin.org/post"), ø.Accept.JSON, ø.ContentType.JSON, - ø.Send(hof.tID.UUID), + ø.Send(&hof.ID.UUID), ƒ.Status.OK, - ƒ.Recv(&hof.tHTTPBin), + ƒ.Recv(&hof.HTTPBin), ) } -// -// hof is a high-order function. It is composed from atomic HTTP I/O into -// the chain of requests. HoF returns results via val variable -func hof(cat http.Stack) (*tHoF, error) { +// request is a high-order function. It is composed from atomic HTTP I/O into +// the chain of requests. +func request() (*Heap, http.Arrow) { + var heap Heap + // // HoF combines HTTP requests to // * https://httpbin.org/uuid // * https://httpbin.org/post // // results of HTTP I/O is persisted in the internal state - val := tHoF{Stack: cat} - - err := gurl.Join( - val.uuid, - val.post, + return &heap, http.Join( + heap.uuid(), + heap.post(), ) - - return &val, err -} - -func eval(cat http.Stack) { - val, err := hof(cat) - if err != nil { - fmt.Printf("fail %v\n", err) - } - fmt.Printf("==> %v\n", val) } func main() { - cat := http.New(http.LogPayload()) + // instance of http stack + stack := http.New(http.LogPayload()) - for i := 0; i < 3; i++ { - eval(cat) + data, lazy := request() + + // executes http I/O + err := stack.IO(context.Background(), lazy) + if err != nil { + panic(err) } + + fmt.Printf("==> %+v\n", data) } diff --git a/examples/http-response/main.go b/examples/http-response/main.go new file mode 100644 index 0000000..cf4d300 --- /dev/null +++ b/examples/http-response/main.go @@ -0,0 +1,76 @@ +// +// Copyright (C) 2019 Dmitry Kolesnikov +// +// This file may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// https://github.com/fogfish/gurl +// + +package main + +import ( + "context" + "fmt" + + "github.com/fogfish/gurl/v2/http" + ƒ "github.com/fogfish/gurl/v2/http/recv" + ø "github.com/fogfish/gurl/v2/http/send" +) + +// data types used by HTTP payload(s) +type Headers struct { + UserAgent string `json:"User-Agent,omitempty"` +} + +type HTTPBin struct { + URL string `json:"url,omitempty"` + Origin string `json:"origin,omitempty"` + Headers Headers `json:"headers,omitempty"` +} + +// combinator validates HTTP response +func (bin *HTTPBin) validate(*http.Context) error { + if bin.Headers.UserAgent == "" { + return fmt.Errorf("User-Agent is not defined") + } + + if bin.Headers.UserAgent != "gurl" { + return fmt.Errorf("User-Agent is not valid") + } + + return nil +} + +func request() (*HTTPBin, http.Arrow) { + var data HTTPBin + + return &data, http.GET( + // HTTP Request + ø.URI("https://httpbin.org/get"), + ø.Accept.JSON, + ø.UserAgent.Set("gurl"), + + // HTTP Response + ƒ.Status.OK, + ƒ.ContentType.JSON, + ƒ.Recv(&data), + + // asserts + data.validate, + ) +} + +func main() { + // instance of http stack + stack := http.New(http.LogPayload()) + + data, lazy := request() + + // executes http I/O + err := stack.IO(context.Background(), lazy) + if err != nil { + panic(err) + } + + fmt.Printf("==> %+v\n", data) +} diff --git a/examples/request/main.go b/examples/request/main.go deleted file mode 100644 index c39ae51..0000000 --- a/examples/request/main.go +++ /dev/null @@ -1,80 +0,0 @@ -// -// Copyright (C) 2019 Dmitry Kolesnikov -// -// This file may be modified and distributed under the terms -// of the MIT license. See the LICENSE file for details. -// https://github.com/fogfish/gurl -// - -package main - -/* - -Example shows a basic usage of HTTP I/O. - -*/ - -import ( - "context" - "fmt" - - "github.com/fogfish/gurl/http" - ƒ "github.com/fogfish/gurl/http/recv" - ø "github.com/fogfish/gurl/http/send" -) - -// data types used by HTTP payload(s) -type tHeaders struct { - UserAgent string `json:"User-Agent,omitempty"` -} - -type tHTTPBin struct { - URL string `json:"url,omitempty"` - Origin string `json:"origin,omitempty"` - Headers tHeaders `json:"headers,omitempty"` -} - -func (bin *tHTTPBin) validate(*http.Context) error { - if bin.Headers.UserAgent == "" { - return fmt.Errorf("User-Agent is not defined") - } - - if bin.Headers.UserAgent != "gurl" { - return fmt.Errorf("User-Agent is not valid") - } - - return nil -} - -// basic declarative request -func request(cat http.Stack) (*tHTTPBin, error) { - var data tHTTPBin - - err := cat.IO(context.TODO(), - // HTTP Request - ø.GET.URL("https://httpbin.org/get"), - ø.Accept.JSON, - ø.UserAgent.Is("gurl"), - - // HTTP Response - ƒ.Status.OK, - ƒ.ContentType.JSON, - ƒ.Recv(&data), - - // asserts - data.validate, - ) - - return &data, err -} - -func main() { - cat := http.New(http.LogPayload()) - - val, err := request(cat) - if err != nil { - fmt.Printf("fail %v\n", err) - } - - fmt.Printf("==> %v\n", val) -} diff --git a/examples/trace/main.go b/examples/trace/main.go index 91efbd3..7c1e6e1 100644 --- a/examples/trace/main.go +++ b/examples/trace/main.go @@ -8,30 +8,24 @@ package main -/* - -Example shows a basic usage of HTTP I/O. - -*/ - import ( "context" "fmt" - "github.com/fogfish/gurl/http" - ƒ "github.com/fogfish/gurl/http/recv" - ø "github.com/fogfish/gurl/http/send" + "github.com/fogfish/gurl/v2/http" + ƒ "github.com/fogfish/gurl/v2/http/recv" + ø "github.com/fogfish/gurl/v2/http/send" ) // basic declarative request func request(ctx context.Context, cat http.Stack) error { return cat.IO(ctx, - // HTTP Request - ø.GET.URL("https://httpbin.org/get"), - ø.Accept.JSON, - ø.UserAgent.Is("gurl"), - // HTTP Response and its validation - ƒ.Status.OK, + http.GET( + ø.URI("https://httpbin.org/get"), + ø.Accept.JSON, + ø.UserAgent.Set("gurl"), + ƒ.Status.OK, + ), ) } diff --git a/examples/trace/tracer.go b/examples/trace/tracer.go index b00e50b..9d07665 100644 --- a/examples/trace/tracer.go +++ b/examples/trace/tracer.go @@ -1,3 +1,11 @@ +// +// Copyright (C) 2019 - 2023 Dmitry Kolesnikov +// +// This file may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// https://github.com/fogfish/gurl +// + package main import ( diff --git a/go.mod b/go.mod index 2ee77a6..3be440f 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ -module github.com/fogfish/gurl +module github.com/fogfish/gurl/v2 go 1.19 require ( github.com/ajg/form v1.5.2-0.20200323032839-9aeb3cf462e1 - github.com/fogfish/it v0.9.1 - golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd + github.com/fogfish/it/v2 v2.0.1 + golang.org/x/net v0.7.0 ) diff --git a/go.sum b/go.sum index bed935c..bf12fb1 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ github.com/ajg/form v1.5.2-0.20200323032839-9aeb3cf462e1 h1:8Qzi+0Uch1VJvdrOhJ8U8FqoPLbUdETPgMqGJ6DSMSQ= github.com/ajg/form v1.5.2-0.20200323032839-9aeb3cf462e1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= -github.com/fogfish/it v0.9.1 h1:Pu+qgqBV2ilZDzZzPIbUIhMIkdpHgbGUsdEwVQvBxNQ= -github.com/fogfish/it v0.9.1/go.mod h1:NQJG4Ygvek85y7zGj0Gny8+6ygAnHjfBORhI7TdQhp4= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +github.com/fogfish/it/v2 v2.0.1 h1:vu3kV2xzYDPHoMHMABxXeu5CoMcTfRc4gkWkzOUkRJY= +github.com/fogfish/it/v2 v2.0.1/go.mod h1:h5FdKaEQT4sUEykiVkB8VV4jX27XabFVeWhoDZaRZtE= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= diff --git a/http/context.go b/http/context.go new file mode 100644 index 0000000..265d419 --- /dev/null +++ b/http/context.go @@ -0,0 +1,118 @@ +// +// Copyright (C) 2019 - 2023 Dmitry Kolesnikov +// +// This file may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// https://github.com/fogfish/gurl +// + +package http + +import ( + "context" + "io" + "log" + "net/http" + "net/http/httputil" +) + +// +// The file implements the context for Arrow +// + +// Context of HTTP I/O +type Context struct { + context.Context + + Method string + Request *http.Request + Response *http.Response + stack *Protocol +} + +// IO executes protocol operations +func (ctx *Context) IO(arrows ...Arrow) error { + for _, f := range arrows { + if err := f(ctx); err != nil { + return err + } + } + + if ctx.Response != nil { + // Note: due to Golang HTTP pool implementation we need to consume and + // discard body. Otherwise, HTTP connection is not returned to + // to the pool. + body := ctx.Response.Body + ctx.Response = nil + + _, err := io.Copy(io.Discard, body) + if err != nil { + return err + } + + err = body.Close() + if err != nil { + return err + } + } + + return nil +} + +// Unsafe evaluates current context of HTTP I/O +func (ctx *Context) Unsafe() error { + eg := ctx.Request + + if ctx.Context != nil { + eg = eg.WithContext(ctx.Context) + } + + ctx.logSend(ctx.stack.LogLevel, eg) + + in, err := ctx.stack.Do(eg) + if err != nil { + return err + } + + ctx.Response = in + + ctx.logRecv(ctx.stack.LogLevel, in) + + return nil +} + +func (ctx *Context) discardBody() error { + if ctx.Response != nil { + // Note: due to Golang HTTP pool implementation we need to consume and + // discard body. Otherwise, HTTP connection is not returned to + // to the pool. + body := ctx.Response.Body + ctx.Response = nil + + if _, err := io.Copy(io.Discard, body); err != nil { + return err + } + + if err := body.Close(); err != nil { + return err + } + } + + return nil +} + +func (ctx *Context) logSend(level int, eg *http.Request) { + if level >= 1 { + if msg, err := httputil.DumpRequest(eg, level == 3); err == nil { + log.Printf(">>>>\n%s\n", msg) + } + } +} + +func (ctx *Context) logRecv(level int, in *http.Response) { + if level >= 2 { + if msg, err := httputil.DumpResponse(in, level == 3); err == nil { + log.Printf("<<<<\n%s\n", msg) + } + } +} diff --git a/http/doc.go b/http/doc.go index c88ea1d..9f5530b 100644 --- a/http/doc.go +++ b/http/doc.go @@ -7,16 +7,15 @@ // /* - Package http defines category of HTTP I/O, "do"-notation becomes - http.Join( - ø..., - ø..., + http.Join( + ø..., + ø..., - ƒ..., - ƒ..., - ) + ƒ..., + ƒ..., + ) Symbol `ø` (option + o) is an convenient alias to module gurl/http/send, which defines writer morphism that focuses inside and reshapes HTTP protocol request. @@ -33,63 +32,62 @@ Let's look on step-by-step usage of the category. **Method and URL** are mandatory. It has to be a first element in the construction. - http.Join( - ø.GET("http://example.com"), - ... - ) + http.GET( + ø.URI("http://example.com"), + ... + ) Definition of **request headers** is an optional. You can list as many headers as needed. Either using string literals or variables. Some frequently used headers implements aliases (e.g. ø.ContentJSON(), ...) - http.Join( - ... - ø.Header("Accept").Is("application/json"), - ø.Header("Authorization").Val(&token), - ... - ) + http.GET( + ... + ø.Header("Accept", "application/json"), + ø.Header("Authorization", &token), + ... + ) The **request payload** is also an optional. You can also use native Golang data types as egress payload. The library implicitly encodes input structures to binary using Content-Type as a hint. - http.Join( - ... - ø.Send(MyType{Hello: "World"}), - ... - ) + http.GET( + ... + ø.Send(MyType{Hello: "World"}), + ... + ) The declaration of expected response is always starts with mandatory HTTP **status code**. The execution fails if peer responds with other than specified value. - http.Join( - ... - ƒ.Code(http.StatusCodeOK), - ... - ) + http.GET( + ... + ƒ.Code(http.StatusCodeOK), + ... + ) It is possible to match presence of header in the response, match its entire content or lift the header value to a variable. The execution fails if HTTP response do not match the expectation. - http.Join( - ... - ƒ.Header("Content-Type").Is("application/json"), - ... - ) + http.GET( + ... + ƒ.Header("Content-Type", "application/json"), + ... + ) The library is able to **decode payload** into Golang native data structure using Content-Type header as a hint. - var data MyType - http.Join( - ... - ƒ.Recv(&data) - ... - ) + var data MyType + http.GET( + ... + ƒ.Recv(&data) + ... + ) Please note, the library implements lenses to inline assert of decoded content. See the documentation of gurl/http/recv module. - */ package http diff --git a/http/recv/arrows.go b/http/recv/arrows.go index 9f5aa0b..c8c6406 100644 --- a/http/recv/arrows.go +++ b/http/recv/arrows.go @@ -1,5 +1,5 @@ // -// Copyright (C) 2019 Dmitry Kolesnikov +// Copyright (C) 2019 - 2023 Dmitry Kolesnikov // // This file may be modified and distributed under the terms // of the MIT license. See the LICENSE file for details. @@ -13,11 +13,13 @@ import ( "encoding/json" "fmt" "io" + "strconv" "strings" + "time" "github.com/ajg/form" - "github.com/fogfish/gurl" - "github.com/fogfish/gurl/http" + "github.com/fogfish/gurl/v2" + "github.com/fogfish/gurl/v2/http" ) //------------------------------------------------------------------- @@ -45,30 +47,32 @@ func Code(code ...http.StatusCode) http.Arrow { func hasCode(s []http.StatusCode, e int) bool { for _, a := range s { - if a.Value() == e { + if a.StatusCode() == e { return true } } return false } -/* -StatusCode is a warpper type over http.StatusCode - - http.Join( - ... - ƒ.Code(http.StatusOK), - ) - - so that response code is matched using constant - http.Join( - ... - ƒ.Status.OK, - ) -*/ +// StatusCode is a warpper type over http.StatusCode +// +// http.Join( +// ... +// ƒ.Code(http.StatusOK), +// ) +// +// so that response code is matched using constant +// +// http.Join( +// ... +// ƒ.Status.OK, +// ) type StatusCode int // Status is collection of constants for HTTP Status Code checks +// +// ƒ.Status.OK +// ƒ.Status.NotFound const Status = StatusCode(0) func (StatusCode) eval(code http.StatusCode, cat *http.Context) error { @@ -302,6 +306,96 @@ TODO: NetworkAuthenticationRequired */ +// helper function to match HTTP header to value +func match(ctx *http.Context, header string, value string) error { + h := ctx.Response.Header.Get(string(header)) + if h == "" { + return &gurl.NoMatch{ + Diff: fmt.Sprintf("- %s: %s", string(header), value), + Payload: nil, + } + } + + if value != "*" && !strings.HasPrefix(h, value) { + return &gurl.NoMatch{ + Diff: fmt.Sprintf("+ %s: %s\n- %s: %s", string(header), h, string(header), value), + Payload: map[string]string{string(header): h}, + } + } + + return nil +} + +// helper function to lift header value to string +func liftString(ctx *http.Context, header string, value *string) error { + val := ctx.Response.Header.Get(string(header)) + if val == "" { + return &gurl.NoMatch{ + Diff: fmt.Sprintf("- %s: *", string(header)), + Payload: nil, + } + } + + *value = val + return nil +} + +func liftInt(ctx *http.Context, header string, value *int) error { + val := ctx.Response.Header.Get(string(header)) + if val == "" { + return &gurl.NoMatch{ + Diff: fmt.Sprintf("- %s: *", string(header)), + Payload: nil, + } + } + + num, err := strconv.Atoi(val) + if err != nil { + return err + } + + *value = num + return nil +} + +func liftTime(ctx *http.Context, header string, value *time.Time) error { + val := ctx.Response.Header.Get(string(header)) + if val == "" { + return &gurl.NoMatch{ + Diff: fmt.Sprintf("- %s: *", string(header)), + Payload: nil, + } + } + + t, err := time.Parse(time.RFC1123, val) + if err != nil { + return err + } + + *value = t + return nil +} + +// Header matches or lifts header value +func Header[T http.MatchableHeaderValues](header string, value T) http.Arrow { + switch v := any(value).(type) { + case string: + return HeaderOf[string](header).Is(v) + case int: + return HeaderOf[int](header).Is(v) + case time.Time: + return HeaderOf[time.Time](header).Is(v) + case *string: + return HeaderOf[string](header).To(v) + case *int: + return HeaderOf[int](header).To(v) + case *time.Time: + return HeaderOf[time.Time](header).To(v) + default: + panic("invalid type") + } +} + // Header matches presence of header in the response or match its entire content. // The execution fails with BadMatchHead if the matched value do not meet expectations. // @@ -310,129 +404,209 @@ TODO: // ƒ.ContentType.JSON, // ƒ.ContentEncoding.Is(...), // ) -type Header string +type HeaderOf[T http.ReadableHeaderValues] string -// List of supported HTTP header constants -// https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Response_fields -const ( - CacheControl = Header("Cache-Control") - Connection = Header("Connection") - ContentEncoding = Header("Content-Encoding") - ContentLanguage = Header("Content-Language") - ContentLength = Header("Content-Length") - ContentType = Content("Content-Type") - Date = Header("Date") - ETag = Header("ETag") - Expires = Header("Expires") - LastModified = Header("Last-Modified") - Link = Header("Link") - Location = Header("Location") - Server = Header("Server") - SetCookie = Header("Set-Cookie") - TransferEncoding = Header("Transfer-Encoding") -) +// Matches header to any value +func (h HeaderOf[T]) Any(ctx *http.Context) error { + return match(ctx, string(h), "*") +} -// Is matches value of HTTP header, Use wildcard string ("*") to match any header value -func (header Header) Is(value string) http.Arrow { - return func(cat *http.Context) error { - return header.Match(cat, value) +// Matches value of HTTP header +func (h HeaderOf[T]) Is(value T) http.Arrow { + switch v := any(value).(type) { + case string: + return func(ctx *http.Context) error { + return match(ctx, string(h), v) + } + case int: + return func(ctx *http.Context) error { + return match(ctx, string(h), strconv.Itoa(v)) + } + case time.Time: + return func(ctx *http.Context) error { + return match(ctx, string(h), v.UTC().Format(time.RFC1123)) + } + default: + panic("invalid type") } } -// To matches a header value to closed variable of string type. -func (header Header) To(value *string) http.Arrow { - return func(cat *http.Context) error { - val := cat.Response.Header.Get(string(header)) - if val == "" { - return &gurl.NoMatch{ - Diff: fmt.Sprintf("- %s: *", string(header)), - Payload: nil, - } +// Lifts value of HTTP header +func (h HeaderOf[T]) To(value *T) http.Arrow { + switch v := any(value).(type) { + case *string: + return func(ctx *http.Context) error { + return liftString(ctx, string(h), v) } - - *value = val - return nil + case *int: + return func(ctx *http.Context) error { + return liftInt(ctx, string(h), v) + } + case *time.Time: + return func(ctx *http.Context) error { + return liftTime(ctx, string(h), v) + } + default: + panic("invalid type") } } -// Match is combinator to check HTTP header value -func (header Header) Match(cat *http.Context, value string) error { - h := cat.Response.Header.Get(string(header)) - if h == "" { - return &gurl.NoMatch{ - Diff: fmt.Sprintf("- %s: %s", string(header), value), - Payload: nil, - } +// Type of HTTP Header, Content-Type enumeration +// +// const ContentType = HeaderEnumContent("Content-Type") +// ƒ.ContentType.JSON +type HeaderEnumContent string + +// Matches header to any value +func (h HeaderEnumContent) Any(ctx *http.Context) error { + return match(ctx, string(h), "*") +} + +// Matches value of HTTP header +func (h HeaderEnumContent) Is(value string) http.Arrow { + return func(ctx *http.Context) error { + return match(ctx, string(h), value) } +} - if value != "*" && !strings.HasPrefix(h, value) { - return &gurl.NoMatch{ - Diff: fmt.Sprintf("+ %s: %s\n- %s: %s", string(header), h, string(header), value), - Payload: map[string]string{string(header): h}, - } +// Matches value of HTTP header +func (h HeaderEnumContent) To(value *string) http.Arrow { + return func(ctx *http.Context) error { + return liftString(ctx, string(h), value) } +} - return nil +// ApplicationJSON defines header `???: application/json` +func (h HeaderEnumContent) ApplicationJSON(ctx *http.Context) error { + return match(ctx, string(h), "application/json") } -// Any matches a header value, syntax sugar of Header(...).Is("*") -func (header Header) Any(cat *http.Context) error { - return header.Match(cat, "*") +// JSON defines header `???: application/json` +func (h HeaderEnumContent) JSON(ctx *http.Context) error { + return match(ctx, string(h), "application/json") } -// Content defines headers for content negotiation -type Content Header +// Form defined Header `???: application/x-www-form-urlencoded` +func (h HeaderEnumContent) Form(ctx *http.Context) error { + return match(ctx, string(h), "application/x-www-form-urlencoded") +} -// ApplicationJSON matches header `???: application/json` -func (h Content) ApplicationJSON(cat *http.Context) error { - return Header(h).Match(cat, "application/json") +// TextPlain defined Header `???: text/plain` +func (h HeaderEnumContent) TextPlain(ctx *http.Context) error { + return match(ctx, string(h), "text/plain") } -// JSON matches header `???: application/json` -func (h Content) JSON(cat *http.Context) error { - return Header(h).Match(cat, "application/json") +// Text defined Header `???: text/plain` +func (h HeaderEnumContent) Text(ctx *http.Context) error { + return match(ctx, string(h), "text/plain") } -// Form matches Header `???: application/x-www-form-urlencoded` -func (h Content) Form(cat *http.Context) error { - return Header(h).Match(cat, "application/x-www-form-urlencoded") +// TextHTML defined Header `???: text/html` +func (h HeaderEnumContent) TextHTML(ctx *http.Context) error { + return match(ctx, string(h), "text/html") } -// TextPlain matches Header `???: text/plain` -func (h Content) TextPlain(cat *http.Context) error { - return Header(h).Match(cat, "text/plain") +// HTML defined Header `???: text/html` +func (h HeaderEnumContent) HTML(ctx *http.Context) error { + return match(ctx, string(h), "text/html") } -// Text matches Header `???: text/plain` -func (h Content) Text(cat *http.Context) error { - return Header(h).Match(cat, "text/plain") +// Type of HTTP Header, Connection enumeration +// +// const Connection = HeaderEnumConnection("Connection") +// ƒ.Connection.KeepAlive +type HeaderEnumConnection string + +// Matches header to any value +func (h HeaderEnumConnection) Any(ctx *http.Context) error { + return match(ctx, string(h), "*") } -// TextHTML matches Header `???: text/html` -func (h Content) TextHTML(cat *http.Context) error { - return Header(h).Match(cat, "text/html") +// Matches value of HTTP header +func (h HeaderEnumConnection) Is(value string) http.Arrow { + return func(ctx *http.Context) error { + return match(ctx, string(h), value) + } } -// HTML matches Header `???: text/html` -func (h Content) HTML(cat *http.Context) error { - return Header(h).Match(cat, "text/html") +// Matches value of HTTP header +func (h HeaderEnumConnection) To(value *string) http.Arrow { + return func(ctx *http.Context) error { + return liftString(ctx, string(h), value) + } } -// Any matches a header value `???: *` -func (h Content) Any(cat *http.Context) error { - return Header(h).Match(cat, "*") +// KeepAlive defines header `???: keep-alive` +func (h HeaderEnumConnection) KeepAlive(ctx *http.Context) error { + return match(ctx, string(h), "keep-alive") } -// Is matches value of HTTP header, Use wildcard string ("*") to match any header value -func (h Content) Is(value string) http.Arrow { - return Header(h).Is(value) +// Close defines header `???: close` +func (h HeaderEnumConnection) Close(ctx *http.Context) error { + return match(ctx, string(h), "close") } -// String matches a header value to closed variable of string type. -func (h Content) To(value *string) http.Arrow { - return Header(h).To(value) +// Type of HTTP Header, Transfer-Encoding enumeration +// +// const TransferEncoding = HeaderEnumTransferEncoding("Transfer-Encoding") +// ƒ.TransferEncoding.Chunked +type HeaderEnumTransferEncoding string + +// Matches header to any value +func (h HeaderEnumTransferEncoding) Any(ctx *http.Context) error { + return match(ctx, string(h), "*") } +// Matches value of HTTP header +func (h HeaderEnumTransferEncoding) Is(value string) http.Arrow { + return func(ctx *http.Context) error { + return match(ctx, string(h), value) + } +} + +// Matches value of HTTP header +func (h HeaderEnumTransferEncoding) To(value *string) http.Arrow { + return func(ctx *http.Context) error { + return liftString(ctx, string(h), value) + } +} + +// Chunked defines header `Transfer-Encoding: chunked` +func (h HeaderEnumTransferEncoding) Chunked(ctx *http.Context) error { + return match(ctx, string(h), "chunked") +} + +// Identity defines header `Transfer-Encoding: identity` +func (h HeaderEnumTransferEncoding) Identity(ctx *http.Context) error { + return match(ctx, string(h), "identity") +} + +// List of supported HTTP header constants +// https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Response_fields +const ( + Age = HeaderOf[int]("Age") + CacheControl = HeaderOf[string]("Cache-Control") + Connection = HeaderEnumConnection("Connection") + ContentEncoding = HeaderOf[string]("Content-Encoding") + ContentLanguage = HeaderOf[string]("Content-Language") + ContentLength = HeaderOf[int]("Content-Length") + ContentLocation = HeaderOf[string]("Content-Location") + ContentMD5 = HeaderOf[string]("Content-MD5") + ContentRange = HeaderOf[string]("Content-Range") + ContentType = HeaderEnumContent("Content-Type") + Date = HeaderOf[time.Time]("Date") + ETag = HeaderOf[string]("ETag") + Expires = HeaderOf[time.Time]("Expires") + LastModified = HeaderOf[time.Time]("Last-Modified") + Link = HeaderOf[string]("Link") + Location = HeaderOf[string]("Location") + RetryAfter = HeaderOf[time.Time]("Retry-After") + Server = HeaderOf[string]("Server") + SetCookie = HeaderOf[string]("Set-Cookie") + TransferEncoding = HeaderEnumTransferEncoding("Transfer-Encoding") + Via = HeaderOf[string]("Via") +) + // Recv applies auto decoders for response and returns either binary or // native Go data structure. The Content-Type header give a hint to decoder. // Supply the pointer to data target data structure. diff --git a/http/recv/arrows_test.go b/http/recv/arrows_test.go index 20f0f53..9d7ce3b 100644 --- a/http/recv/arrows_test.go +++ b/http/recv/arrows_test.go @@ -15,43 +15,48 @@ import ( "strconv" "strings" "testing" + "time" - µ "github.com/fogfish/gurl/http" - ƒ "github.com/fogfish/gurl/http/recv" - ø "github.com/fogfish/gurl/http/send" - "github.com/fogfish/it" + µ "github.com/fogfish/gurl/v2/http" + ƒ "github.com/fogfish/gurl/v2/http/recv" + ø "github.com/fogfish/gurl/v2/http/send" + "github.com/fogfish/it/v2" ) func TestCodeOk(t *testing.T) { ts := mock() defer ts.Close() - req := µ.Join( - ø.GET.URL(ts.URL+"/json"), + req := µ.GET( + ø.URI("%s/json", ø.Authority(ts.URL)), ø.Accept.JSON, ƒ.Code(µ.StatusOK), ) cat := µ.New() err := cat.IO(context.Background(), req) - it.Ok(t). - If(err).Should().Equal(nil) + it.Then(t).Should( + it.Nil(err), + ) } func TestCodeNoMatch(t *testing.T) { ts := mock() defer ts.Close() - req := µ.Join( - ø.GET.URL(ts.URL+"/other"), + req := µ.GET( + ø.URI("%s/other", ø.Authority(ts.URL)), ø.Accept.JSON, ƒ.Status.OK, ) cat := µ.New() - err := cat.IO(context.Background(), req) + var err interface{ StatusCode() int } + f := func() error { return cat.IO(context.Background(), req) } - it.Ok(t). - If(err).Should().Be().Like(µ.StatusBadRequest) + it.Then(t).Should( + it.Fail(f).With(&err), + it.Equal(err.(µ.StatusCode).StatusCode(), µ.StatusBadRequest.StatusCode()), + ) } func TestStatusCodes(t *testing.T) { @@ -98,15 +103,16 @@ func TestStatusCodes(t *testing.T) { µ.StatusGatewayTimeout: ƒ.Status.GatewayTimeout, µ.StatusHTTPVersionNotSupported: ƒ.Status.HTTPVersionNotSupported, } { - req := µ.Join( - ø.GET.URL("%s/code/%s", ø.Authority(ts.URL), code.Value()), + req := µ.GET( + ø.URI("%s/code/%d", ø.Authority(ts.URL), code.StatusCode()), check, ) cat := µ.New() err := cat.IO(context.Background(), req) - it.Ok(t). - If(err).Should().Equal(nil) + it.Then(t).Should( + it.Nil(err), + ) } } @@ -114,88 +120,116 @@ func TestHeaderOk(t *testing.T) { ts := mock() defer ts.Close() - req := µ.Join( - ø.GET.URL(ts.URL+"/json"), + req := µ.GET( + ø.URI("%s/json", ø.Authority(ts.URL)), ø.Accept.JSON, ƒ.Status.OK, ƒ.ContentType.JSON, + ƒ.Header("Date", time.Date(2023, 02, 01, 10, 20, 30, 0, time.UTC)), + ƒ.Header("X-Value", 1024), ) cat := µ.New() err := cat.IO(context.Background(), req) - it.Ok(t). - If(err).Should().Equal(nil) + it.Then(t).Should( + it.Nil(err), + ) } func TestHeaderAny(t *testing.T) { ts := mock() defer ts.Close() - req := µ.Join( - ø.GET.URL(ts.URL+"/json"), + req := µ.GET( + ø.URI("%s/json", ø.Authority(ts.URL)), ø.Accept.JSON, ƒ.Status.OK, ƒ.ContentType.Is("*"), - ƒ.Header("Content-Type").Any, + ƒ.ContentType.Any, + ƒ.Header("Content-Type", "*"), + ƒ.HeaderOf[string]("Content-Type").Any, ) cat := µ.New() err := cat.IO(context.Background(), req) - it.Ok(t). - If(err).Should().Equal(nil) + it.Then(t).Should( + it.Nil(err), + ) } func TestHeaderVal(t *testing.T) { ts := mock() defer ts.Close() - var content string - req := µ.Join( - ø.GET.URL(ts.URL+"/json"), + var ( + date time.Time + content string + value int + ) + req := µ.GET( + ø.URI("%s/json", ø.Authority(ts.URL)), ø.Accept.JSON, ƒ.Status.OK, ƒ.ContentType.To(&content), + ƒ.Header("Date", &date), + ƒ.Header("X-Value", &value), ) cat := µ.New() err := cat.IO(context.Background(), req) - it.Ok(t). - If(err).Should().Equal(nil). - If(content).Should().Equal("application/json") + it.Then(t).Should( + it.Nil(err), + it.Equal(content, "application/json"), + it.Equal(date.Format(time.RFC1123), "Wed, 01 Feb 2023 10:20:30 UTC"), + it.Equal(value, 1024), + ) } func TestHeaderMismatch(t *testing.T) { ts := mock() defer ts.Close() - req := µ.Join( - ø.GET.URL(ts.URL+"/json"), - ø.Accept.JSON, - ƒ.Status.OK, - ƒ.ContentType.Is("foo/bar"), + var ( + date time.Time + value int ) - cat := µ.New() - err := cat.IO(context.Background(), req) - it.Ok(t). - If(err).ShouldNot().Equal(nil) + for _, header := range []µ.Arrow{ + ƒ.ContentType.Is("foo/bar"), + ƒ.Header("X-FOO", &value), + ƒ.Header("X-FOO", &date), + } { + req := µ.GET( + ø.URI("%s/json", ø.Authority(ts.URL)), + ø.Accept.JSON, + ƒ.Status.OK, + header, + ) + cat := µ.New() + err := cat.IO(context.Background(), req) + + it.Then(t).ShouldNot( + it.Nil(err), + ) + } } func TestHeaderUndefinedWithLit(t *testing.T) { ts := mock() defer ts.Close() - req := µ.Join( - ø.GET.URL(ts.URL+"/json"), + req := µ.GET( + ø.URI("%s/json", ø.Authority(ts.URL)), ø.Accept.JSON, ƒ.Status.OK, - ƒ.Header("x-content-type").Is("foo/bar"), + ƒ.Header("x-content-type", "foo/bar"), ) cat := µ.New() err := cat.IO(context.Background(), req) - it.Ok(t). - If(err).ShouldNot().Equal(nil) + it.Then(t).ShouldNot( + it.Nil(err), + ) } func TestHeaderUndefinedWithVal(t *testing.T) { @@ -203,17 +237,18 @@ func TestHeaderUndefinedWithVal(t *testing.T) { defer ts.Close() var val string - req := µ.Join( - ø.GET.URL(ts.URL+"/json"), + req := µ.GET( + ø.URI("%s/json", ø.Authority(ts.URL)), ø.Accept.JSON, ƒ.Status.OK, - ƒ.Header("x-content-type").To(&val), + ƒ.Header("x-content-type", &val), ) cat := µ.New() err := cat.IO(context.Background(), req) - it.Ok(t). - If(err).ShouldNot().Equal(nil) + it.Then(t).ShouldNot( + it.Nil(err), + ) } func TestRecvJSON(t *testing.T) { @@ -225,18 +260,20 @@ func TestRecvJSON(t *testing.T) { defer ts.Close() var site Site - req := µ.Join( - ø.GET.URL(ts.URL+"/json"), + req := µ.GET( + ø.URI("%s/json", ø.Authority(ts.URL)), ƒ.Status.OK, + ƒ.ContentType.ApplicationJSON, ƒ.ContentType.JSON, ƒ.Recv(&site), ) cat := µ.New() err := cat.IO(context.Background(), req) - it.Ok(t). - If(err).Should().Equal(nil). - If(site.Site).Should().Equal("example.com") + it.Then(t).Should( + it.Nil(err), + it.Equal(site.Site, "example.com"), + ) } func TestRecvForm(t *testing.T) { @@ -248,8 +285,8 @@ func TestRecvForm(t *testing.T) { defer ts.Close() var site Site - req := µ.Join( - ø.GET.URL(ts.URL+"/form"), + req := µ.GET( + ø.URI("%s/form", ø.Authority(ts.URL)), ƒ.Status.OK, ƒ.ContentType.Form, ƒ.Recv(&site), @@ -257,9 +294,10 @@ func TestRecvForm(t *testing.T) { cat := µ.New() err := cat.IO(context.Background(), req) - it.Ok(t). - If(err).Should().Equal(nil). - If(site.Site).Should().Equal("example.com") + it.Then(t).Should( + it.Nil(err), + it.Equal(site.Site, "example.com"), + ) } func TestRecvBytes(t *testing.T) { @@ -267,13 +305,15 @@ func TestRecvBytes(t *testing.T) { defer ts.Close() for path, content := range map[string]µ.Arrow{ - "/text": ƒ.ContentType.Text, - "/html": ƒ.ContentType.HTML, + "/text": ƒ.ContentType.Text, + "/text/1": ƒ.ContentType.TextPlain, + "/html": ƒ.ContentType.HTML, + "/html/2": ƒ.ContentType.TextHTML, } { var data []byte - req := µ.Join( - ø.GET.URL(ts.URL+path), + req := µ.GET( + ø.URI(ts.URL+path), ƒ.Status.OK, content, ƒ.Bytes(&data), @@ -281,9 +321,10 @@ func TestRecvBytes(t *testing.T) { cat := µ.New() err := cat.IO(context.Background(), req) - it.Ok(t). - If(err).Should().Equal(nil). - If(string(data)).Should().Equal("site=example.com") + it.Then(t).Should( + it.Nil(err), + it.Equal(string(data), "site=example.com"), + ) } } @@ -291,16 +332,18 @@ func mock() *httptest.Server { return httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { - case r.URL.Path == "/json": + case strings.HasPrefix(r.URL.Path, "/json"): w.Header().Add("Content-Type", "application/json") + w.Header().Add("Date", "Wed, 01 Feb 2023 10:20:30 UTC") + w.Header().Add("X-Value", "1024") w.Write([]byte(`{"site": "example.com"}`)) - case r.URL.Path == "/form": + case strings.HasPrefix(r.URL.Path, "/form"): w.Header().Add("Content-Type", "application/x-www-form-urlencoded") w.Write([]byte("site=example.com")) - case r.URL.Path == "/text": + case strings.HasPrefix(r.URL.Path, "/text"): w.Header().Add("Content-Type", "text/plain") w.Write([]byte("site=example.com")) - case r.URL.Path == "/html": + case strings.HasPrefix(r.URL.Path, "/html"): w.Header().Add("Content-Type", "text/html") w.Write([]byte("site=example.com")) case r.URL.Path == "/code/301": diff --git a/http/send/arrows.go b/http/send/arrows.go index 9374c9d..606e300 100644 --- a/http/send/arrows.go +++ b/http/send/arrows.go @@ -1,5 +1,5 @@ // -// Copyright (C) 2019 Dmitry Kolesnikov +// Copyright (C) 2019 - 2023 Dmitry Kolesnikov // // This file may be modified and distributed under the terms // of the MIT license. See the LICENSE file for details. @@ -16,88 +16,74 @@ import ( "io" "net/url" "reflect" + "strconv" "strings" + "time" - "github.com/fogfish/gurl" - "github.com/fogfish/gurl/http" + "github.com/fogfish/gurl/v2" + "github.com/fogfish/gurl/v2/http" ) -// Method is base type for HTTP methods -type Method string - -// List of supported built-in method constants -const ( - GET = Method("GET") - POST = Method("POST") - PUT = Method("PUT") - DELETE = Method("DELETE") - PATCH = Method("PATCH") -) +// Method defines HTTP Method/Verb to the request +func Method(verb string) http.Arrow { + return func(ctx *http.Context) error { + ctx.Method = verb + return nil + } +} // Authority is part of URL, use the type to prevent escaping type Authority string -// Segment is part of URL, use the type to prevent path escaping -type Segment string +// Path is part of URL, use the type to prevent path escaping +type Path string -// URL defines a mandatory parameters to the request such as -// HTTP method and destination URI -func (method Method) URI(addr string) http.Arrow { - return func(cat *http.Context) error { - switch { - case strings.HasPrefix(addr, "http"): - req, err := http.NewRequest(string(method), addr) - if err != nil { - return err - } - - cat.Request = req - default: - return &gurl.NotSupported{URL: addr} +// URI defines destination URI +// use Params arrow if you need to supply URL query params. +func URI(url string, args ...any) http.Arrow { + return func(ctx *http.Context) error { + if len(args) != 0 { + url = mkURI(url, args) } - return nil - } -} - -// URL defines a mandatory parameters to the request such as -// HTTP method and destination URL, use Params arrow if you -// need to supply URL query params. -func (method Method) URL(uri string, args ...interface{}) http.Arrow { - return func(cat *http.Context) error { - addr := mkURL(uri, args...) - - switch { - case strings.HasPrefix(addr, "http"): - req, err := http.NewRequest(string(method), addr) - if err != nil { - return err - } + if !strings.HasPrefix(url, "http") { + return &gurl.NotSupported{URL: url} + } - cat.Request = req - default: - return &gurl.NotSupported{URL: addr} + req, err := http.NewRequest(ctx.Method, url) + if err != nil { + return err } + ctx.Request = req + return nil } } -func mkURL(uri string, args ...interface{}) string { - opts := []interface{}{} +func mkURI(uri string, args []any) string { + opts := []any{} for _, x := range args { switch v := x.(type) { case *url.URL: v.Path = strings.TrimSuffix(v.Path, "/") opts = append(opts, v.String()) - case *Segment: + case *Path: opts = append(opts, *v) - case Segment: + case Path: opts = append(opts, v) case *Authority: opts = append(opts, *v) case Authority: opts = append(opts, v) + case string: + opts = append(opts, url.PathEscape(v)) + case *string: + opts = append(opts, url.PathEscape(*v)) + case int: + opts = append(opts, v) + case *int: + opts = append(opts, *v) default: opts = append(opts, url.PathEscape(urlSegment(x))) } @@ -106,7 +92,7 @@ func mkURL(uri string, args ...interface{}) string { return fmt.Sprintf(uri, opts...) } -func urlSegment(arg interface{}) string { +func urlSegment(arg any) string { val := reflect.ValueOf(arg) if val.Kind() == reflect.Ptr { @@ -116,194 +102,251 @@ func urlSegment(arg interface{}) string { return fmt.Sprintf("%v", val) } -/* -Header defines HTTP headers to the request, use combinator -to define multiple header values. - - http.Do( - ø.Header("User-Agent").Is("gurl"), - ø.Header("Content-Type").Is(content), - ) -*/ -type Header string - -/* -List of supported HTTP header constants -https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Request_fields -*/ -const ( - Accept = HeaderContent("Accept") - AcceptCharset = Header("Accept-Charset") - AcceptEncoding = Header("Accept-Encoding") - AcceptLanguage = Header("Accept-Language") - Authorization = Header("Authorization") - CacheControl = Header("Cache-Control") - Connection = HeaderConnection("Connection") - ContentEncoding = Header("Content-Encoding") - ContentLength = HeaderContentLength("Content-Length") - ContentType = HeaderContent("Content-Type") - Cookie = Header("Cookie") - Date = Header("Date") - Host = Header("Host") - IfMatch = Header("If-Match") - IfModifiedSince = Header("If-Modified-Since") - IfNoneMatch = Header("If-None-Match") - IfRange = Header("If-Range") - IfUnmodifiedSince = Header("If-Unmodified-Since") - Origin = Header("Origin") - Range = Header("Range") - TransferEncoding = HeaderTransferEncoding("Transfer-Encoding") - UserAgent = Header("User-Agent") - Upgrade = Header("Upgrade") -) - -// Is sets value of HTTP header -func (header Header) Is(value string) http.Arrow { +// Params appends query params to request URL. The arrow takes a struct and +// converts it to map[string]string. The function fails if input is not convertable +// to map of strings (e.g. contains nested struct). +func Params[T any](query T) http.Arrow { return func(cat *http.Context) error { - cat.Request.Header.Add(string(header), value) + bytes, err := json.Marshal(query) + if err != nil { + return err + } + + var req map[string]string + err = json.Unmarshal(bytes, &req) + if err != nil { + return err + } + uri := cat.Request.URL + + q := uri.Query() + for k, v := range req { + q.Add(k, v) + } + uri.RawQuery = q.Encode() + cat.Request.URL = uri + + return nil + } +} + +// Param appends query params to request URL. +func Param[T interface{ string | int }](key string, val T) http.Arrow { + return func(ctx *http.Context) error { + uri := ctx.Request.URL + q := uri.Query() + switch v := any(val).(type) { + case string: + q.Add(key, v) + case int: + q.Add(key, strconv.Itoa(v)) + } + + uri.RawQuery = q.Encode() + ctx.Request.URL = uri + return nil } } -// Content defines headers for content negotiation -type HeaderContent Header +// Header defines HTTP headers to the request +// +// ø.Header("User-Agent", "gurl"), +func Header[T http.ReadableHeaderValues](header string, value T) http.Arrow { + return HeaderOf[T](header).Set(value) +} + +// Type of HTTP Header +// +// const Host = HeaderOf[string]("Host") +// ø.Host.Set("example.com") +type HeaderOf[T http.ReadableHeaderValues] string + +// Sets value of HTTP header +func (h HeaderOf[T]) Set(value T) http.Arrow { + switch v := any(value).(type) { + case string: + return func(cat *http.Context) error { + cat.Request.Header.Add(string(h), v) + return nil + } + case int: + return func(cat *http.Context) error { + cat.Request.Header.Add(string(h), strconv.Itoa(v)) + return nil + } + case time.Time: + return func(cat *http.Context) error { + cat.Request.Header.Add(string(h), v.UTC().Format(time.RFC1123)) + return nil + } + default: + panic("invalid type") + } +} + +// Type of HTTP Header, Content-Type enumeration +// +// const ContentType = HeaderEnumContent("Content-Type") +// ø.ContentType.JSON +type HeaderEnumContent string + +// Sets value of HTTP header +func (h HeaderEnumContent) Set(value string) http.Arrow { + return func(cat *http.Context) error { + cat.Request.Header.Add(string(h), value) + return nil + } +} // ApplicationJSON defines header `???: application/json` -func (h HeaderContent) ApplicationJSON(cat *http.Context) error { +func (h HeaderEnumContent) ApplicationJSON(cat *http.Context) error { cat.Request.Header.Add(string(h), "application/json") return nil } // JSON defines header `???: application/json` -func (h HeaderContent) JSON(cat *http.Context) error { +func (h HeaderEnumContent) JSON(cat *http.Context) error { cat.Request.Header.Add(string(h), "application/json") return nil } // Form defined Header `???: application/x-www-form-urlencoded` -func (h HeaderContent) Form(cat *http.Context) error { +func (h HeaderEnumContent) Form(cat *http.Context) error { cat.Request.Header.Add(string(h), "application/x-www-form-urlencoded") return nil } // TextPlain defined Header `???: text/plain` -func (h HeaderContent) TextPlain(cat *http.Context) error { +func (h HeaderEnumContent) TextPlain(cat *http.Context) error { cat.Request.Header.Add(string(h), "text/plain") return nil } // Text defined Header `???: text/plain` -func (h HeaderContent) Text(cat *http.Context) error { +func (h HeaderEnumContent) Text(cat *http.Context) error { cat.Request.Header.Add(string(h), "text/plain") return nil } // TextHTML defined Header `???: text/html` -func (h HeaderContent) TextHTML(cat *http.Context) error { +func (h HeaderEnumContent) TextHTML(cat *http.Context) error { cat.Request.Header.Add(string(h), "text/html") return nil } // HTML defined Header `???: text/html` -func (h HeaderContent) HTML(cat *http.Context) error { +func (h HeaderEnumContent) HTML(cat *http.Context) error { cat.Request.Header.Add(string(h), "text/html") return nil } -// Is sets a literval value of HTTP header -func (h HeaderContent) Is(value string) http.Arrow { - return Header(h).Is(value) -} +// Type of HTTP Header, Connection enumeration +// +// const Connection = HeaderEnumConnection("Connection") +// ø.Connection.KeepAlive +type HeaderEnumConnection string -// Lifecycle defines headers for connection management -type HeaderConnection Header +// Sets value of HTTP header +func (h HeaderEnumConnection) Set(value string) http.Arrow { + return func(cat *http.Context) error { + cat.Request.Header.Add(string(h), value) + return nil + } +} // KeepAlive defines header `???: keep-alive` -func (h HeaderConnection) KeepAlive(cat *http.Context) error { +func (h HeaderEnumConnection) KeepAlive(cat *http.Context) error { cat.Request.Header.Add(string(h), "keep-alive") cat.Request.Close = false return nil } // Close defines header `???: close` -func (h HeaderConnection) Close(cat *http.Context) error { +func (h HeaderEnumConnection) Close(cat *http.Context) error { cat.Request.Header.Add(string(h), "close") cat.Request.Close = true return nil } -// Header TransferEncoding -type HeaderTransferEncoding Header +// Type of HTTP Header, Transfer-Encoding enumeration +// +// const TransferEncoding = HeaderEnumTransferEncoding("Transfer-Encoding") +// ø.TransferEncoding.Chunked +type HeaderEnumTransferEncoding string + +// Sets value of HTTP header +func (h HeaderEnumTransferEncoding) Set(value string) http.Arrow { + return func(cat *http.Context) error { + cat.Request.TransferEncoding = strings.Split(value, ",") + return nil + } +} // Chunked defines header `Transfer-Encoding: chunked` -func (h HeaderTransferEncoding) Chunked(cat *http.Context) error { +func (h HeaderEnumTransferEncoding) Chunked(cat *http.Context) error { cat.Request.TransferEncoding = []string{"chunked"} return nil } // Identity defines header `Transfer-Encoding: identity` -func (h HeaderTransferEncoding) Identity(cat *http.Context) error { +func (h HeaderEnumTransferEncoding) Identity(cat *http.Context) error { cat.Request.TransferEncoding = []string{"identity"} return nil } -// Is sets a literval value of HTTP header -func (h HeaderTransferEncoding) Is(value string) http.Arrow { - return func(cat *http.Context) error { - cat.Request.TransferEncoding = strings.Split(value, ",") - return nil - } -} - // Header Content-Length -type HeaderContentLength Header +// +// const ContentLength = HeaderEnumContentLength("Content-Length") +// ø.ContentLength.Set(1024) +type HeaderEnumContentLength string -// Is sets a literval value of HTTP header -func (h HeaderContentLength) Is(value int64) http.Arrow { +// Is sets a literal value of HTTP header +func (h HeaderEnumContentLength) Set(value int64) http.Arrow { return func(cat *http.Context) error { cat.Request.ContentLength = value return nil } } -// Params appends query params to request URL. The arrow takes a struct and -// converts it to map[string]string. The function fails if input is not convertable -// to map of strings (e.g. nested struct). -func Params[T any](query T) http.Arrow { - return func(cat *http.Context) error { - bytes, err := json.Marshal(query) - if err != nil { - return err - } - - var req map[string]string - err = json.Unmarshal(bytes, &req) - if err != nil { - return err - } - uri := cat.Request.URL - - q := uri.Query() - for k, v := range req { - q.Add(k, v) - } - uri.RawQuery = q.Encode() - cat.Request.URL = uri - - return nil - } -} - -/* -Send payload to destination URL. You can also use native Go data types -(e.g. maps, struct, etc) as egress payload. The library implicitly encodes -input structures to binary using Content-Type as a hint. The function fails -if content type is not supported by the library. +// List of supported HTTP header constants +// https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Request_fields +const ( + Accept = HeaderEnumContent("Accept") + AcceptCharset = HeaderOf[string]("Accept-Charset") + AcceptEncoding = HeaderOf[string]("Accept-Encoding") + AcceptLanguage = HeaderOf[string]("Accept-Language") + Authorization = HeaderOf[string]("Authorization") + CacheControl = HeaderOf[string]("Cache-Control") + Connection = HeaderEnumConnection("Connection") + ContentEncoding = HeaderOf[string]("Content-Encoding") + ContentLength = HeaderEnumContentLength("Content-Length") + ContentType = HeaderEnumContent("Content-Type") + Cookie = HeaderOf[string]("Cookie") + Date = HeaderOf[time.Time]("Date") + From = HeaderOf[string]("From") + Host = HeaderOf[string]("Host") + IfMatch = HeaderOf[string]("If-Match") + IfModifiedSince = HeaderOf[time.Time]("If-Modified-Since") + IfNoneMatch = HeaderOf[string]("If-None-Match") + IfRange = HeaderOf[string]("If-Range") + IfUnmodifiedSince = HeaderOf[time.Time]("If-Unmodified-Since") + Origin = HeaderOf[string]("Origin") + Range = HeaderOf[string]("Range") + Referer = HeaderOf[string]("Referer") + TransferEncoding = HeaderEnumTransferEncoding("Transfer-Encoding") + UserAgent = HeaderOf[string]("User-Agent") + Upgrade = HeaderOf[string]("Upgrade") +) -The function accept a "classical" data container such as string, []bytes or -io.Reader interfaces. -*/ -func Send(data interface{}) http.Arrow { +// Send payload to destination URL. You can also use native Go data types +// (e.g. maps, struct, etc) as egress payload. The library implicitly encodes +// input structures to binary using Content-Type as a hint. The function fails +// if content type is not supported by the library. +// +// The function accept a "classical" data container such as string, []bytes or +// io.Reader interfaces. +func Send(data any) http.Arrow { return func(cat *http.Context) error { chunked := cat.Request.Header.Get(string(TransferEncoding)) == "chunked" content := cat.Request.Header.Get(string(ContentType)) diff --git a/http/send/arrows_test.go b/http/send/arrows_test.go index fe08cac..d6ad952 100644 --- a/http/send/arrows_test.go +++ b/http/send/arrows_test.go @@ -15,115 +15,151 @@ import ( "net/url" "strings" "testing" + "time" - "github.com/fogfish/gurl/http" - ø "github.com/fogfish/gurl/http/send" - "github.com/fogfish/it" + "github.com/fogfish/gurl/v2/http" + ø "github.com/fogfish/gurl/v2/http/send" + "github.com/fogfish/it/v2" ) -func TestSchemaHTTP(t *testing.T) { - req := ø.GET.URL("http://example.com") - cat := http.New().WithContext(context.TODO()) +func TestSchema(t *testing.T) { + cat := http.New() - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil) -} + t.Run("HTTP", func(t *testing.T) { + err := cat.IO(context.Background(), + http.GET(ø.URI("http://example.com")), + ) + it.Then(t).Should(it.Nil(err)) + }) -func TestSchemaHTTPS(t *testing.T) { - req := ø.GET.URL("https://example.com") - cat := http.New().WithContext(context.TODO()) + t.Run("HTTPS", func(t *testing.T) { + err := cat.IO(context.Background(), + http.GET(ø.URI("https://example.com")), + ) + it.Then(t).Should(it.Nil(err)) + }) - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil) + t.Run("Unsupported", func(t *testing.T) { + err := cat.IO(context.Background(), + http.GET(ø.URI("other://example.com")), + ) + it.Then(t).ShouldNot(it.Nil(err)) + }) } -func TestSchemaUnsupported(t *testing.T) { - req := ø.GET.URL("other://example.com") - cat := http.New().WithContext(context.TODO()) - - it.Ok(t). - If(cat.IO(req)).ShouldNot().Equal(nil) +func TestMethod(t *testing.T) { + cat := http.New() + + for expect, method := range map[string]func(arrows ...http.Arrow) http.Arrow{ + "GET": http.GET, + "HEAD": http.HEAD, + "POST": http.POST, + "PUT": http.PUT, + "DELETE": http.DELETE, + "PATCH": http.PATCH, + "OPTIONS": func(arrows ...http.Arrow) http.Arrow { return http.Join(ø.Method("OPTIONS"), http.Join(arrows...)) }, + } { + cat := cat.WithContext(context.Background()) + err := cat.IO(method(ø.URI("https://example.com"))) + it.Then(t).Should( + it.Nil(err), + it.Equal(cat.Method, expect), + it.Equal(cat.Request.Method, expect), + ) + } } func TestURI(t *testing.T) { - req := ø.GET.URI("https://example.com/a/1") - cat := http.New().WithContext(context.TODO()) - - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil). - If(cat.Request.URL.String()).Should().Equal("https://example.com/a/1") -} - -func TestURL(t *testing.T) { - req := ø.GET.URL("https://example.com/%s/%v", "a", 1) - cat := http.New().WithContext(context.TODO()) + cat := http.New() - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil). - If(cat.Request.URL.String()).Should().Equal("https://example.com/a/1") -} - -func TestURLByRef(t *testing.T) { - a := "a" - b := 1 - req := ø.GET.URL("https://example.com/%s/%v", &a, &b) - cat := http.New().WithContext(context.TODO()) - - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil). - If(cat.Request.URL.String()).Should().Equal("https://example.com/a/1") -} - -func TestURLEscape(t *testing.T) { - a := "a b" - b := 1 - req := ø.GET.URL("https://example.com/%s/%v", &a, &b) - cat := http.New().WithContext(context.TODO()) - - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil). - If(cat.Request.URL.String()).Should().Equal("https://example.com/a%20b/1") -} + t.Run("Literal", func(t *testing.T) { + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET(ø.URI("https://example.com/a/1")), + ) + it.Then(t).Should( + it.Nil(err), + it.Equal(cat.Request.URL.String(), "https://example.com/a/1"), + ) + }) -func TestURLEscapeSkip(t *testing.T) { - a := "a/b" - req := ø.GET.URL("https://example.com/%s/%s", (*ø.Segment)(&a), ø.Segment(a)) - cat := http.New().WithContext(context.TODO()) + t.Run("Format", func(t *testing.T) { + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET(ø.URI("https://example.com/%s/%d", "a", 1)), + ) + it.Then(t).Should( + it.Nil(err), + it.Equal(cat.Request.URL.String(), "https://example.com/a/1"), + ) + }) - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil). - If(cat.Request.URL.String()).Should().Equal("https://example.com/a/b/a/b") -} + t.Run("FormatByRef", func(t *testing.T) { + a, b := "a", 1 + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET(ø.URI("https://example.com/%s/%d", &a, &b)), + ) + it.Then(t).Should( + it.Nil(err), + it.Equal(cat.Request.URL.String(), "https://example.com/a/1"), + ) + }) -func TestURLEscapeAuthority(t *testing.T) { - a := "a.b" - req := ø.GET.URL("https://%s.%s", ø.Authority(a), (*ø.Authority)(&a)) - cat := http.New().WithContext(context.TODO()) + t.Run("Escape", func(t *testing.T) { + a := "a b" + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET(ø.URI("https://example.com/%s/%s", "a b", &a)), + ) + it.Then(t).Should( + it.Nil(err), + it.Equal(cat.Request.URL.String(), "https://example.com/a%20b/a%20b"), + ) + }) - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil). - If(cat.Request.URL.String()).Should().Equal("https://a.b.a.b") -} + t.Run("NoEscape", func(t *testing.T) { + a := "a/b" + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET(ø.URI("https://example.com/%s/%s", ø.Path("a/b"), (*ø.Path)(&a))), + ) + it.Then(t).Should( + it.Nil(err), + it.Equal(cat.Request.URL.String(), "https://example.com/a/b/a/b"), + ) + }) -func TestURLType(t *testing.T) { - a := "a b" - b := 1 - p, _ := url.Parse("https://example.com") - req := ø.GET.URL("%s/%s/%v", p, &a, &b) - cat := http.New().WithContext(context.TODO()) + t.Run("Authority", func(t *testing.T) { + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET(ø.URI("https://%s/%s", ø.Authority("example.com"), ø.Path("a/b"))), + ) + it.Then(t).Should( + it.Nil(err), + it.Equal(cat.Request.URL.String(), "https://example.com/a/b"), + ) + }) - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil). - If(cat.Request.URL.String()).Should().Equal("https://example.com/a%20b/1") + t.Run("url.URL", func(t *testing.T) { + cat := cat.WithContext(context.Background()) + u, _ := url.Parse("https://example.com/a/b") + err := cat.IO( + http.GET(ø.URI("%s/%s", u, "c")), + ) + it.Then(t).Should( + it.Nil(err), + it.Equal(cat.Request.URL.String(), "https://example.com/a/b/c"), + ) + }) } func TestHeaders(t *testing.T) { - defAccept := "text/plain" + cat := http.New() for val, arr := range map[*[]string]http.Arrow{ // - {"accept", "text/plain"}: ø.Header("Accept").Is("text/plain"), - {"accept", "text/plain"}: ø.Header("Accept").Is(defAccept), + {"accept", "text/plain"}: ø.Header("Accept", "text/plain"), {"accept", "text/plain"}: ø.Accept.Text, {"accept", "text/plain"}: ø.Accept.TextPlain, {"accept", "text/html"}: ø.Accept.HTML, @@ -131,132 +167,192 @@ func TestHeaders(t *testing.T) { {"accept", "application/json"}: ø.Accept.ApplicationJSON, {"accept", "application/json"}: ø.Accept.JSON, {"accept", "application/x-www-form-urlencoded"}: ø.Accept.Form, - - {"accept", "text/plain"}: ø.Accept.Is("text/plain"), - {"accept", "text/plain"}: ø.Accept.Is(defAccept), - // - {"connection", "keep-alive"}: ø.Connection.KeepAlive, - {"connection", "close"}: ø.Connection.Close, - // - {"authorization", "foo bar"}: ø.Authorization.Is("foo bar"), + {"accept", "text/plain"}: ø.Accept.Set("text/plain"), + {"connection", "keep-alive"}: ø.Connection.KeepAlive, + {"connection", "close"}: ø.Connection.Close, + {"connection", "close"}: ø.Connection.Set("close"), + {"authorization", "foo bar"}: ø.Authorization.Set("foo bar"), + {"x-value", "1024"}: ø.Header("x-value", 1024), + {"date", "Wed, 01 Feb 2023 10:20:30 UTC"}: ø.Date.Set(time.Date(2023, 02, 01, 10, 20, 30, 0, time.UTC)), } { - req := http.Join( - ø.GET.URL("http://example.com"), - arr, + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET( + ø.URI("http://example.com"), + arr, + ), + ) + it.Then(t).Should( + it.Nil(err), + it.Equal(cat.Request.Header.Get((*val)[0]), (*val)[1]), ) - cat := http.New().WithContext(context.TODO()) - - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil). - If(cat.Request.Header.Get((*val)[0])).Equal((*val)[1]) } } func TestHeaderContentLength(t *testing.T) { - req := http.Join( - ø.GET.URL("http://example.com"), - ø.ContentLength.Is(10), - ) cat := http.New().WithContext(context.TODO()) + err := cat.IO( + http.GET( + ø.URI("http://example.com"), + ø.ContentLength.Set(1024), + ), + ) - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil). - If(cat.Request.ContentLength).Equal(int64(10)) + it.Then(t).Should( + it.Nil(err), + it.Equal(cat.Request.ContentLength, int64(1024)), + ) } func TestHeaderTransferEncoding(t *testing.T) { + cat := http.New() + for val, arr := range map[*[]string]http.Arrow{ {"chunked"}: ø.TransferEncoding.Chunked, {"identity"}: ø.TransferEncoding.Identity, - {"gzip"}: ø.TransferEncoding.Is("gzip"), + {"gzip"}: ø.TransferEncoding.Set("gzip"), } { - req := http.Join( - ø.GET.URL("http://example.com"), - arr, + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET( + ø.URI("http://example.com"), + arr, + ), ) - cat := http.New().WithContext(context.TODO()) - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil). - If(cat.Request.TransferEncoding).Equal((*val)) + it.Then(t).Should( + it.Nil(err), + it.Seq(cat.Request.TransferEncoding).Equal(*val...), + ) } - } func TestParams(t *testing.T) { - type Site struct { - Site string `json:"site"` - Host string `json:"host,omitempty"` - } - - req := http.Join( - ø.GET.URL("https://example.com"), - ø.Params(Site{"host", "site"}), - ) - cat := http.New().WithContext(context.TODO()) + cat := http.New() - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil). - If(cat.Request.URL.String()).Should().Equal("https://example.com?host=site&site=host") -} - -func TestParamsInvalidFormat(t *testing.T) { - type Site struct { - Site string `json:"site"` - Host int `json:"host,omitempty"` - } - - req := http.Join( - ø.GET.URL("https://example.com"), - ø.Params(Site{"host", 100}), - ) - cat := http.New().WithContext(context.TODO()) - - it.Ok(t). - If(cat.IO(req)).ShouldNot().Equal(nil) -} - -func TestSendJSON(t *testing.T) { - type Site struct { - Site string `json:"site"` - Host string `json:"host,omitempty"` - } + t.Run("Struct", func(t *testing.T) { + type Site struct { + Site string `json:"site"` + Host string `json:"host,omitempty"` + } + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET( + ø.URI("https://example.com"), + ø.Params(Site{"host", "site"}), + ), + ) + it.Then(t).Should( + it.Nil(err), + it.Equal(cat.Request.URL.String(), "https://example.com?host=site&site=host"), + ) + }) - req := http.Join( - ø.GET.URL("https://example.com"), - ø.Header("Content-Type").Is("application/json"), - ø.Send(Site{"host", "site"}), - ) - cat := http.New().WithContext(context.TODO()) - err := cat.IO(req) - buf, _ := io.ReadAll(cat.Request.Body) + t.Run("StructInvalid", func(t *testing.T) { + type Host struct{ Host string } + type Site struct { + Host Host `json:"host"` + } + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET( + ø.URI("https://example.com"), + ø.Params(Site{Host{"host"}}), + ), + ) + it.Then(t).ShouldNot( + it.Nil(err), + ) + }) + + t.Run("KeyVal", func(t *testing.T) { + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET( + ø.URI("https://example.com"), + ø.Param("host", "site"), + ø.Param("site", "host"), + ), + ) + it.Then(t).Should( + it.Nil(err), + it.Equal(cat.Request.URL.String(), "https://example.com?host=site&site=host"), + ) - it.Ok(t). - If(err).Should().Equal(nil). - If(string(buf)).Should().Equal("{\"site\":\"host\",\"host\":\"site\"}") + }) } -func TestSendForm(t *testing.T) { +func TestSend(t *testing.T) { type Site struct { Site string `json:"site"` Host string `json:"host,omitempty"` } - req := http.Join( - ø.GET.URL("https://example.com"), - ø.Header("Content-Type").Is("application/x-www-form-urlencoded"), - ø.Send(Site{"host", "site"}), - ) - cat := http.New().WithContext(context.TODO()) - err := cat.IO(req) - buf, _ := io.ReadAll(cat.Request.Body) + cat := http.New() - it.Ok(t). - If(err).Should().Equal(nil). - If(string(buf)).Should().Equal("host=site&site=host") + t.Run("Json", func(t *testing.T) { + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET( + ø.URI("https://example.com"), + ø.ContentType.JSON, + ø.Send(Site{"host", "site"}), + ), + ) + buf, _ := io.ReadAll(cat.Request.Body) + it.Then(t).Should( + it.Nil(err), + it.Equal(string(buf), "{\"site\":\"host\",\"host\":\"site\"}"), + ) + }) + + t.Run("Form", func(t *testing.T) { + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET( + ø.URI("https://example.com"), + ø.ContentType.Form, + ø.Send(Site{"host", "site"}), + ), + ) + buf, _ := io.ReadAll(cat.Request.Body) + it.Then(t).Should( + it.Nil(err), + it.Equal(string(buf), "host=site&site=host"), + ) + }) + + t.Run("Unknown", func(t *testing.T) { + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET( + ø.URI("https://example.com"), + ø.Send(Site{"host", "site"}), + ), + ) + it.Then(t).ShouldNot( + it.Nil(err), + ) + }) + + t.Run("NotSupported", func(t *testing.T) { + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET( + ø.URI("https://example.com"), + ø.ContentType.Set("foo/bar"), + ø.Send(Site{"host", "site"}), + ), + ) + it.Then(t).ShouldNot( + it.Nil(err), + ) + }) } func TestSendBytes(t *testing.T) { + cat := http.New() + for _, content := range []http.Arrow{ ø.ContentType.Text, ø.ContentType.HTML, @@ -269,69 +365,37 @@ func TestSendBytes(t *testing.T) { bytes.NewReader([]byte("host=site")), io.NopCloser(bytes.NewBuffer([]byte("host=site"))), } { - req := http.Join( - ø.GET.URL("https://example.com"), - content, - ø.Send(val), + cat := cat.WithContext(context.Background()) + err := cat.IO( + http.GET( + ø.URI("https://example.com"), + content, + ø.Send(val), + ), ) - cat := http.New().WithContext(context.TODO()) - err := cat.IO(req) buf, _ := io.ReadAll(cat.Request.Body) - - it.Ok(t). - If(err).Should().Equal(nil). - If(string(buf)).Should().Equal("host=site") + it.Then(t).Should( + it.Nil(err), + it.Equal(string(buf), "host=site"), + ) } } } -func TestSendUnknown(t *testing.T) { - type Site struct { - Site string `json:"site"` - Host string `json:"host,omitempty"` - } - - req := http.Join( - ø.GET.URL("https://example.com"), - ø.Send(Site{"host", "site"}), - ) - cat := http.New().WithContext(context.TODO()) - - it.Ok(t). - If(cat.IO(req)).ShouldNot().Equal(nil) -} - -func TestSendNotSupported(t *testing.T) { - type Site struct { - Site string `json:"site"` - Host string `json:"host,omitempty"` - } - - req := http.Join( - ø.GET.URL("https://example.com"), - ø.ContentType.Is("foo/bar"), - ø.Send(Site{"host", "site"}), - ) - cat := http.New().WithContext(context.TODO()) - - it.Ok(t). - If(cat.IO(req)).ShouldNot().Equal(nil) -} - -func TestAliasesURL(t *testing.T) { - for mthd, f := range map[string]func(string, ...interface{}) http.Arrow{ - "GET": ø.GET.URL, - "PUT": ø.PUT.URL, - "POST": ø.POST.URL, - "DELETE": ø.DELETE.URL, - "PATCH": ø.PATCH.URL, - } { - req := f("https://example.com/%s/%v", "a", 1) - cat := http.New().WithContext(context.TODO()) - - it.Ok(t). - If(cat.IO(req)).Should().Equal(nil). - If(cat.Request.URL.String()).Should().Equal("https://example.com/a/1"). - If(cat.Request.Method).Should().Equal(mthd) - } -} +// func TestAliasesURL(t *testing.T) { +// for mthd, f := range map[string]func(string, ...interface{}) http.Arrow{ +// "GET": ø.GET.URL, +// "PUT": ø.PUT.URL, +// "POST": ø.POST.URL, +// "DELETE": ø.DELETE.URL, +// "PATCH": ø.PATCH.URL, +// } { +// req := f("https://example.com/%s/%v", "a", 1) +// cat := http.New().WithContext(context.TODO()) + +// it.Ok(t). +// If(cat.IO(req)).Should().Equal(nil). +// If(cat.Request.URL.String()).Should().Equal("https://example.com/a/1"). +// If(cat.Request.Method).Should().Equal(mthd) +// } +// } diff --git a/http/stack.go b/http/stack.go new file mode 100644 index 0000000..ac57341 --- /dev/null +++ b/http/stack.go @@ -0,0 +1,163 @@ +// +// Copyright (C) 2019 - 2023 Dmitry Kolesnikov +// +// This file may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// https://github.com/fogfish/gurl +// + +package http + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "net/http/cookiejar" + "time" + + "golang.org/x/net/publicsuffix" +) + +// +// The file implements the protocol stack, type owning HTTP client +// + +// Creates instance of HTTP Request +func NewRequest(method, url string) (*http.Request, error) { + return http.NewRequest(method, url, nil) +} + +// Stack is HTTP protocol stack +type Stack interface { + WithContext(context.Context) *Context + IO(context.Context, ...Arrow) error +} + +// Protocol is an instance of Stack +type Protocol struct { + *http.Client + LogLevel int +} + +// Allocate instance of HTTP Stack +func New(opts ...Config) Stack { + cat := &Protocol{Client: Client()} + + for _, opt := range opts { + opt(cat) + } + + return cat +} + +// WithContext create instance of I/O Context +func (cat *Protocol) WithContext(ctx context.Context) *Context { + return &Context{ + Context: ctx, + Request: nil, + Response: nil, + stack: cat, + } +} + +func (stack *Protocol) IO(ctx context.Context, arrows ...Arrow) error { + c := Context{ + Context: ctx, + Method: http.MethodGet, + Request: nil, + Response: nil, + stack: stack, + } + + for _, f := range arrows { + if err := f(&c); err != nil { + c.discardBody() + return err + } + if err := c.discardBody(); err != nil { + return err + } + } + + return nil +} + +// Config option for HTTP client +type Config func(*Protocol) + +// Creates default HTTP client +func Client() *http.Client { + return &http.Client{ + Timeout: 60 * time.Second, + Transport: &http.Transport{ + ReadBufferSize: 128 * 1024, + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + }).DialContext, + // TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 100, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// WithClient replaces default client with custom instance +func WithClient(client *http.Client) Config { + return func(cat *Protocol) { + cat.Client = client + } +} + +// LogRequest enables debug logging for requests +func LogRequest() Config { + return func(cat *Protocol) { + cat.LogLevel = 1 + } +} + +// LogResponse enables debug logging for requests +func LogResponse() Config { + return func(cat *Protocol) { + cat.LogLevel = 2 + } +} + +// LogResponse enables debug logging for requests +func LogPayload() Config { + return func(cat *Protocol) { + cat.LogLevel = 3 + } +} + +// InsecureTLS disables certificates validation +func InsecureTLS() Config { + return func(cat *Protocol) { + switch t := cat.Client.Transport.(type) { + case *http.Transport: + if t.TLSClientConfig == nil { + t.TLSClientConfig = &tls.Config{} + } + t.TLSClientConfig.InsecureSkipVerify = true + default: + panic(fmt.Errorf("unsupported transport type %T", t)) + } + } +} + +// CookieJar enables cookie handlings +func CookieJar() Config { + return func(cat *Protocol) { + jar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + if err != nil { + panic(err) + } + cat.Client.Jar = jar + } +} diff --git a/http/status.go b/http/status.go index e08e1cd..6341533 100644 --- a/http/status.go +++ b/http/status.go @@ -1,3 +1,11 @@ +// +// Copyright (C) 2019 Dmitry Kolesnikov +// +// This file may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// https://github.com/fogfish/gurl +// + package http import ( @@ -6,7 +14,6 @@ import ( ) /* - StatusCode is a base type for typesafe HTTP status codes. The library advertises a usage of "pattern-matching" on HTTP status handling, which helps developers to catch mismatch of HTTP statuses along with other side-effect failures. @@ -17,32 +24,31 @@ within IO category. Use final type instances in the error handling routines. Use type switch for error handling "branches" - switch err := cat.Fail.(type) { - case nil: - // Nothing - case StatusCode: - switch err { - case http.StatusOK: - // HTTP 200 OK - case http.StatusNotFound: - // HTTP 404 NotFound - default: - // any other HTTP errors - } - default: - // any other errors - } + switch err := cat.Fail.(type) { + case nil: + // Nothing + case StatusCode: + switch err { + case http.StatusOK: + // HTTP 200 OK + case http.StatusNotFound: + // HTTP 404 NotFound + default: + // any other HTTP errors + } + default: + // any other errors + } Conditional error handling on expected HTTP Status - if errors.Is(cat.Fail, http.StatusNotFound) { - } + if errors.Is(cat.Fail, http.StatusNotFound) { + } Conditional error handling on any HTTP Status - if _, ok := cat.Fail.(gurl.StatusCode); ok { - } - + if _, ok := cat.Fail.(gurl.StatusCode); ok { + } */ type StatusCode int @@ -50,14 +56,14 @@ type StatusCode int func NewStatusCode(code int, required ...StatusCode) StatusCode { req := 0 if len(required) > 0 { - req = required[0].Value() + req = required[0].StatusCode() } return StatusCode((req << 16) | code) } // Error makes StatusCode to be error func (e StatusCode) Error() string { - status := e.Value() + status := e.StatusCode() if req := e.Required(); req != 0 { return fmt.Sprintf("HTTP Status `%d %s`, required `%d %s`.", status, http.StatusText(status), req, http.StatusText(req)) @@ -68,13 +74,13 @@ func (e StatusCode) Error() string { // Is compares wrapped errors func (e StatusCode) Is(err error) bool { if code, ok := err.(StatusCode); ok { - return e.Value() == code.Value() + return e.StatusCode() == code.StatusCode() } return false } // Value transforms StatusCode type to integer value: StatusCode ⟼ int -func (e StatusCode) Value() int { +func (e StatusCode) StatusCode() int { return int(e) & 0xffff } @@ -83,7 +89,6 @@ func (e StatusCode) Required() int { return int(e) >> 16 } -// const ( // StatusContinue = StatusCode(http.StatusContinue) diff --git a/http/status_test.go b/http/status_test.go index 048043b..3513d55 100644 --- a/http/status_test.go +++ b/http/status_test.go @@ -14,8 +14,8 @@ import ( "net/http" "testing" - gurl "github.com/fogfish/gurl/http" - "github.com/fogfish/it" + gurl "github.com/fogfish/gurl/v2/http" + "github.com/fogfish/it/v2" ) var httpStatusCode = map[int]gurl.StatusCode{ @@ -91,26 +91,32 @@ var httpStatusCode = map[int]gurl.StatusCode{ func TestStatusCodeCodec(t *testing.T) { for code, val := range httpStatusCode { status := gurl.NewStatusCode(code, gurl.StatusOK) - it.Ok(t). - If(code).Should().Equal(val.Value()). - If(status.Value()).Should().Equal(code). - If(status.Value()).Should().Equal(val.Value()). - If(errors.Is(status, val)).Should().Equal(true). - If(fmt.Sprintf("%T", status)).Should().Equal(fmt.Sprintf("%T", val)) + it.Then(t).Should( + it.Equal(code, val.StatusCode()), + it.Equal(status.StatusCode(), code), + it.Equal(status.StatusCode(), val.StatusCode()), + it.True(errors.Is(status, val)), + it.Equal(fmt.Sprintf("%T", status), fmt.Sprintf("%T", val)), + ) } } func TestStatusCodeRequired(t *testing.T) { var code error = gurl.NewStatusCode(200, 201) - it.Ok(t). - If(code.Error()).Should().Equal("HTTP Status `200 OK`, required `201 Created`.") + it.Then(t).Should( + it.Equal(code.Error(), "HTTP Status `200 OK`, required `201 Created`."), + ) } func TestStatusCodeText(t *testing.T) { var code error = gurl.NewStatusCode(200) - it.Ok(t). - If(code.Error()).Should().Equal("HTTP 200 OK"). - If(errors.Is(code, gurl.StatusOK)).Should().Equal(true). - If(errors.Is(code, gurl.StatusCreated)).Should().Equal(false). - If(errors.Is(code, fmt.Errorf("some error"))).Should().Equal(false) + it.Then(t). + Should( + it.Equal(code.Error(), "HTTP 200 OK"), + it.True(errors.Is(code, gurl.StatusOK)), + ). + ShouldNot( + it.True(errors.Is(code, gurl.StatusCreated)), + it.True(errors.Is(code, fmt.Errorf("some error"))), + ) } diff --git a/http/types.go b/http/types.go index c23a72b..313c89a 100644 --- a/http/types.go +++ b/http/types.go @@ -9,138 +9,34 @@ package http import ( - "context" - "crypto/tls" "encoding/json" "fmt" "io" - "log" - "net" "net/http" - "net/http/cookiejar" - "net/http/httputil" "strings" "time" "github.com/ajg/form" - "github.com/fogfish/gurl" - "golang.org/x/net/publicsuffix" + "github.com/fogfish/gurl/v2" ) // Arrow is a morphism applied to HTTP protocol stack type Arrow func(*Context) error -// Stack is HTTP protocol stack -type Stack interface { - WithContext(context.Context) *Context - IO(context.Context, ...Arrow) error +type ReadableHeaderValues interface { + int | string | time.Time } -// Context defines the category of HTTP I/O -type Context struct { - *Protocol - - // Context of Request / Response - context.Context - *http.Request - *http.Response -} - -func NewRequest(method, url string) (*http.Request, error) { - return http.NewRequest(method, url, nil) +type WriteableHeaderValues interface { + *int | *string | *time.Time } -// Unsafe evaluates current context of HTTP I/O -func (ctx *Context) Unsafe() error { - eg := ctx.Request - - if ctx.Context != nil { - eg = eg.WithContext(ctx.Context) - } - - logSend(ctx.LogLevel, eg) - - in, err := ctx.Client.Do(eg) - if err != nil { - return err - } - - ctx.Response = in - - logRecv(ctx.LogLevel, in) - - return nil +type MatchableHeaderValues interface { + ReadableHeaderValues | WriteableHeaderValues } -// IO executes protocol operations -func (ctx *Context) IO(arrows ...Arrow) error { - for _, f := range arrows { - if err := f(ctx); err != nil { - return err - } - } - - if ctx.Response != nil { - // Note: due to Golang HTTP pool implementation we need to consume and - // discard body. Otherwise, HTTP connection is not returned to - // to the pool. - body := ctx.Response.Body - ctx.Response = nil - - _, err := io.Copy(io.Discard, body) - if err != nil { - return err - } - - err = body.Close() - if err != nil { - return err - } - } - - return nil -} - -/* -Protocol is an instance of Stack -*/ -type Protocol struct { - *http.Client - LogLevel int -} - -/* -New instantiates category of HTTP I/O -*/ -func New(opts ...Config) Stack { - cat := &Protocol{Client: Client()} - - for _, opt := range opts { - opt(cat) - } - - return cat -} - -// WithContext create instance of I/O Context -func (cat *Protocol) WithContext(ctx context.Context) *Context { - return &Context{ - Protocol: cat, - Context: ctx, - Request: nil, - Response: nil, - } -} - -// IO executes protocol operations -func (cat *Protocol) IO(ctx context.Context, arrows ...Arrow) error { - return cat.WithContext(ctx).IO(arrows...) -} - -/* -Join composes HTTP arrows to high-order function -(a ⟼ b, b ⟼ c, c ⟼ d) ⤇ a ⟼ d -*/ +// Join composes HTTP arrows to high-order function +// (a ⟼ b, b ⟼ c, c ⟼ d) ⤇ a ⟼ d func Join(arrows ...Arrow) Arrow { return func(cat *Context) error { for _, f := range arrows { @@ -153,89 +49,44 @@ func Join(arrows ...Arrow) Arrow { } } -// Config for HTTP client -type Config func(*Protocol) +// GET composes HTTP arrows to high-order function for HTTP GET request +// (a ⟼ b, b ⟼ c, c ⟼ d) ⤇ a ⟼ d +func GET(arrows ...Arrow) Arrow { return method(http.MethodGet, arrows) } -/* -Client Default HTTP client -*/ -func Client() *http.Client { - return &http.Client{ - Timeout: 60 * time.Second, - Transport: &http.Transport{ - ReadBufferSize: 128 * 1024, - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - }).DialContext, - // TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - MaxIdleConns: 100, - MaxIdleConnsPerHost: 100, - }, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } -} +// HEAD composes HTTP arrows to high-order function for HTTP HEAD request +// (a ⟼ b, b ⟼ c, c ⟼ d) ⤇ a ⟼ d +func HEAD(arrows ...Arrow) Arrow { return method(http.MethodHead, arrows) } -// WithClient replaces default client with custom instance -func WithClient(client *http.Client) Config { - return func(cat *Protocol) { - cat.Client = client - } -} +// POST composes HTTP arrows to high-order function for HTTP POST request +// (a ⟼ b, b ⟼ c, c ⟼ d) ⤇ a ⟼ d +func POST(arrows ...Arrow) Arrow { return method(http.MethodPost, arrows) } -// LogRequest enables debug logging for requests -func LogRequest() Config { - return func(cat *Protocol) { - cat.LogLevel = 1 - } -} +// PUT composes HTTP arrows to high-order function for HTTP PUT request +// (a ⟼ b, b ⟼ c, c ⟼ d) ⤇ a ⟼ d +func PUT(arrows ...Arrow) Arrow { return method(http.MethodPut, arrows) } -// LogResponse enables debug logging for requests -func LogResponse() Config { - return func(cat *Protocol) { - cat.LogLevel = 2 - } -} +// DELETE composes HTTP arrows to high-order function for HTTP DELETE request +// (a ⟼ b, b ⟼ c, c ⟼ d) ⤇ a ⟼ d +func DELETE(arrows ...Arrow) Arrow { return method(http.MethodDelete, arrows) } -// LogResponse enables debug logging for requests -func LogPayload() Config { - return func(cat *Protocol) { - cat.LogLevel = 3 - } -} +// PATCH composes HTTP arrows to high-order function for HTTP PATCH request +// (a ⟼ b, b ⟼ c, c ⟼ d) ⤇ a ⟼ d +func PATCH(arrows ...Arrow) Arrow { return method(http.MethodPatch, arrows) } -// InsecureTLS disables certificates validation -func InsecureTLS() Config { - return func(cat *Protocol) { - switch t := cat.Client.Transport.(type) { - case *http.Transport: - if t.TLSClientConfig == nil { - t.TLSClientConfig = &tls.Config{} +func method(verb string, arrows []Arrow) Arrow { + return func(ctx *Context) error { + ctx.Method = verb + for _, f := range arrows { + if err := f(ctx); err != nil { + return err } - t.TLSClientConfig.InsecureSkipVerify = true - default: - panic(fmt.Errorf("unsupported transport type %T", t)) } - } -} -// CookieJar enables cookie handlings -func CookieJar() Config { - return func(cat *Protocol) { - jar, err := cookiejar.New(&cookiejar.Options{ - PublicSuffixList: publicsuffix.List, - }) - if err != nil { - panic(err) - } - cat.Client.Jar = jar + return nil } } -/* -IO executes protocol operations -*/ +// Executes protocol operation func IO[T any](ctx *Context, arrows ...Arrow) (*T, error) { for _, f := range arrows { if err := f(ctx); err != nil { @@ -274,19 +125,3 @@ func decode[T any](content string, stream io.ReadCloser, data *T) error { } } } - -func logSend(level int, eg *http.Request) { - if level >= 1 { - if msg, err := httputil.DumpRequest(eg, level == 3); err == nil { - log.Printf(">>>>\n%s\n", msg) - } - } -} - -func logRecv(level int, in *http.Response) { - if level >= 2 { - if msg, err := httputil.DumpResponse(in, level == 3); err == nil { - log.Printf("<<<<\n%s\n", msg) - } - } -} diff --git a/http/types_test.go b/http/types_test.go index b41cf47..9981f24 100644 --- a/http/types_test.go +++ b/http/types_test.go @@ -15,25 +15,67 @@ import ( "testing" "time" - µ "github.com/fogfish/gurl/http" - ƒ "github.com/fogfish/gurl/http/recv" - ø "github.com/fogfish/gurl/http/send" - "github.com/fogfish/it" + µ "github.com/fogfish/gurl/v2/http" + ƒ "github.com/fogfish/gurl/v2/http/recv" + ø "github.com/fogfish/gurl/v2/http/send" + "github.com/fogfish/it/v2" ) +func TestMethod(t *testing.T) { + cat := µ.New() + + for expect, method := range map[string]func(arrows ...µ.Arrow) µ.Arrow{ + "GET": µ.GET, + "HEAD": µ.HEAD, + "POST": µ.POST, + "PUT": µ.PUT, + "DELETE": µ.DELETE, + "PATCH": µ.PATCH, + "OPTIONS": func(arrows ...µ.Arrow) µ.Arrow { return µ.Join(ø.Method("OPTIONS"), µ.Join(arrows...)) }, + } { + cat := cat.WithContext(context.Background()) + err := cat.IO(method(ø.URI("https://example.com"))) + it.Then(t).Should( + it.Nil(err), + it.Equal(cat.Method, expect), + it.Equal(cat.Request.Method, expect), + ) + } +} + +func TestStackOptions(t *testing.T) { + ts := mock() + defer ts.Close() + + req := µ.GET( + ø.URI("%s/ok", ø.Authority(ts.URL)), + ƒ.Code(µ.StatusOK), + ) + cat := µ.New( + µ.CookieJar(), + µ.InsecureTLS(), + ) + err := cat.IO(context.Background(), req) + + it.Then(t).Should( + it.Nil(err), + ) +} + func TestJoin(t *testing.T) { ts := mock() defer ts.Close() - req := µ.Join( - ø.GET.URL(ts.URL+"/ok"), + req := µ.GET( + ø.URI("%s/ok", ø.Authority(ts.URL)), ƒ.Code(µ.StatusOK), ) cat := µ.New() err := cat.IO(context.Background(), req) - it.Ok(t). - If(err).Should().Equal(nil) + it.Then(t).Should( + it.Nil(err), + ) } func TestJoinCats(t *testing.T) { @@ -41,20 +83,21 @@ func TestJoinCats(t *testing.T) { defer ts.Close() req := µ.Join( - µ.Join( - ø.GET.URL(ts.URL+"/ok"), + µ.GET( + ø.URI("%s/ok", ø.Authority(ts.URL)), ƒ.Status.OK, ), - µ.Join( - ø.GET.URL(ts.URL), + µ.GET( + ø.URI(ts.URL), ƒ.Code(µ.StatusBadRequest), ), ) cat := µ.New() err := cat.IO(context.Background(), req) - it.Ok(t). - If(err).Should().Equal(nil) + it.Then(t).Should( + it.Nil(err), + ) } func TestIOWithContext(t *testing.T) { @@ -62,15 +105,17 @@ func TestIOWithContext(t *testing.T) { defer ts.Close() ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) - req := µ.Join( - ø.GET.URL(ts.URL+"/ok"), + req := µ.GET( + ø.URI("%s/ok", ø.Authority(ts.URL)), ƒ.Status.OK, ) cat := µ.New() err := cat.IO(ctx, req) - it.Ok(t). - If(err).ShouldNot().Equal(nil) + + it.Then(t).ShouldNot( + it.Nil(err), + ) cancel() } @@ -80,8 +125,8 @@ func TestIOWithContextCancel(t *testing.T) { defer ts.Close() ctx, cancel := context.WithTimeout(context.Background(), 1000*time.Nanosecond) - req := µ.Join( - ø.GET.URL(ts.URL+"/ok"), + req := µ.GET( + ø.URI("%s/ok", ø.Authority(ts.URL)), ƒ.Status.OK, ) @@ -89,14 +134,59 @@ func TestIOWithContextCancel(t *testing.T) { cancel() err := cat.IO(ctx, req) - it.Ok(t). - If(err).ShouldNot().Equal(nil) + it.Then(t).ShouldNot( + it.Nil(err), + ) +} + +func TestIO(t *testing.T) { + ts := mock() + defer ts.Close() + + type Site struct { + Site string `json:"site"` + } + + cat := µ.New() + + t.Run("JSON", func(t *testing.T) { + val, err := µ.IO[Site](cat.WithContext(context.Background()), + µ.GET( + ø.URI("%s/json", ø.Authority(ts.URL)), + ƒ.Status.OK, + ), + ) + it.Then(t).Should( + it.Nil(err), + it.Equal(val.Site, "example.com"), + ) + }) + + t.Run("Form", func(t *testing.T) { + val, err := µ.IO[Site](cat.WithContext(context.Background()), + µ.GET( + ø.URI("%s/form", ø.Authority(ts.URL)), + ƒ.Status.OK, + ), + ) + it.Then(t).Should( + it.Nil(err), + it.Equal(val.Site, "example.com"), + ) + }) + } func mock() *httptest.Server { return httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { + case r.URL.Path == "/json": + w.Header().Add("Content-Type", "application/json") + w.Write([]byte(`{"site": "example.com"}`)) + case r.URL.Path == "/form": + w.Header().Add("Content-Type", "application/x-www-form-urlencoded") + w.Write([]byte("site=example.com")) case r.URL.Path == "/ok": w.WriteHeader(http.StatusOK) default: diff --git a/types.go b/types.go index 81bdfa8..c412c4d 100644 --- a/types.go +++ b/types.go @@ -12,26 +12,6 @@ import ( "fmt" ) -/* - -Arrow is generic composable I/O -*/ -type Arrow func() error - -/* - -Join composes I/O -*/ -func Join(arrows ...Arrow) error { - for _, f := range arrows { - if err := f(); err != nil { - return err - } - } - - return nil -} - // NotSupported is returned if communication schema is not supported. type NotSupported struct{ URL string }