├── .editorconfig ├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── VERSION ├── adapter_fasthttp.go ├── adapter_mock.go ├── adapter_native.go ├── call.go ├── call_req.go ├── call_res.go ├── call_with.go ├── codec ├── constants.go ├── json.go ├── json_each_row.go ├── noop.go └── proxy_bytes.go ├── content_types.go ├── csvparser ├── columns.go ├── errors.go ├── parser.go ├── parser_test.go ├── types.go └── types_test.go ├── endpoint.go ├── errors.go ├── examples_fasthttp_test.go ├── examples_mock_test.go ├── examples_request_stream_test.go ├── examples_response_stream_test.go ├── examples_singlecall_test.go ├── go.mod ├── go.sum ├── stream_factory.go ├── streams.go ├── testing.go ├── utils_buf.go ├── utils_nalloc.go ├── utils_str.go ├── with_endpoint.go ├── with_logger.go ├── with_req.go ├── with_req_test.go └── with_res.go /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | 5 | tab_width = 2 -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | 11 | test: 12 | strategy: 13 | matrix: 14 | go-version: ['1.23', '1.24'] 15 | os: [ubuntu-latest, windows-latest, macos-latest] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ matrix.go-version }} 24 | 25 | - name: Build 26 | run: go build -v ./... 27 | 28 | - name: Test 29 | run: go test -v ./... 30 | 31 | - name: Run Examples 32 | run: go test -v -run "^Example" 33 | 34 | coverage: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - name: Set up Go 40 | uses: actions/setup-go@v5 41 | with: 42 | go-version: '1.23' 43 | 44 | - name: Run tests with coverage 45 | run: go test -v -coverprofile=coverage.out ./... 46 | 47 | - name: Upload coverage reports to Codecov 48 | uses: codecov/codecov-action@v3 49 | with: 50 | file: ./coverage.out 51 | flags: unittests 52 | name: codecov-umbrella 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | *.swp 4 | .test 5 | *.prof 6 | *.test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Marquitos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION := $(shell cat VERSION) 2 | XC_OS := linux darwin 3 | XC_OS := linux 4 | XC_ARCH := 386 amd64 arm 5 | XC_ARCH := amd64 6 | LD_FLAGS := -X main.version=$(VERSION) -s -w 7 | 8 | SOURCE_FILES ?=./*.go 9 | TEST_OPTIONS := -v -failfast -race 10 | TEST_OPTIONS := -v -failfast -race 11 | PROFILE_OPTIONS := -cpuprofile cpu.prof -memprofile mem.prof 12 | TEST_PATTERN ?=. 13 | BENCH_OPTIONS ?= -v -bench=. -benchmem 14 | CLEAN_OPTIONS ?=-modcache -testcache 15 | TEST_TIMEOUT ?=1m 16 | LINT_VERSION := 1.40.1 17 | 18 | export CGO_ENABLED=0 19 | export XC_OS 20 | export XC_ARCH 21 | export VERSION 22 | export PROJECT 23 | export GO111MODULE=on 24 | export LD_FLAGS 25 | export SOURCE_FILES 26 | export TEST_PATTERN 27 | export TEST_OPTIONS 28 | export TEST_TIMEOUT 29 | export LINT_VERSION 30 | export MallocNanoZone ?= 0 31 | 32 | .PHONY: all 33 | all: help 34 | 35 | .PHONY: help 36 | help: 37 | @echo "make fmt - use gofmt & goimports" 38 | @echo "make lint - run golangci-lint" 39 | @echo "make test - run go test including race detection" 40 | @echo "make bench - run go test including benchmarking" 41 | 42 | 43 | .PHONY: fmt 44 | fmt: 45 | $(info: Make: fmt) 46 | gofmt -w ./**/*.go 47 | gofmt -w ./*.go 48 | goimports -w ./**/*.go 49 | goimports -w ./*.go 50 | golines -w ./**/*.go 51 | golines -w ./*.go 52 | 53 | .PHONY: lint 54 | lint: 55 | $(info: Make: Lint) 56 | @golangci-lint run --tests=false 57 | 58 | 59 | .PHONY: test 60 | test: 61 | CGO_ENABLED=1 go test ${TEST_OPTIONS} ${SOURCE_FILES} -run ${TEST_PATTERN} -timeout=${TEST_TIMEOUT} 62 | 63 | .PHONY: bench 64 | bench: 65 | CGO_ENABLED=1 go test ${BENCH_OPTIONS} ${SOURCE_FILES} -run ${TEST_PATTERN} -timeout=${TEST_TIMEOUT} 66 | 67 | .PHONY: setup 68 | setup: 69 | go install golang.org/x/tools/cmd/goimports@latest 70 | go install github.com/segmentio/golines@latest -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # 🎯 withttp 4 | 5 | **Build HTTP requests and parse responses with fluent syntax and wit** 6 | 7 | [![Build Status](https://github.com/sonirico/withttp/actions/workflows/go.yml/badge.svg)](https://github.com/sonirico/withttp/actions/workflows/go.yml) 8 | [![Go Report Card](https://goreportcard.com/badge/github.com/sonirico/withttp)](https://goreportcard.com/report/github.com/sonirico/withttp) 9 | [![GoDoc](https://godoc.org/github.com/sonirico/withttp?status.svg)](https://godoc.org/github.com/sonirico/withttp) 10 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 11 | [![Go Version](https://img.shields.io/badge/go-1.23+-blue.svg)](https://golang.org/dl/) 12 | 13 | *A fluent HTTP client library that covers common scenarios while maintaining maximum flexibility* 14 | 15 |
16 | 17 | --- 18 | 19 | ## 🚀 Features 20 | 21 | - **🔄 Fluent API** - Chain methods for intuitive request building 22 | - **📡 Multiple HTTP Backends** - Support for `net/http` and `fasthttp` 23 | - **🎯 Type-Safe Responses** - Generic-based response parsing 24 | - **📊 Streaming Support** - Stream data from slices, channels, or readers 25 | - **🧪 Mock-Friendly** - Built-in mocking capabilities for testing 26 | - **⚡ High Performance** - Optimized for speed and low allocations 27 | 28 | ## 📦 Installation 29 | 30 | ```bash 31 | go get github.com/sonirico/withttp 32 | ``` 33 | 34 | ## 🎛️ Supported HTTP Implementations 35 | 36 | | Implementation | Description | 37 | | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | 38 | | [net/http](https://pkg.go.dev/net/http) | Go's standard HTTP client | 39 | | [fasthttp](https://pkg.go.dev/github.com/valyala/fasthttp) | High-performance HTTP client | 40 | | Custom Client | Implement the [Client interface](https://github.com/sonirico/withttp/blob/main/endpoint.go#L43) | 41 | 42 | > 💡 Missing your preferred HTTP client? [Open an issue](https://github.com/sonirico/withttp/issues/new) and let us know! 43 | 44 | ## 📚 Table of Contents 45 | 46 | - [🎯 withttp](#-withttp) 47 | - [🚀 Features](#-features) 48 | - [📦 Installation](#-installation) 49 | - [🎛️ Supported HTTP Implementations](#️-supported-http-implementations) 50 | - [📚 Table of Contents](#-table-of-contents) 51 | - [🏁 Quick Start](#-quick-start) 52 | - [💡 Examples](#-examples) 53 | - [RESTful API Queries](#restful-api-queries) 54 | - [Streaming Data](#streaming-data) 55 | - [📄 Stream from Slice](#-stream-from-slice) 56 | - [📡 Stream from Channel](#-stream-from-channel) 57 | - [📖 Stream from Reader](#-stream-from-reader) 58 | - [Multiple Endpoints](#multiple-endpoints) 59 | - [Testing with Mocks](#testing-with-mocks) 60 | - [🗺️ Roadmap](#️-roadmap) 61 | - [🤝 Contributing](#-contributing) 62 | - [📄 License](#-license) 63 | - [⭐ Show Your Support](#-show-your-support) 64 | 65 | ## 🏁 Quick Start 66 | 67 | ```go 68 | package main 69 | 70 | import ( 71 | "context" 72 | "fmt" 73 | "net/http" 74 | 75 | "github.com/sonirico/withttp" 76 | ) 77 | 78 | type GithubRepo struct { 79 | ID int `json:"id"` 80 | Name string `json:"name"` 81 | URL string `json:"html_url"` 82 | } 83 | 84 | func main() { 85 | call := withttp.NewCall[GithubRepo](withttp.Fasthttp()). 86 | URL("https://api.github.com/repos/sonirico/withttp"). 87 | Method(http.MethodGet). 88 | Header("User-Agent", "withttp-example/1.0", false). 89 | ParseJSON(). 90 | ExpectedStatusCodes(http.StatusOK) 91 | 92 | err := call.Call(context.Background()) 93 | if err != nil { 94 | panic(err) 95 | } 96 | 97 | fmt.Printf("Repository: %s (ID: %d)\n", call.BodyParsed.Name, call.BodyParsed.ID) 98 | } 99 | ``` 100 | 101 | ## 💡 Examples 102 | 103 | All examples are now available as: 104 | - **Example functions** in the test files - you can run these with `go test -v -run "^Example"` 105 | - **Test functions** for comprehensive testing scenarios 106 | - **Documentation examples** in the code itself 107 | 108 | ### RESTful API Queries 109 | 110 |
111 | Click to expand 112 | 113 | ```go 114 | type GithubRepoInfo struct { 115 | ID int `json:"id"` 116 | URL string `json:"html_url"` 117 | } 118 | 119 | func GetRepoInfo(user, repo string) (GithubRepoInfo, error) { 120 | call := withttp.NewCall[GithubRepoInfo](withttp.Fasthttp()). 121 | URL(fmt.Sprintf("https://api.github.com/repos/%s/%s", user, repo)). 122 | Method(http.MethodGet). 123 | Header("User-Agent", "withttp/0.5.1 See https://github.com/sonirico/withttp", false). 124 | ParseJSON(). 125 | ExpectedStatusCodes(http.StatusOK) 126 | 127 | err := call.Call(context.Background()) 128 | return call.BodyParsed, err 129 | } 130 | 131 | func main() { 132 | info, _ := GetRepoInfo("sonirico", "withttp") 133 | log.Println(info) 134 | } 135 | ``` 136 | 137 |
138 | 139 | ### Streaming Data 140 | 141 | #### 📄 Stream from Slice 142 | 143 |
144 | View example 145 | 146 | [See test example](https://github.com/sonirico/withttp/blob/main/examples_request_stream_test.go) 147 | 148 | ```go 149 | type metric struct { 150 | Time time.Time `json:"t"` 151 | Temp float32 `json:"T"` 152 | } 153 | 154 | func CreateStream() error { 155 | points := []metric{ 156 | {Time: time.Unix(time.Now().Unix()-1, 0), Temp: 39}, 157 | {Time: time.Now(), Temp: 40}, 158 | } 159 | 160 | stream := withttp.Slice[metric](points) 161 | testEndpoint := withttp.NewEndpoint("webhook-site-request-stream-example"). 162 | Request(withttp.BaseURL("https://webhook.site/24e84e8f-75cf-4239-828e-8bed244c0afb")) 163 | 164 | call := withttp.NewCall[any](withttp.Fasthttp()). 165 | Method(http.MethodPost). 166 | ContentType(withttp.ContentTypeJSONEachRow). 167 | RequestSniffed(func(data []byte, err error) { 168 | fmt.Printf("recv: '%s', err: %v", string(data), err) 169 | }). 170 | RequestStreamBody(withttp.RequestStreamBody[any, metric](stream)). 171 | ExpectedStatusCodes(http.StatusOK) 172 | 173 | return call.CallEndpoint(context.Background(), testEndpoint) 174 | } 175 | ``` 176 | 177 |
178 | 179 | #### 📡 Stream from Channel 180 | 181 |
182 | View example 183 | 184 | [See test example](https://github.com/sonirico/withttp/blob/main/examples_request_stream_test.go) 185 | 186 | ```go 187 | func CreateStreamChannel() error { 188 | points := make(chan metric, 2) 189 | 190 | go func() { 191 | points <- metric{Time: time.Unix(time.Now().Unix()-1, 0), Temp: 39} 192 | points <- metric{Time: time.Now(), Temp: 40} 193 | close(points) 194 | }() 195 | 196 | stream := withttp.Channel[metric](points) 197 | testEndpoint := withttp.NewEndpoint("webhook-site-request-stream-example"). 198 | Request(withttp.BaseURL("https://webhook.site/24e84e8f-75cf-4239-828e-8bed244c0afb")) 199 | 200 | call := withttp.NewCall[any](withttp.Fasthttp()). 201 | Method(http.MethodPost). 202 | ContentType(withttp.ContentTypeJSONEachRow). 203 | RequestSniffed(func(data []byte, err error) { 204 | fmt.Printf("recv: '%s', err: %v", string(data), err) 205 | }). 206 | RequestStreamBody(withttp.RequestStreamBody[any, metric](stream)). 207 | ExpectedStatusCodes(http.StatusOK) 208 | 209 | return call.CallEndpoint(context.Background(), testEndpoint) 210 | } 211 | ``` 212 | 213 |
214 | 215 | #### 📖 Stream from Reader 216 | 217 |
218 | View example 219 | 220 | [See test example](https://github.com/sonirico/withttp/blob/main/examples_request_stream_test.go) 221 | 222 | ```go 223 | func CreateStreamReader() error { 224 | buf := bytes.NewBuffer(nil) 225 | 226 | go func() { 227 | buf.WriteString("{\"t\":\"2022-09-01T00:58:15+02:00\"") 228 | buf.WriteString(",\"T\":39}\n{\"t\":\"2022-09-01T00:59:15+02:00\",\"T\":40}\n") 229 | }() 230 | 231 | streamFactory := withttp.NewProxyStreamFactory(1 << 10) 232 | stream := withttp.NewStreamFromReader(buf, streamFactory) 233 | testEndpoint := withttp.NewEndpoint("webhook-site-request-stream-example"). 234 | Request(withttp.BaseURL("https://webhook.site/24e84e8f-75cf-4239-828e-8bed244c0afb")) 235 | 236 | call := withttp.NewCall[any](withttp.NetHttp()). 237 | Method(http.MethodPost). 238 | RequestSniffed(func(data []byte, err error) { 239 | fmt.Printf("recv: '%s', err: %v", string(data), err) 240 | }). 241 | ContentType(withttp.ContentTypeJSONEachRow). 242 | RequestStreamBody(withttp.RequestStreamBody[any, []byte](stream)). 243 | ExpectedStatusCodes(http.StatusOK) 244 | 245 | return call.CallEndpoint(context.Background(), testEndpoint) 246 | } 247 | ``` 248 | 249 |
250 | 251 | ### Multiple Endpoints 252 | 253 |
254 | Click to expand 255 | 256 | Define reusable endpoint configurations for API consistency: 257 | 258 | ```go 259 | var ( 260 | githubApi = withttp.NewEndpoint("GithubAPI"). 261 | Request(withttp.BaseURL("https://api.github.com/")) 262 | ) 263 | 264 | type GithubRepoInfo struct { 265 | ID int `json:"id"` 266 | URL string `json:"html_url"` 267 | } 268 | 269 | func GetRepoInfo(user, repo string) (GithubRepoInfo, error) { 270 | call := withttp.NewCall[GithubRepoInfo](withttp.Fasthttp()). 271 | URI(fmt.Sprintf("repos/%s/%s", user, repo)). 272 | Method(http.MethodGet). 273 | Header("User-Agent", "withttp/0.5.1 See https://github.com/sonirico/withttp", false). 274 | HeaderFunc(func() (key, value string, override bool) { 275 | return "X-Date", time.Now().String(), true 276 | }). 277 | ParseJSON(). 278 | ExpectedStatusCodes(http.StatusOK) 279 | 280 | err := call.CallEndpoint(context.Background(), githubApi) 281 | return call.BodyParsed, err 282 | } 283 | 284 | type GithubCreateIssueResponse struct { 285 | ID int `json:"id"` 286 | URL string `json:"url"` 287 | } 288 | 289 | func CreateRepoIssue(user, repo, title, body, assignee string) (GithubCreateIssueResponse, error) { 290 | type payload struct { 291 | Title string `json:"title"` 292 | Body string `json:"body"` 293 | Assignee string `json:"assignee"` 294 | } 295 | 296 | p := payload{Title: title, Body: body, Assignee: assignee} 297 | 298 | call := withttp.NewCall[GithubCreateIssueResponse](withttp.Fasthttp()). 299 | URI(fmt.Sprintf("repos/%s/%s/issues", user, repo)). 300 | Method(http.MethodPost). 301 | ContentType("application/vnd+github+json"). 302 | Body(p). 303 | HeaderFunc(func() (key, value string, override bool) { 304 | return "Authorization", fmt.Sprintf("Bearer %s", "S3cret"), true 305 | }). 306 | ExpectedStatusCodes(http.StatusCreated) 307 | 308 | err := call.CallEndpoint(context.Background(), githubApi) 309 | log.Println("req body", string(call.Req.Body())) 310 | 311 | return call.BodyParsed, err 312 | } 313 | 314 | func main() { 315 | // Fetch repo info 316 | info, _ := GetRepoInfo("sonirico", "withttp") 317 | log.Println(info) 318 | 319 | // Create an issue 320 | res, err := CreateRepoIssue("sonirico", "withttp", "test", "This is a test", "sonirico") 321 | log.Println(res, err) 322 | } 323 | ``` 324 | 325 |
326 | 327 | ### Testing with Mocks 328 | 329 |
330 | Click to expand 331 | 332 | Easily test your HTTP calls with built-in mocking: 333 | 334 | ```go 335 | var ( 336 | exchangeListOrders = withttp.NewEndpoint("ListOrders"). 337 | Request(withttp.BaseURL("http://example.com")). 338 | Response( 339 | withttp.MockedRes(func(res withttp.Response) { 340 | res.SetBody(io.NopCloser(bytes.NewReader(mockResponse))) 341 | res.SetStatus(http.StatusOK) 342 | }), 343 | ) 344 | mockResponse = []byte(strings.TrimSpace(` 345 | {"amount": 234, "pair": "BTC/USDT"} 346 | {"amount": 123, "pair": "ETH/USDT"}`)) 347 | ) 348 | 349 | func main() { 350 | type Order struct { 351 | Amount float64 `json:"amount"` 352 | Pair string `json:"pair"` 353 | } 354 | 355 | res := make(chan Order) 356 | 357 | call := withttp.NewCall[Order](withttp.Fasthttp()). 358 | URL("https://github.com/"). 359 | Method(http.MethodGet). 360 | Header("User-Agent", "withttp/0.5.1 See https://github.com/sonirico/withttp", false). 361 | ParseJSONEachRowChan(res). 362 | ExpectedStatusCodes(http.StatusOK) 363 | 364 | go func() { 365 | for order := range res { 366 | log.Println(order) 367 | } 368 | }() 369 | 370 | err := call.CallEndpoint(context.Background(), exchangeListOrders) 371 | if err != nil { 372 | panic(err) 373 | } 374 | } 375 | ``` 376 | 377 |
378 | 379 | ## 🗺️ Roadmap 380 | 381 | | Feature | Status | 382 | | ----------------------------- | ------------- | 383 | | Form-data content type codecs | 🔄 In Progress | 384 | | Enhanced auth methods | 📋 Planned | 385 | | XML parsing support | 📋 Planned | 386 | | Tabular data support | 📋 Planned | 387 | | gRPC integration | 🤔 Considering | 388 | 389 | ## 🤝 Contributing 390 | 391 | We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. 392 | 393 | 1. Fork the repository 394 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 395 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 396 | 4. Push to the branch (`git push origin feature/amazing-feature`) 397 | 5. Open a Pull Request 398 | 399 | ## 📄 License 400 | 401 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 402 | 403 | ## ⭐ Show Your Support 404 | 405 | If this project helped you, please give it a ⭐! It helps others discover the project. 406 | 407 | --- 408 | 409 |
410 | 411 | **[Documentation](https://godoc.org/github.com/sonirico/withttp)** • 412 | **[Test Examples](https://github.com/sonirico/withttp/blob/main/)** • 413 | **[Issues](https://github.com/sonirico/withttp/issues)** • 414 | **[Discussions](https://github.com/sonirico/withttp/discussions)** 415 | 416 | Made with ❤️ by [sonirico](https://github.com/sonirico) 417 | 418 |
419 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | v0.1.0 -------------------------------------------------------------------------------- /adapter_fasthttp.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | 10 | "github.com/valyala/fasthttp" 11 | ) 12 | 13 | type ( 14 | fastHttpReqAdapter struct { 15 | stream io.ReadWriteCloser 16 | 17 | req *fasthttp.Request 18 | } 19 | 20 | fastHttpResAdapter struct { 21 | statusText string 22 | res *fasthttp.Response 23 | } 24 | 25 | FastHttpHttpClientAdapter struct { 26 | cli *fasthttp.Client 27 | } 28 | ) 29 | 30 | var ( 31 | fastClient = &fasthttp.Client{} 32 | ) 33 | 34 | func (a *fastHttpReqAdapter) AddHeader(key, value string) { 35 | a.req.Header.Add(key, value) 36 | } 37 | 38 | func (a *fastHttpReqAdapter) RangeHeaders(fn func(string, string)) { 39 | a.req.Header.VisitAll(func(key, value []byte) { 40 | fn(string(key), string(value)) 41 | }) 42 | } 43 | 44 | func (a *fastHttpReqAdapter) SetHeader(key, value string) { 45 | a.req.Header.Set(key, value) 46 | } 47 | 48 | func (a *fastHttpReqAdapter) Header(key string) (string, bool) { 49 | data := a.req.Header.Peek(key) 50 | if len(data) == 0 { 51 | return "", false 52 | } 53 | bts := make([]byte, len(data)) 54 | copy(bts, data) 55 | return string(bts), true 56 | } 57 | 58 | func (a *fastHttpReqAdapter) Method() string { 59 | return string(a.req.Header.Method()) 60 | } 61 | 62 | func (a *fastHttpReqAdapter) SetMethod(method string) { 63 | a.req.Header.SetMethod(method) 64 | } 65 | 66 | func (a *fastHttpReqAdapter) SetURL(u *url.URL) { 67 | uri := a.req.URI() 68 | uri.SetScheme(u.Scheme) 69 | uri.SetHost(u.Host) 70 | uri.SetPath(u.Path) 71 | uri.SetQueryString(u.RawQuery) 72 | uri.SetHash(string(uri.Hash())) 73 | 74 | username := u.User.Username() 75 | if StrIsset(username) { 76 | uri.SetUsername(username) 77 | } 78 | 79 | if pass, ok := u.User.Password(); ok { 80 | uri.SetPassword(pass) 81 | } 82 | } 83 | 84 | func (a *fastHttpReqAdapter) SetBodyStream(body io.ReadWriteCloser, bodySize int) { 85 | a.stream = body 86 | a.req.SetBodyStream(a.stream, bodySize) 87 | } 88 | 89 | func (a *fastHttpReqAdapter) SetBody(body []byte) { 90 | a.req.SetBody(body) 91 | a.req.Header.SetContentLength(len(body)) 92 | } 93 | 94 | func (a *fastHttpReqAdapter) Body() (bts []byte) { 95 | bts, _ = io.ReadAll(a.stream) 96 | return 97 | } 98 | 99 | func (a *fastHttpReqAdapter) BodyStream() io.ReadWriteCloser { 100 | return a.stream 101 | } 102 | 103 | func (a *fastHttpReqAdapter) URL() *url.URL { 104 | uri := a.req.URI() 105 | 106 | var user *url.Userinfo 107 | if BtsIsset(uri.Username()) { 108 | user = url.UserPassword(string(uri.Username()), string(uri.Password())) 109 | } 110 | 111 | u := &url.URL{ 112 | Scheme: string(uri.Scheme()), 113 | User: user, 114 | Host: string(uri.Host()), 115 | Path: string(uri.Path()), 116 | RawPath: string(uri.Path()), 117 | RawQuery: string(uri.QueryString()), 118 | Fragment: string(uri.Hash()), 119 | } 120 | 121 | return u 122 | } 123 | 124 | func adaptReqFastHttp(req *fasthttp.Request) Request { 125 | return &fastHttpReqAdapter{req: req} 126 | } 127 | 128 | func (a *FastHttpHttpClientAdapter) Request(_ context.Context) (Request, error) { 129 | req := &fasthttp.Request{} 130 | req.Header.SetMethod(http.MethodGet) 131 | return adaptReqFastHttp(req), nil 132 | } 133 | 134 | func (a *FastHttpHttpClientAdapter) Do(_ context.Context, req Request) (Response, error) { 135 | res := &fasthttp.Response{} // TODO: Acquire/Release 136 | err := a.cli.Do(req.(*fastHttpReqAdapter).req, res) 137 | return adaptResFastHttp(res), err 138 | } 139 | 140 | func Fasthttp() *FastHttpHttpClientAdapter { 141 | return newFastHttpHttpClientAdapter(fastClient) 142 | } 143 | 144 | func FasthttpClient(cli *fasthttp.Client) *FastHttpHttpClientAdapter { 145 | return newFastHttpHttpClientAdapter(cli) 146 | } 147 | 148 | func newFastHttpHttpClientAdapter(cli *fasthttp.Client) *FastHttpHttpClientAdapter { 149 | return &FastHttpHttpClientAdapter{cli: cli} 150 | } 151 | 152 | func adaptResFastHttp(res *fasthttp.Response) Response { 153 | return &fastHttpResAdapter{res: res} 154 | } 155 | 156 | func (a *fastHttpResAdapter) SetBody(body io.ReadCloser) { 157 | a.res.SetBodyStream(body, -1) 158 | } 159 | 160 | func (a *fastHttpResAdapter) Status() int { 161 | return a.res.StatusCode() 162 | } 163 | 164 | func (a *fastHttpResAdapter) SetStatus(status int) { 165 | a.res.SetStatusCode(status) 166 | } 167 | 168 | func (a *fastHttpResAdapter) StatusText() string { 169 | return a.statusText 170 | } 171 | 172 | func (a *fastHttpResAdapter) Body() io.ReadCloser { 173 | return io.NopCloser(bytes.NewReader(a.res.Body())) 174 | } 175 | 176 | func (a *fastHttpResAdapter) AddHeader(key, value string) { 177 | a.res.Header.Add(key, value) 178 | } 179 | 180 | func (a *fastHttpResAdapter) SetHeader(key, value string) { 181 | a.res.Header.Set(key, value) 182 | } 183 | 184 | func (a *fastHttpResAdapter) Header(key string) (string, bool) { 185 | data := a.res.Header.Peek(key) 186 | if len(data) == 0 { 187 | return "", false 188 | } 189 | bts := make([]byte, len(data)) 190 | copy(bts, data) 191 | return string(bts), true 192 | } 193 | 194 | func (a *fastHttpResAdapter) RangeHeaders(fn func(string, string)) { 195 | a.res.Header.VisitAll(func(key, value []byte) { 196 | fn(string(key), string(value)) 197 | }) 198 | } 199 | -------------------------------------------------------------------------------- /adapter_mock.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type ( 9 | MockHttpClientAdapter struct{} 10 | ) 11 | 12 | func NewMockHttpClientAdapter() *MockHttpClientAdapter { 13 | return &MockHttpClientAdapter{} 14 | } 15 | 16 | func (a *MockHttpClientAdapter) Request(_ context.Context) (Request, error) { 17 | req, err := http.NewRequest("GET", "", nil) 18 | if err != nil { 19 | return nil, err 20 | } 21 | return adaptReqMock(req), err 22 | } 23 | 24 | func (a *MockHttpClientAdapter) Do(_ context.Context, _ Request) (Response, error) { 25 | return adaptResMock(&http.Response{}), nil 26 | } 27 | 28 | func adaptResMock(res *http.Response) Response { 29 | return adaptResNative(res) 30 | } 31 | 32 | func adaptReqMock(req *http.Request) Request { 33 | return adaptReqNative(req) 34 | } 35 | -------------------------------------------------------------------------------- /adapter_native.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | ) 11 | 12 | type ( 13 | nativeReqAdapter struct { 14 | body io.ReadWriteCloser 15 | 16 | req *http.Request 17 | } 18 | 19 | nativeResAdapter struct { 20 | res *http.Response 21 | } 22 | 23 | NativeHttpClientAdapter struct { 24 | cli *http.Client 25 | } 26 | ) 27 | 28 | func (a *nativeReqAdapter) AddHeader(key, value string) { 29 | a.req.Header.Add(key, value) 30 | } 31 | 32 | func (a *nativeReqAdapter) SetHeader(key, value string) { 33 | a.req.Header.Set(key, value) 34 | } 35 | 36 | func (a *nativeReqAdapter) Header(key string) (string, bool) { 37 | s := a.req.Header.Get(key) 38 | return s, len(s) > 0 39 | } 40 | 41 | func (a *nativeReqAdapter) Method() string { 42 | return a.req.Method 43 | } 44 | 45 | func (a *nativeReqAdapter) SetMethod(method string) { 46 | a.req.Method = method 47 | } 48 | 49 | func (a *nativeReqAdapter) SetURL(u *url.URL) { 50 | a.req.URL = u 51 | } 52 | 53 | func (a *nativeReqAdapter) SetBodyStream(body io.ReadWriteCloser, _ int) { 54 | a.body = body 55 | a.req.Body = body 56 | } 57 | 58 | func (a *nativeReqAdapter) SetBody(payload []byte) { 59 | // TODO: pool these readers 60 | a.body = closableReaderWriter{ReadWriter: bytes.NewBuffer(payload)} 61 | a.req.Body = a.body 62 | a.req.ContentLength = int64(len(payload)) 63 | } 64 | 65 | func (a *nativeReqAdapter) Body() []byte { 66 | bts, _ := io.ReadAll(a.req.Body) 67 | return bts 68 | } 69 | 70 | func (a *nativeReqAdapter) RangeHeaders(fn func(string, string)) { 71 | for k, v := range a.req.Header { 72 | fn(k, strings.Join(v, ", ")) 73 | } 74 | } 75 | 76 | func (a *nativeReqAdapter) BodyStream() io.ReadWriteCloser { 77 | return a.body 78 | } 79 | 80 | func (a *nativeReqAdapter) URL() *url.URL { 81 | return a.req.URL 82 | } 83 | 84 | func adaptReqNative(req *http.Request) Request { 85 | return &nativeReqAdapter{req: req} 86 | } 87 | 88 | func (a *NativeHttpClientAdapter) Request(ctx context.Context) (Request, error) { 89 | req, err := http.NewRequestWithContext(ctx, "GET", "", nil) 90 | if err != nil { 91 | return nil, err 92 | } 93 | return adaptReqNative(req), err 94 | } 95 | 96 | func (a *NativeHttpClientAdapter) Do(ctx context.Context, req Request) (Response, error) { 97 | res, err := a.cli.Do(req.(*nativeReqAdapter).req) 98 | return adaptResNative(res), err 99 | } 100 | 101 | func NetHttpClient(cli *http.Client) *NativeHttpClientAdapter { 102 | return &NativeHttpClientAdapter{cli: cli} 103 | } 104 | 105 | func NetHttp() *NativeHttpClientAdapter { 106 | return NetHttpClient(http.DefaultClient) 107 | } 108 | 109 | func adaptResNative(res *http.Response) Response { 110 | return &nativeResAdapter{res: res} 111 | } 112 | 113 | func (a *nativeResAdapter) SetBody(body io.ReadCloser) { 114 | a.res.Body = body 115 | } 116 | 117 | func (a *nativeResAdapter) Status() int { 118 | return a.res.StatusCode 119 | } 120 | 121 | func (a *nativeResAdapter) SetStatus(status int) { 122 | a.res.StatusCode = status 123 | } 124 | 125 | func (a *nativeResAdapter) StatusText() string { 126 | return a.res.Status 127 | } 128 | 129 | func (a *nativeResAdapter) Body() io.ReadCloser { 130 | return a.res.Body 131 | } 132 | 133 | func (a *nativeResAdapter) AddHeader(key, value string) { 134 | a.res.Header.Add(key, value) 135 | } 136 | 137 | func (a *nativeResAdapter) SetHeader(key, value string) { 138 | a.res.Header.Set(key, value) 139 | } 140 | 141 | func (a *nativeResAdapter) Header(key string) (string, bool) { 142 | s := a.res.Header.Get(key) 143 | return s, len(s) > 0 144 | } 145 | 146 | func (a *nativeResAdapter) RangeHeaders(fn func(string, string)) { 147 | for k, v := range a.res.Header { 148 | fn(k, strings.Join(v, ", ")) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /call.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "sync" 8 | ) 9 | 10 | type ( 11 | CallResOption[T any] interface { 12 | Parse(c *Call[T], r Response) error 13 | } 14 | 15 | CallReqOption[T any] interface { 16 | Configure(c *Call[T], r Request) error 17 | } 18 | 19 | CallReqOptionFunc[T any] func(c *Call[T], res Request) error 20 | 21 | CallResOptionFunc[T any] func(c *Call[T], res Response) error 22 | 23 | Call[T any] struct { 24 | logger logger 25 | 26 | client Client 27 | 28 | reqOptions []ReqOption // TODO: Linked Lists 29 | resOptions []ResOption 30 | 31 | Req Request 32 | Res Response 33 | 34 | BodyRaw []byte 35 | BodyParsed T 36 | 37 | ReqContentType string 38 | ReqBodyRaw []byte 39 | ReqIsStream bool 40 | 41 | ReqStreamWriter func(ctx context.Context, c *Call[T], res Request, wg *sync.WaitGroup) error 42 | ReqStreamSniffer func([]byte, error) 43 | ReqShouldSniff bool 44 | } 45 | ) 46 | 47 | func (f CallResOptionFunc[T]) Parse(c *Call[T], res Response) error { 48 | return f(c, res) 49 | } 50 | 51 | func (f CallReqOptionFunc[T]) Configure(c *Call[T], req Request) error { 52 | return f(c, req) 53 | } 54 | 55 | func NewCall[T any](client Client) *Call[T] { 56 | return &Call[T]{client: client} 57 | } 58 | 59 | func (c *Call[T]) bodyReader(res Response) (rc io.ReadCloser) { 60 | if c.BodyRaw != nil { 61 | rc = io.NopCloser(bytes.NewReader(c.BodyRaw)) 62 | } else { 63 | rc = res.Body() 64 | } 65 | return 66 | } 67 | 68 | func (c *Call[T]) withRes(fn CallResOption[T]) *Call[T] { 69 | c.resOptions = append( 70 | c.resOptions, 71 | ResOptionFunc(func(res Response) (err error) { 72 | return fn.Parse(c, res) 73 | }), 74 | ) 75 | return c 76 | } 77 | 78 | func (c *Call[T]) withReq(fn CallReqOption[T]) *Call[T] { 79 | c.reqOptions = append( 80 | c.reqOptions, 81 | ReqOptionFunc(func(req Request) error { 82 | return fn.Configure(c, req) 83 | }), 84 | ) 85 | return c 86 | } 87 | 88 | func (c *Call[T]) parseRes(res Response) error { 89 | for _, opt := range c.resOptions { 90 | if err := opt.Parse(res); err != nil { 91 | return err 92 | } 93 | } 94 | return nil 95 | } 96 | 97 | func (c *Call[T]) configureReq(req Request) error { 98 | for _, opt := range c.reqOptions { 99 | if err := opt.Configure(req); err != nil { 100 | return err 101 | } 102 | } 103 | return nil 104 | } 105 | 106 | func (c *Call[T]) Call(ctx context.Context) (err error) { 107 | return c.callEndpoint(ctx, nil) 108 | } 109 | 110 | func (c *Call[T]) CallEndpoint(ctx context.Context, e *Endpoint) (err error) { 111 | return c.callEndpoint(ctx, e) 112 | } 113 | 114 | func (c *Call[T]) callEndpoint(ctx context.Context, e *Endpoint) (err error) { 115 | req, err := c.client.Request(ctx) 116 | defer func() { c.Req = req }() 117 | 118 | if err != nil { 119 | return 120 | } 121 | 122 | if e != nil { 123 | for _, opt := range e.requestOpts { 124 | if err = opt.Configure(req); err != nil { 125 | return err 126 | } 127 | } 128 | } 129 | 130 | if err = c.configureReq(req); err != nil { 131 | return 132 | } 133 | 134 | var wg *sync.WaitGroup 135 | 136 | if c.ReqIsStream { 137 | wg = &sync.WaitGroup{} 138 | wg.Add(1) 139 | 140 | go func() { 141 | _ = c.ReqStreamWriter(ctx, c, req, wg) 142 | }() 143 | } 144 | 145 | c.log("[withttp] %s %s", req.Method(), req.URL().String()) 146 | 147 | res, err := c.client.Do(ctx, req) 148 | 149 | if c.ReqIsStream { 150 | wg.Wait() 151 | } 152 | 153 | if err != nil { 154 | return 155 | } 156 | 157 | if res != nil { 158 | c.log("[withttp] server returned status code %d", res.Status()) 159 | } 160 | 161 | defer func() { c.Res = res }() 162 | 163 | if e != nil { 164 | for _, opt := range e.responseOpts { 165 | if err = opt.Parse(res); err != nil { 166 | return 167 | } 168 | } 169 | } 170 | 171 | if err = c.parseRes(res); err != nil { 172 | return 173 | } 174 | 175 | return 176 | } 177 | 178 | func (c *Call[T]) log(tpl string, args ...any) { 179 | if c.logger == nil { 180 | return 181 | } 182 | 183 | c.logger.Printf(tpl, args...) 184 | } 185 | -------------------------------------------------------------------------------- /call_req.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "io" 5 | "strconv" 6 | ) 7 | 8 | type ( 9 | StreamCallReqOption[T any] interface { 10 | CallReqOption[T] 11 | 12 | stream() 13 | } 14 | 15 | StreamCallReqOptionFunc[T any] func(c *Call[T], req Request) error 16 | ) 17 | 18 | func (s StreamCallReqOptionFunc[T]) stream() {} 19 | 20 | func (s StreamCallReqOptionFunc[T]) Configure(c *Call[T], req Request) error { 21 | return s(c, req) 22 | } 23 | 24 | func (c *Call[T]) Request(opts ...ReqOption) *Call[T] { 25 | c.reqOptions = append(c.reqOptions, opts...) 26 | return c 27 | } 28 | 29 | func (c *Call[T]) URL(raw string) *Call[T] { 30 | return c.withReq(URL[T](raw)) 31 | } 32 | 33 | func (c *Call[T]) URI(raw string) *Call[T] { 34 | return c.withReq(URI[T](raw)) 35 | } 36 | 37 | func (c *Call[T]) Method(method string) *Call[T] { 38 | return c.withReq(Method[T](method)) 39 | } 40 | 41 | func (c *Call[T]) Query(k, v string) *Call[T] { 42 | return c.withReq(Query[T](k, v)) 43 | } 44 | 45 | func (c *Call[T]) RequestSniffed(fn func([]byte, error)) *Call[T] { 46 | return c.withReq(RequestSniffer[T](fn)) 47 | } 48 | 49 | func (c *Call[T]) RequestStreamBody(opt StreamCallReqOptionFunc[T]) *Call[T] { 50 | return c.withReq(opt) 51 | } 52 | 53 | // BodyStream receives a stream of data to set on the request. Second parameter `bodySize` indicates 54 | // the estimated content-length of this stream. Required when employing fasthttp http client. 55 | func (c *Call[T]) BodyStream(rc io.ReadWriteCloser, bodySize int) *Call[T] { 56 | return c.withReq(BodyStream[T](rc, bodySize)) 57 | } 58 | 59 | func (c *Call[T]) Body(payload any) *Call[T] { 60 | return c.withReq(Body[T](payload)) 61 | } 62 | 63 | func (c *Call[T]) RawBody(payload []byte) *Call[T] { 64 | return c.withReq(RawBody[T](payload)) 65 | } 66 | 67 | func (c *Call[T]) ContentLength(length int) *Call[T] { 68 | return c.Header("content-length", strconv.FormatInt(int64(length), 10), true) 69 | } 70 | 71 | func (c *Call[T]) Header(key, value string, override bool) *Call[T] { 72 | return c.withReq(Header[T](key, value, override)) 73 | } 74 | 75 | func (c *Call[T]) HeaderFunc(fn func() (key, value string, override bool)) *Call[T] { 76 | return c.withReq(HeaderFunc[T](fn)) 77 | } 78 | 79 | func (c *Call[T]) BasicAuth(user, pass string) *Call[T] { 80 | return c.withReq(BasicAuth[T](user, pass)) 81 | } 82 | 83 | func (c *Call[T]) ContentType(ct string) *Call[T] { 84 | return c.withReq(ContentType[T](ct)) 85 | } 86 | -------------------------------------------------------------------------------- /call_res.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import "github.com/sonirico/withttp/csvparser" 4 | 5 | func (c *Call[T]) Response(opts ...ResOption) *Call[T] { 6 | c.resOptions = append(c.resOptions, opts...) 7 | return c 8 | } 9 | 10 | func (c *Call[T]) ReadBody() *Call[T] { 11 | return c.withRes(ParseBodyRaw[T]()) 12 | } 13 | 14 | func (c *Call[T]) ParseStreamChan(factory StreamFactory[T], ch chan<- T) *Call[T] { 15 | return c.withRes(ParseStreamChan[T](factory, ch)) 16 | } 17 | 18 | func (c *Call[T]) ParseStream(factory StreamFactory[T], fn func(T) bool) *Call[T] { 19 | return c.withRes(ParseStream[T](factory, fn)) 20 | } 21 | 22 | func (c *Call[T]) ParseJSONEachRowChan(out chan<- T) *Call[T] { 23 | return c.ParseStreamChan(NewJSONEachRowStreamFactory[T](), out) 24 | } 25 | 26 | func (c *Call[T]) ParseJSONEachRow(fn func(T) bool) *Call[T] { 27 | return c.ParseStream(NewJSONEachRowStreamFactory[T](), fn) 28 | } 29 | 30 | func (c *Call[T]) ParseCSV( 31 | ignoreLines int, 32 | parser csvparser.Parser[T], 33 | fn func(T) bool, 34 | ) *Call[T] { 35 | return c.ParseStream(NewCSVStreamFactory[T](ignoreLines, parser), fn) 36 | } 37 | 38 | func (c *Call[T]) IgnoreResponseBody() *Call[T] { 39 | return c.withRes(IgnoredBody[T]()) 40 | } 41 | 42 | func (c *Call[T]) ParseJSON() *Call[T] { 43 | return c.withRes(ParseJSON[T]()) 44 | } 45 | 46 | func (c *Call[T]) Assert(fn func(req Response) error) *Call[T] { 47 | return c.withRes(Assertion[T](fn)) 48 | } 49 | 50 | func (c *Call[T]) ExpectedStatusCodes(states ...int) *Call[T] { 51 | return c.withRes(ExpectedStatusCodes[T](states...)) 52 | } 53 | -------------------------------------------------------------------------------- /call_with.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | func (c *Call[T]) WithLogger(l logger) *Call[T] { 10 | c.logger = l 11 | return c 12 | } 13 | 14 | func (c *Call[T]) Log(w io.Writer) { 15 | buf := bufio.NewWriter(w) 16 | 17 | _, _ = buf.WriteString(c.Req.Method()) 18 | _, _ = buf.WriteString(" ") 19 | _, _ = buf.WriteString(c.Req.URL().String()) 20 | _, _ = buf.WriteString("\n") 21 | 22 | c.Req.RangeHeaders(func(key string, value string) { 23 | _, _ = buf.WriteString(key) 24 | _ = buf.WriteByte(':') 25 | _ = buf.WriteByte(' ') 26 | _, _ = buf.WriteString(value) 27 | _ = buf.WriteByte('\n') 28 | }) 29 | 30 | if !c.ReqIsStream && len(c.ReqBodyRaw) > 0 { 31 | _ = buf.WriteByte('\n') 32 | _, _ = buf.Write(c.ReqBodyRaw) 33 | _ = buf.WriteByte('\n') 34 | } 35 | 36 | _ = buf.WriteByte('\n') 37 | 38 | // TODO: print text repr of status code 39 | _, _ = buf.WriteString(fmt.Sprintf("%d %s", c.Res.Status(), "")) 40 | _ = buf.WriteByte('\n') 41 | 42 | c.Res.RangeHeaders(func(key string, value string) { 43 | _, _ = buf.WriteString(key) 44 | _ = buf.WriteByte(':') 45 | _ = buf.WriteByte(' ') 46 | _, _ = buf.WriteString(value) 47 | _ = buf.WriteByte('\n') 48 | }) 49 | 50 | if len(c.BodyRaw) > 0 { 51 | _, _ = buf.Write(c.BodyRaw) 52 | } 53 | 54 | _ = buf.WriteByte('\n') 55 | 56 | _ = buf.Flush() 57 | } 58 | -------------------------------------------------------------------------------- /codec/constants.go: -------------------------------------------------------------------------------- 1 | package codec 2 | 3 | import "github.com/pkg/errors" 4 | 5 | var ( 6 | NativeJSONCodec = NewNativeJsonCodec() 7 | NativeJSONEachRowCodec = NewNativeJsonEachRowCodec(NativeJSONCodec) 8 | ProxyBytesEncoder = ProxyBytesCodec{} 9 | ) 10 | 11 | var ( 12 | ErrTypeAssertion = errors.New("unexpected type") 13 | ) 14 | -------------------------------------------------------------------------------- /codec/json.go: -------------------------------------------------------------------------------- 1 | package codec 2 | 3 | import "encoding/json" 4 | 5 | type ( 6 | NativeJsonCodec struct{} 7 | ) 8 | 9 | func (c NativeJsonCodec) Encode(t any) ([]byte, error) { 10 | return json.Marshal(t) 11 | } 12 | 13 | func (c NativeJsonCodec) Decode(data []byte, item any) (err error) { 14 | err = json.Unmarshal(data, item) 15 | return 16 | } 17 | 18 | func NewNativeJsonCodec() NativeJsonCodec { 19 | return NativeJsonCodec{} 20 | } 21 | -------------------------------------------------------------------------------- /codec/json_each_row.go: -------------------------------------------------------------------------------- 1 | package codec 2 | 3 | const ( 4 | LN = byte('\n') 5 | ) 6 | 7 | type ( 8 | NativeJsonEachRowCodec struct { 9 | NativeJsonCodec 10 | } 11 | ) 12 | 13 | func (c NativeJsonEachRowCodec) Encode(t any) (bts []byte, err error) { 14 | bts, err = c.NativeJsonCodec.Encode(t) 15 | if err != nil { 16 | return 17 | } 18 | 19 | bts = append(bts, LN) 20 | return 21 | } 22 | 23 | func NewNativeJsonEachRowCodec(inner NativeJsonCodec) NativeJsonEachRowCodec { 24 | return NativeJsonEachRowCodec{NativeJsonCodec: inner} 25 | } 26 | -------------------------------------------------------------------------------- /codec/noop.go: -------------------------------------------------------------------------------- 1 | package codec 2 | 3 | type ( 4 | Encoder interface { 5 | Encode(any) ([]byte, error) 6 | } 7 | 8 | Decoder interface { 9 | Decode([]byte, any) error 10 | } 11 | 12 | Codec interface { 13 | Encoder 14 | Decoder 15 | } 16 | 17 | NoopCodec struct{} 18 | ) 19 | 20 | func (c NoopCodec) Encode(_ any) (bts []byte, err error) { return } 21 | func (c NoopCodec) Decode(_ []byte, _ any) (err error) { return } 22 | -------------------------------------------------------------------------------- /codec/proxy_bytes.go: -------------------------------------------------------------------------------- 1 | package codec 2 | 3 | import "github.com/pkg/errors" 4 | 5 | type ( 6 | ProxyBytesCodec struct{} 7 | ) 8 | 9 | func (e ProxyBytesCodec) Encode(x any) ([]byte, error) { 10 | bts, ok := x.([]byte) 11 | if !ok { 12 | return nil, errors.Wrapf(ErrTypeAssertion, "want '[]byte', have %T", x) 13 | } 14 | 15 | return bts, nil 16 | } 17 | 18 | func (e ProxyBytesCodec) Decode(_ []byte, _ any) error { 19 | panic("not implemented") 20 | } 21 | -------------------------------------------------------------------------------- /content_types.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "github.com/sonirico/withttp/codec" 6 | ) 7 | 8 | var ( 9 | ContentTypeJSON string = "application/json" 10 | ContentTypeJSONEachRow string = "application/jsoneachrow" 11 | ) 12 | 13 | var ( 14 | ErrUnknownContentType = errors.New("unknown content type") 15 | ) 16 | 17 | func ContentTypeCodec(c string) (codec.Codec, error) { 18 | switch c { 19 | case ContentTypeJSON: 20 | return codec.NativeJSONCodec, nil 21 | case ContentTypeJSONEachRow: 22 | return codec.NativeJSONEachRowCodec, nil 23 | default: 24 | return nil, errors.Wrapf(ErrUnknownContentType, "got: '%s'", c) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /csvparser/columns.go: -------------------------------------------------------------------------------- 1 | package csvparser 2 | 3 | type ( 4 | Col[T any] interface { 5 | Parse(data []byte, item *T) (int, error) 6 | //Compile(x T, writer io.Writer) error 7 | } 8 | 9 | opts struct { 10 | sep byte 11 | } 12 | 13 | ColFactory[T any] func(opts) Col[T] 14 | 15 | StringColumn[T any] struct { 16 | inner StringType 17 | setter func(x *T, v string) 18 | getter func(x T) string 19 | } 20 | 21 | IntColumn[T any] struct { 22 | inner IntegerType 23 | setter func(x *T, v int) 24 | getter func(x T) int 25 | } 26 | 27 | BoolColumn[T any] struct { 28 | inner StringType 29 | setter func(x T, v bool) 30 | getter func(x T) bool 31 | } 32 | ) 33 | 34 | func (s StringColumn[T]) Parse(data []byte, item *T) (int, error) { 35 | val, n, err := s.inner.Parse(data) 36 | if err != nil { 37 | return n, err 38 | } 39 | s.setter(item, val) 40 | return n, nil 41 | } 42 | 43 | func (c IntColumn[T]) Parse(data []byte, item *T) (int, error) { 44 | val, n, err := c.inner.Parse(data) 45 | if err != nil { 46 | return n, err 47 | } 48 | c.setter(item, val) 49 | return n, nil 50 | } 51 | 52 | func StringCol[T any]( 53 | quote byte, 54 | getter func(T) string, 55 | setter func(*T, string), 56 | ) ColFactory[T] { 57 | return func(opts opts) Col[T] { 58 | return StringColumn[T]{ 59 | inner: StrType(quote, opts.sep), 60 | getter: getter, setter: setter, 61 | } 62 | } 63 | } 64 | 65 | func IntCol[T any]( 66 | quote byte, 67 | getter func(T) int, 68 | setter func(*T, int), 69 | ) ColFactory[T] { 70 | return func(opts opts) Col[T] { 71 | return IntColumn[T]{ 72 | inner: IntType(quote, opts.sep), 73 | getter: getter, setter: setter, 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /csvparser/errors.go: -------------------------------------------------------------------------------- 1 | package csvparser 2 | 3 | import "github.com/pkg/errors" 4 | 5 | var ( 6 | ErrColumnMismatch = errors.New("column mismatch") 7 | ErrQuoteExpected = errors.New("quote was expected") 8 | ) 9 | -------------------------------------------------------------------------------- /csvparser/parser.go: -------------------------------------------------------------------------------- 1 | package csvparser 2 | 3 | import ( 4 | "bytes" 5 | ) 6 | 7 | var ( 8 | quote = []byte{byte('"')} 9 | 10 | QuoteDouble byte = '"' 11 | QuoteSimple byte = '\'' 12 | QuoteNone byte = 0 13 | 14 | SeparatorComma byte = ',' 15 | SeparatorSemicolon byte = ';' 16 | SeparatorTab byte = '\t' 17 | ) 18 | 19 | type ( 20 | Parser[T any] struct { 21 | separator byte 22 | columns []Col[T] 23 | } 24 | ) 25 | 26 | func (p Parser[T]) Parse(data []byte, item *T) (err error) { 27 | data = bytes.TrimSpace(data) // cleanup phase 28 | sepLen := 1 // len(p.separator) 29 | 30 | for i, col := range p.columns { 31 | var read int 32 | read, err = col.Parse(data, item) 33 | if err != nil { 34 | return 35 | } 36 | 37 | // TODO: handle read =0 38 | _ = i 39 | 40 | if read > len(data) { 41 | break 42 | } 43 | 44 | // create a cursor to have better readability under the fact the column types will only parse 45 | // its desired data, letting the parser have the liability to advance de cursor. 46 | cursor := read 47 | if read+sepLen <= len(data) { 48 | cursor += sepLen 49 | } 50 | 51 | data = data[cursor:] 52 | } 53 | return nil 54 | } 55 | 56 | func New[T any](sep byte, cols ...ColFactory[T]) Parser[T] { 57 | columns := make([]Col[T], len(cols)) 58 | opt := opts{sep: sep} 59 | 60 | for i, c := range cols { 61 | columns[i] = c(opt) 62 | } 63 | return Parser[T]{separator: sep, columns: columns} 64 | } 65 | -------------------------------------------------------------------------------- /csvparser/parser_test.go: -------------------------------------------------------------------------------- 1 | package csvparser 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | func TestParser_RawColumn(t *testing.T) { 11 | type ( 12 | args struct { 13 | payload []byte 14 | sep byte 15 | } 16 | 17 | duck struct { 18 | Name string 19 | Siblings int 20 | } 21 | 22 | want struct { 23 | expected duck 24 | err error 25 | } 26 | 27 | testCase struct { 28 | name string 29 | args args 30 | want want 31 | } 32 | ) 33 | 34 | var ( 35 | duckNameSetter = func(d *duck, name string) { 36 | d.Name = name 37 | } 38 | duckSiblingsSetter = func(d *duck, siblings int) { 39 | d.Siblings = siblings 40 | } 41 | ) 42 | 43 | tests := []testCase{ 44 | { 45 | name: "simple csv string line should parse", 46 | args: args{ 47 | payload: []byte("a duck knight in shinny armor,2"), 48 | sep: SeparatorComma, 49 | }, 50 | want: want{ 51 | expected: duck{ 52 | Name: "a duck knight in shinny armor", 53 | Siblings: 2, 54 | }, 55 | }, 56 | }, 57 | { 58 | name: "simple csv string line with trailing separator should parse", 59 | args: args{ 60 | payload: []byte("a duck knight in shinny armor,2,"), 61 | sep: SeparatorComma, 62 | }, 63 | want: want{ 64 | expected: duck{ 65 | Name: "a duck knight in shinny armor", 66 | Siblings: 2, 67 | }, 68 | }, 69 | }, 70 | { 71 | name: "simple csv string line with trailing separator and spaces should parse", 72 | args: args{ 73 | payload: []byte("a duck knight in shinny armor,2, "), 74 | sep: SeparatorComma, 75 | }, 76 | want: want{ 77 | expected: duck{ 78 | Name: "a duck knight in shinny armor", 79 | Siblings: 2, 80 | }, 81 | }, 82 | }, 83 | { 84 | name: "simple csv string line with trailing spaces at the start and spaces should parse", 85 | args: args{ 86 | payload: []byte(" a duck knight in shinny armor,2, "), 87 | sep: SeparatorComma, 88 | }, 89 | want: want{ 90 | expected: duck{ 91 | Name: "a duck knight in shinny armor", 92 | Siblings: 2, 93 | }, 94 | }, 95 | }, 96 | { 97 | name: "blank column should render emptiness", 98 | args: args{ 99 | payload: []byte(",2"), 100 | sep: SeparatorComma, 101 | }, 102 | want: want{ 103 | expected: duck{ 104 | Name: "", 105 | Siblings: 2, 106 | }, 107 | }, 108 | }, 109 | } 110 | 111 | for _, test := range tests { 112 | t.Run(test.name, func(t *testing.T) { 113 | 114 | parser := New[duck]( 115 | test.args.sep, 116 | StringCol[duck](QuoteNone, nil, duckNameSetter), 117 | IntCol[duck](QuoteNone, nil, duckSiblingsSetter), 118 | ) 119 | 120 | rubberDuck := duck{} 121 | 122 | if err := parser.Parse(test.args.payload, &rubberDuck); !errors.Is(test.want.err, err) { 123 | t.Errorf("unexpected error, want %v, have %v", 124 | test.want.err, err) 125 | } 126 | 127 | if !reflect.DeepEqual(test.want.expected, rubberDuck) { 128 | t.Errorf("unexpected duck\nwant %v\nhave %v", 129 | test.want.expected, rubberDuck) 130 | } 131 | 132 | }) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /csvparser/types.go: -------------------------------------------------------------------------------- 1 | package csvparser 2 | 3 | import ( 4 | "io" 5 | "strconv" 6 | 7 | "github.com/pkg/errors" 8 | "github.com/sonirico/vago/slices" 9 | ) 10 | 11 | type ( 12 | Type[T any] interface { 13 | Parse(data []byte) (T, int, error) 14 | //Compile(x T, writer io.Writer) error 15 | } 16 | 17 | StringType struct { 18 | sep byte 19 | quote byte 20 | } 21 | 22 | IntegerType struct { 23 | inner StringType 24 | } 25 | ) 26 | 27 | // Parse parses `data`, which is ensured to be non-nil and its length greater than zero 28 | func (s StringType) Parse(data []byte) (string, int, error) { 29 | if s.quote != QuoteNone { 30 | if data[0] != s.quote { 31 | return "", 0, errors.Wrapf(ErrQuoteExpected, "<%s>", string(s.quote)) 32 | } 33 | 34 | i := 3 35 | // Find the next non-escaped quote 36 | for i < len(data) { 37 | prev := data[i-2] 38 | middle := data[i-1] 39 | next := data[i] 40 | if middle == s.quote && prev != '\\' && next == s.sep { 41 | break 42 | } 43 | i++ 44 | } 45 | 46 | payload := data[1 : i-1] 47 | return string(payload), len(payload), nil 48 | } 49 | 50 | payload := data 51 | 52 | idx := slices.IndexOf(data, func(x byte) bool { return x == s.sep }) 53 | if idx > -1 { 54 | // next separator has not been found. End of line? 55 | payload = data[:idx] 56 | } 57 | 58 | return string(payload), len(payload), nil 59 | } 60 | 61 | func (s StringType) Compile(data []byte, w io.Writer) error { 62 | if s.quote != QuoteNone { 63 | 64 | n, err := w.Write(quote) 65 | if err != nil || n < 1 { 66 | // todo: handle 67 | } 68 | } 69 | 70 | n, err := w.Write(data) 71 | 72 | if err != nil || n < 1 { 73 | // todo: handle 74 | } 75 | 76 | if s.quote != QuoteNone { 77 | n, err := w.Write(quote) 78 | if err != nil || n < 1 { 79 | // todo: handle 80 | } 81 | } 82 | return nil 83 | } 84 | 85 | func (i IntegerType) Parse(data []byte) (int, int, error) { 86 | val, n, err := i.inner.Parse(data) 87 | if err != nil { 88 | return 0, n, err 89 | } 90 | 91 | var res int64 92 | res, err = strconv.ParseInt(val, 10, 64) 93 | return int(res), n, err 94 | } 95 | 96 | func StrType(quote, sep byte) StringType { 97 | return StringType{quote: quote, sep: sep} 98 | } 99 | 100 | func IntType(quote, sep byte) IntegerType { 101 | return IntegerType{inner: StrType(quote, sep)} 102 | } 103 | -------------------------------------------------------------------------------- /csvparser/types_test.go: -------------------------------------------------------------------------------- 1 | package csvparser 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | func TestStringType_Parse(t *testing.T) { 11 | type ( 12 | args struct { 13 | payload []byte 14 | quote byte 15 | sep byte 16 | } 17 | 18 | want struct { 19 | expected []byte 20 | read int 21 | err error 22 | } 23 | 24 | testCase struct { 25 | name string 26 | args args 27 | want want 28 | } 29 | ) 30 | 31 | tests := []testCase{ 32 | { 33 | name: "unquoted simple string", 34 | args: args{ 35 | quote: QuoteNone, 36 | sep: SeparatorComma, 37 | payload: []byte("fmartingr,danirod_,3"), 38 | }, 39 | want: want{ 40 | expected: []byte("fmartingr"), 41 | read: 9, 42 | err: nil, 43 | }, 44 | }, 45 | { 46 | name: "double quote simple string", 47 | args: args{ 48 | quote: QuoteDouble, 49 | sep: SeparatorComma, 50 | payload: []byte("\"fmartingr\",danirod_,3"), 51 | }, 52 | want: want{ 53 | expected: []byte("fmartingr"), 54 | read: 9, 55 | err: nil, 56 | }, 57 | }, 58 | { 59 | name: "simple quote simple string", 60 | args: args{ 61 | quote: QuoteSimple, 62 | sep: SeparatorComma, 63 | payload: []byte("'fmartingr',danirod_,3"), 64 | }, 65 | want: want{ 66 | expected: []byte("fmartingr"), 67 | read: 9, 68 | err: nil, 69 | }, 70 | }, 71 | { 72 | name: "non quote non-ascii string", 73 | args: args{ 74 | quote: QuoteNone, 75 | sep: SeparatorComma, 76 | payload: []byte("你好吗,danirod_,3"), 77 | }, 78 | want: want{ 79 | expected: []byte("你好吗"), 80 | read: 9, 81 | err: nil, 82 | }, 83 | }, 84 | { 85 | name: "double quote non-ascii string", 86 | args: args{ 87 | quote: QuoteDouble, 88 | sep: SeparatorComma, 89 | payload: []byte("\"你好吗\",danirod_,3"), 90 | }, 91 | want: want{ 92 | expected: []byte("你好吗"), 93 | read: 9, 94 | err: nil, 95 | }, 96 | }, 97 | { 98 | name: "double quote non-ascii string with escaped char same as quote", 99 | args: args{ 100 | quote: QuoteDouble, 101 | sep: SeparatorComma, 102 | payload: []byte("\"你\\\"好吗\",danirod_,3"), 103 | }, 104 | want: want{ 105 | expected: []byte("你\\\"好吗"), 106 | read: 11, 107 | err: nil, 108 | }, 109 | }, 110 | { 111 | name: "double quote non-ascii string with escaped char same as quote and other char same as separator", 112 | args: args{ 113 | quote: QuoteDouble, 114 | sep: SeparatorComma, 115 | payload: []byte("\"你\\\"好,吗\",danirod_,3"), 116 | }, 117 | want: want{ 118 | expected: []byte("你\\\"好,吗"), 119 | read: 12, 120 | err: nil, 121 | }, 122 | }, 123 | { 124 | name: "simple quoted json", 125 | args: args{ 126 | quote: QuoteSimple, 127 | sep: SeparatorComma, 128 | payload: []byte(`'{"name":"Pato","age":3}',danirod_,3`), 129 | }, 130 | want: want{ 131 | expected: []byte(`{"name":"Pato","age":3}`), 132 | read: 23, 133 | err: nil, 134 | }, 135 | }, 136 | } 137 | 138 | for _, test := range tests { 139 | t.Run(test.name, func(t *testing.T) { 140 | str := StrType(test.args.quote, test.args.sep) 141 | actual, read, actualErr := str.Parse(test.args.payload) 142 | if !errors.Is(test.want.err, actualErr) { 143 | t.Fatalf("unexpected error. want %v, have %v", 144 | test.want.err, actualErr) 145 | } 146 | 147 | if test.want.read != read { 148 | t.Fatalf("unexpected bytes read, want %d have %d", 149 | test.want.read, read) 150 | } 151 | 152 | if strings.Compare(string(test.want.expected), actual) != 0 { 153 | t.Fatalf("unexpected result. want %v, have %v", 154 | string(test.want.expected), actual) 155 | } 156 | }) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /endpoint.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/url" 7 | ) 8 | 9 | type ( 10 | header interface { 11 | SetHeader(k, v string) 12 | AddHeader(k, v string) 13 | Header(k string) (string, bool) 14 | RangeHeaders(func(string, string)) 15 | } 16 | 17 | Request interface { 18 | header 19 | 20 | Method() string 21 | SetMethod(string) 22 | 23 | SetURL(*url.URL) 24 | // SetBodyStream sets the stream of body data belonging to a request. bodySize parameter is needed 25 | // when using fasthttp implementation. 26 | SetBodyStream(rc io.ReadWriteCloser, bodySize int) 27 | SetBody([]byte) 28 | 29 | Body() []byte 30 | BodyStream() io.ReadWriteCloser 31 | 32 | URL() *url.URL 33 | } 34 | 35 | Response interface { 36 | header 37 | 38 | Status() int 39 | StatusText() string 40 | Body() io.ReadCloser 41 | 42 | SetBody(rc io.ReadCloser) 43 | SetStatus(status int) 44 | } 45 | 46 | Client interface { 47 | Request(ctx context.Context) (Request, error) 48 | Do(ctx context.Context, req Request) (Response, error) 49 | } 50 | 51 | Endpoint struct { 52 | name string 53 | 54 | requestOpts []ReqOption 55 | 56 | responseOpts []ResOption 57 | } 58 | 59 | MockEndpoint struct{} 60 | 61 | ReqOption interface { 62 | Configure(r Request) error 63 | } 64 | 65 | ReqOptionFunc func(req Request) error 66 | 67 | ResOption interface { 68 | Parse(r Response) error 69 | } 70 | 71 | ResOptionFunc func(res Response) error 72 | ) 73 | 74 | func (f ResOptionFunc) Parse(res Response) error { 75 | return f(res) 76 | } 77 | 78 | func (f ReqOptionFunc) Configure(req Request) error { 79 | return f(req) 80 | } 81 | 82 | func (e *Endpoint) Request(opts ...ReqOption) *Endpoint { 83 | e.requestOpts = append(e.requestOpts, opts...) 84 | return e 85 | } 86 | 87 | func (e *Endpoint) Response(opts ...ResOption) *Endpoint { 88 | e.responseOpts = append(e.responseOpts, opts...) 89 | return e 90 | } 91 | 92 | func NewEndpoint(name string) *Endpoint { 93 | return &Endpoint{name: name} 94 | } 95 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import "github.com/pkg/errors" 4 | 5 | var ( 6 | ErrAssertion = errors.New("assertion was unmet") 7 | ErrUnexpectedStatusCode = errors.Wrap(ErrAssertion, "unexpected status code") 8 | ErrInsufficientParams = errors.New("insufficient params") 9 | ) 10 | -------------------------------------------------------------------------------- /examples_fasthttp_test.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | var ( 14 | githubApi = NewEndpoint("GithubAPI"). 15 | Request(BaseURL("https://api.github.com/")) 16 | ) 17 | 18 | type githubRepoInfoFast struct { 19 | ID int `json:"id"` 20 | URL string `json:"html_url"` 21 | } 22 | 23 | type githubCreateIssueResponse struct { 24 | ID int `json:"id"` 25 | URL string `json:"url"` 26 | } 27 | 28 | func getRepoInfoFast(user, repo string) (githubRepoInfoFast, error) { 29 | call := NewCall[githubRepoInfoFast](Fasthttp()). 30 | URI(fmt.Sprintf("repos/%s/%s", user, repo)). 31 | Method(http.MethodGet). 32 | Header("User-Agent", "withttp/0.5.1 See https://github.com/sonirico/withttp", false). 33 | HeaderFunc(func() (key, value string, override bool) { 34 | key = "X-Date" 35 | value = time.Now().String() 36 | override = true 37 | return 38 | }). 39 | ParseJSON(). 40 | ExpectedStatusCodes(http.StatusOK) 41 | 42 | err := call.CallEndpoint(context.Background(), githubApi) 43 | 44 | return call.BodyParsed, err 45 | } 46 | 47 | func createRepoIssue(user, repo, title, body, assignee string) (githubCreateIssueResponse, error) { 48 | type payload struct { 49 | Title string `json:"title"` 50 | Body string `json:"body"` 51 | Assignee string `json:"assignee"` 52 | } 53 | 54 | p := payload{ 55 | Title: title, 56 | Body: body, 57 | Assignee: assignee, 58 | } 59 | 60 | call := NewCall[githubCreateIssueResponse](Fasthttp()). 61 | URI(fmt.Sprintf("repos/%s/%s/issues", user, repo)). 62 | Method(http.MethodPost). 63 | ContentType(ContentTypeJSON). 64 | Body(p). 65 | HeaderFunc(func() (key, value string, override bool) { 66 | key = "Authorization" 67 | value = fmt.Sprintf("Bearer %s", "S3cret") 68 | override = true 69 | return 70 | }). 71 | ExpectedStatusCodes(http.StatusCreated) 72 | 73 | err := call.CallEndpoint(context.Background(), githubApi) 74 | 75 | return call.BodyParsed, err 76 | } 77 | 78 | func TestFastHTTP_GetRepoInfo(t *testing.T) { 79 | t.Skip("Skipping live API test - enable manually for integration testing") 80 | 81 | info, err := getRepoInfoFast("sonirico", "withttp") 82 | if err != nil { 83 | t.Fatalf("Failed to get repo info: %v", err) 84 | } 85 | 86 | if info.ID == 0 { 87 | t.Error("Expected repo ID to be non-zero") 88 | } 89 | 90 | if info.URL == "" { 91 | t.Error("Expected repo URL to be non-empty") 92 | } 93 | 94 | t.Logf("Repo info: ID=%d, URL=%s", info.ID, info.URL) 95 | } 96 | 97 | func TestFastHTTP_MockedExample(t *testing.T) { 98 | mockEndpoint := NewEndpoint("MockGithubAPI"). 99 | Request(BaseURL("http://example.com")). 100 | Response( 101 | MockedRes(func(res Response) { 102 | res.SetBody( 103 | io.NopCloser( 104 | strings.NewReader( 105 | `{"id": 67890, "html_url": "https://github.com/sonirico/withttp"}`, 106 | ), 107 | ), 108 | ) 109 | res.SetStatus(http.StatusOK) 110 | }), 111 | ) 112 | 113 | call := NewCall[githubRepoInfoFast](Fasthttp()). 114 | URI("repos/sonirico/withttp"). 115 | Method(http.MethodGet). 116 | Header("User-Agent", "withttp/0.5.1 See https://github.com/sonirico/withttp", false). 117 | HeaderFunc(func() (key, value string, override bool) { 118 | key = "X-Date" 119 | value = time.Now().String() 120 | override = true 121 | return 122 | }). 123 | ParseJSON(). 124 | ExpectedStatusCodes(http.StatusOK) 125 | 126 | err := call.CallEndpoint(context.Background(), mockEndpoint) 127 | if err != nil { 128 | t.Fatalf("Failed to call mocked endpoint: %v", err) 129 | } 130 | 131 | if call.BodyParsed.ID != 67890 { 132 | t.Errorf("Expected ID 67890, got %d", call.BodyParsed.ID) 133 | } 134 | 135 | if call.BodyParsed.URL != "https://github.com/sonirico/withttp" { 136 | t.Errorf("Expected URL 'https://github.com/sonirico/withttp', got %s", call.BodyParsed.URL) 137 | } 138 | } 139 | 140 | func TestFastHTTP_CreateIssue(t *testing.T) { 141 | t.Skip("Skipping live API test - enable manually for integration testing") 142 | 143 | res, err := createRepoIssue("sonirico", "withttp", "test", "This is a test", "sonirico") 144 | if err != nil { 145 | t.Fatalf("Failed to create issue: %v", err) 146 | } 147 | 148 | if res.ID == 0 { 149 | t.Error("Expected issue ID to be non-zero") 150 | } 151 | 152 | t.Logf("Issue created: ID=%d, URL=%s", res.ID, res.URL) 153 | } 154 | 155 | func TestFastHTTP_MockedCreateIssue(t *testing.T) { 156 | mockEndpoint := NewEndpoint("MockGithubAPI"). 157 | Request(BaseURL("http://example.com")). 158 | Response( 159 | MockedRes(func(res Response) { 160 | res.SetBody( 161 | io.NopCloser( 162 | strings.NewReader( 163 | `{"id": 999, "url": "https://api.github.com/repos/sonirico/withttp/issues/999"}`, 164 | ), 165 | ), 166 | ) 167 | res.SetStatus(http.StatusCreated) 168 | }), 169 | ) 170 | 171 | call := NewCall[githubCreateIssueResponse](Fasthttp()). 172 | URI("repos/sonirico/withttp/issues"). 173 | Method(http.MethodPost). 174 | ContentType(ContentTypeJSON). 175 | Body(map[string]string{ 176 | "title": "test issue", 177 | "body": "test body", 178 | "assignee": "sonirico", 179 | }). 180 | HeaderFunc(func() (key, value string, override bool) { 181 | key = "Authorization" 182 | value = "Bearer S3cret" 183 | override = true 184 | return 185 | }). 186 | ParseJSON(). 187 | ExpectedStatusCodes(http.StatusCreated) 188 | 189 | err := call.CallEndpoint(context.Background(), mockEndpoint) 190 | if err != nil { 191 | t.Fatalf("Failed to call mocked endpoint: %v", err) 192 | } 193 | 194 | if call.BodyParsed.ID != 999 { 195 | t.Errorf("Expected ID 999, got %d", call.BodyParsed.ID) 196 | } 197 | 198 | expectedURL := "https://api.github.com/repos/sonirico/withttp/issues/999" 199 | if call.BodyParsed.URL != expectedURL { 200 | t.Errorf("Expected URL '%s', got %s", expectedURL, call.BodyParsed.URL) 201 | } 202 | } 203 | 204 | // Example_fasthttp_basicCall demonstrates how to make a simple HTTP call using the FastHTTP adapter. 205 | func Example_fasthttp_basicCall() { 206 | type RepoInfo struct { 207 | ID int `json:"id"` 208 | Name string `json:"name"` 209 | } 210 | 211 | // Create a mocked endpoint for the example 212 | endpoint := NewEndpoint("GithubAPI"). 213 | Request(BaseURL("https://api.github.com")). 214 | Response( 215 | MockedRes(func(res Response) { 216 | res.SetBody(io.NopCloser(strings.NewReader(`{"id": 12345, "name": "withttp"}`))) 217 | res.SetStatus(http.StatusOK) 218 | }), 219 | ) 220 | 221 | call := NewCall[RepoInfo](NewMockHttpClientAdapter()). 222 | URI("/repos/user/repo"). 223 | Method(http.MethodGet). 224 | Header("User-Agent", "withttp-example", false). 225 | ParseJSON(). 226 | ExpectedStatusCodes(http.StatusOK) 227 | 228 | err := call.CallEndpoint(context.Background(), endpoint) 229 | if err != nil { 230 | fmt.Printf("Error: %v\n", err) 231 | return 232 | } 233 | 234 | fmt.Printf("Repo ID: %d, Name: %s\n", call.BodyParsed.ID, call.BodyParsed.Name) 235 | // Output: Repo ID: 12345, Name: withttp 236 | } 237 | 238 | // Example_fasthttp_postRequest demonstrates how to make a POST request with JSON body using FastHTTP. 239 | func Example_fasthttp_postRequest() { 240 | type CreateRequest struct { 241 | Title string `json:"title"` 242 | Body string `json:"body"` 243 | } 244 | 245 | type CreateResponse struct { 246 | ID int `json:"id"` 247 | URL string `json:"url"` 248 | } 249 | 250 | // Create a mocked endpoint for the example 251 | endpoint := NewEndpoint("API"). 252 | Request(BaseURL("https://api.example.com")). 253 | Response( 254 | MockedRes(func(res Response) { 255 | res.SetBody( 256 | io.NopCloser( 257 | strings.NewReader(`{"id": 42, "url": "https://api.example.com/items/42"}`), 258 | ), 259 | ) 260 | res.SetStatus(http.StatusCreated) 261 | }), 262 | ) 263 | 264 | payload := CreateRequest{ 265 | Title: "Example Item", 266 | Body: "This is an example", 267 | } 268 | 269 | call := NewCall[CreateResponse](NewMockHttpClientAdapter()). 270 | URI("/items"). 271 | Method(http.MethodPost). 272 | ContentType(ContentTypeJSON). 273 | Body(payload). 274 | Header("Authorization", "Bearer token", true). 275 | ParseJSON(). 276 | ExpectedStatusCodes(http.StatusCreated) 277 | 278 | err := call.CallEndpoint(context.Background(), endpoint) 279 | if err != nil { 280 | fmt.Printf("Error: %v\n", err) 281 | return 282 | } 283 | 284 | fmt.Printf("Created item ID: %d\n", call.BodyParsed.ID) 285 | // Output: Created item ID: 42 286 | } 287 | 288 | // Example_fasthttp_withHeaderFunc demonstrates using header functions with FastHTTP. 289 | func Example_fasthttp_withHeaderFunc() { 290 | type APIResponse struct { 291 | Message string `json:"message"` 292 | } 293 | 294 | endpoint := NewEndpoint("API"). 295 | Request(BaseURL("https://api.example.com")). 296 | Response( 297 | MockedRes(func(res Response) { 298 | res.SetBody(io.NopCloser(strings.NewReader(`{"message": "Hello"}`))) 299 | res.SetStatus(http.StatusOK) 300 | }), 301 | ) 302 | 303 | call := NewCall[APIResponse](NewMockHttpClientAdapter()). 304 | URI("/hello"). 305 | Method(http.MethodGet). 306 | HeaderFunc(func() (key, value string, override bool) { 307 | key = "X-Timestamp" 308 | value = "2025-06-29T12:00:00Z" 309 | override = true 310 | return 311 | }). 312 | HeaderFunc(func() (key, value string, override bool) { 313 | key = "X-Request-ID" 314 | value = "req-12345" 315 | override = true 316 | return 317 | }). 318 | ParseJSON(). 319 | ExpectedStatusCodes(http.StatusOK) 320 | 321 | err := call.CallEndpoint(context.Background(), endpoint) 322 | if err != nil { 323 | fmt.Printf("Error: %v\n", err) 324 | return 325 | } 326 | 327 | fmt.Printf("Response: %s\n", call.BodyParsed.Message) 328 | // Output: Response: Hello 329 | } 330 | -------------------------------------------------------------------------------- /examples_mock_test.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | var ( 14 | exchangeListOrders = NewEndpoint("ListOrders"). 15 | Request(BaseURL("http://example.com")). 16 | Response( 17 | MockedRes(func(res Response) { 18 | res.SetBody(io.NopCloser(strings.NewReader(mockResponse))) 19 | res.SetStatus(http.StatusOK) 20 | }), 21 | ) 22 | mockResponse = `{"amount": 234, "pair": "BTC/USDT"} 23 | {"amount": 123, "pair": "ETH/USDT"}` 24 | ) 25 | 26 | type Order struct { 27 | Amount float64 `json:"amount"` 28 | Pair string `json:"pair"` 29 | } 30 | 31 | func TestMockEndpoint_ParseJSONEachRowChan(t *testing.T) { 32 | res := make(chan Order) 33 | orders := make([]Order, 0) 34 | 35 | // Collect orders from channel 36 | go func() { 37 | for order := range res { 38 | orders = append(orders, order) 39 | } 40 | }() 41 | 42 | call := NewCall[Order](Fasthttp()). 43 | Method(http.MethodGet). 44 | BasicAuth("pepito", "secret"). 45 | Header("User-Agent", "withttp/0.5.1 See https://github.com/sonirico/withttp", false). 46 | HeaderFunc(func() (key, value string, override bool) { 47 | key = "X-Date" 48 | value = time.Now().String() 49 | override = true 50 | return 51 | }). 52 | ParseJSONEachRowChan(res). 53 | ExpectedStatusCodes(http.StatusOK) 54 | 55 | err := call.CallEndpoint(context.Background(), exchangeListOrders) 56 | if err != nil { 57 | t.Fatalf("Failed to call endpoint: %v", err) 58 | } 59 | 60 | // Wait a bit for goroutine to finish 61 | time.Sleep(100 * time.Millisecond) 62 | 63 | if len(orders) != 2 { 64 | t.Errorf("Expected 2 orders, got %d", len(orders)) 65 | } 66 | 67 | if len(orders) >= 1 { 68 | if orders[0].Amount != 234 || orders[0].Pair != "BTC/USDT" { 69 | t.Errorf("First order incorrect: got %+v", orders[0]) 70 | } 71 | } 72 | 73 | if len(orders) >= 2 { 74 | if orders[1].Amount != 123 || orders[1].Pair != "ETH/USDT" { 75 | t.Errorf("Second order incorrect: got %+v", orders[1]) 76 | } 77 | } 78 | 79 | // Check authorization header was set 80 | authHeader, hasAuth := call.Req.Header("Authorization") 81 | if !hasAuth { 82 | t.Error("Expected Authorization header to be set") 83 | } else { 84 | t.Logf("Authorization header: %s", authHeader) 85 | } 86 | } 87 | 88 | func TestMockEndpoint_ParseJSONEachRow(t *testing.T) { 89 | orders := make([]Order, 0) 90 | 91 | call := NewCall[Order](Fasthttp()). 92 | Method(http.MethodGet). 93 | BasicAuth("pepito", "secret"). 94 | Header("User-Agent", "withttp/0.5.1 See https://github.com/sonirico/withttp", false). 95 | HeaderFunc(func() (key, value string, override bool) { 96 | key = "X-Date" 97 | value = time.Now().String() 98 | override = true 99 | return 100 | }). 101 | ParseJSONEachRow(func(order Order) bool { 102 | orders = append(orders, order) 103 | return true // continue processing 104 | }). 105 | ExpectedStatusCodes(http.StatusOK) 106 | 107 | err := call.CallEndpoint(context.Background(), exchangeListOrders) 108 | if err != nil { 109 | t.Fatalf("Failed to call endpoint: %v", err) 110 | } 111 | 112 | if len(orders) != 2 { 113 | t.Errorf("Expected 2 orders, got %d", len(orders)) 114 | } 115 | 116 | if len(orders) >= 1 { 117 | if orders[0].Amount != 234 || orders[0].Pair != "BTC/USDT" { 118 | t.Errorf("First order incorrect: got %+v", orders[0]) 119 | } 120 | } 121 | 122 | if len(orders) >= 2 { 123 | if orders[1].Amount != 123 || orders[1].Pair != "ETH/USDT" { 124 | t.Errorf("Second order incorrect: got %+v", orders[1]) 125 | } 126 | } 127 | } 128 | 129 | // Example_mockEndpoint demonstrates how to create and use a mocked endpoint for testing. 130 | func Example_mockEndpoint() { 131 | type Order struct { 132 | Amount float64 `json:"amount"` 133 | Pair string `json:"pair"` 134 | } 135 | 136 | // Create a mocked endpoint that returns test data 137 | mockEndpoint := NewEndpoint("MockExchange"). 138 | Request(BaseURL("http://example.com")). 139 | Response( 140 | MockedRes(func(res Response) { 141 | res.SetBody(io.NopCloser(strings.NewReader(`{"amount": 100.5, "pair": "BTC/USD"}`))) 142 | res.SetStatus(http.StatusOK) 143 | }), 144 | ) 145 | 146 | call := NewCall[Order](NewMockHttpClientAdapter()). 147 | Method(http.MethodGet). 148 | Header("Authorization", "Bearer token123", true). 149 | ParseJSON(). 150 | ExpectedStatusCodes(http.StatusOK) 151 | 152 | err := call.CallEndpoint(context.Background(), mockEndpoint) 153 | if err != nil { 154 | fmt.Printf("Error: %v\n", err) 155 | return 156 | } 157 | 158 | fmt.Printf("Order amount: %.1f, Pair: %s\n", call.BodyParsed.Amount, call.BodyParsed.Pair) 159 | // Output: Order amount: 100.5, Pair: BTC/USD 160 | } 161 | 162 | // Example_parseJSONEachRow demonstrates parsing JSON-each-row format responses. 163 | func Example_parseJSONEachRow() { 164 | type Trade struct { 165 | Price float64 `json:"price"` 166 | Volume float64 `json:"volume"` 167 | } 168 | 169 | mockData := `{"price": 50000.0, "volume": 1.5} 170 | {"price": 51000.0, "volume": 0.8}` 171 | 172 | mockEndpoint := NewEndpoint("TradesAPI"). 173 | Request(BaseURL("http://example.com")). 174 | Response( 175 | MockedRes(func(res Response) { 176 | res.SetBody(io.NopCloser(strings.NewReader(mockData))) 177 | res.SetStatus(http.StatusOK) 178 | }), 179 | ) 180 | 181 | var trades []Trade 182 | call := NewCall[Trade](NewMockHttpClientAdapter()). 183 | Method(http.MethodGet). 184 | ParseJSONEachRow(func(trade Trade) bool { 185 | trades = append(trades, trade) 186 | return true // continue processing 187 | }). 188 | ExpectedStatusCodes(http.StatusOK) 189 | 190 | err := call.CallEndpoint(context.Background(), mockEndpoint) 191 | if err != nil { 192 | fmt.Printf("Error: %v\n", err) 193 | return 194 | } 195 | 196 | fmt.Printf("Processed %d trades\n", len(trades)) 197 | if len(trades) > 0 { 198 | fmt.Printf("First trade: $%.0f, volume: %.1f\n", trades[0].Price, trades[0].Volume) 199 | } 200 | // Output: Processed 2 trades 201 | // First trade: $50000, volume: 1.5 202 | } 203 | -------------------------------------------------------------------------------- /examples_request_stream_test.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strings" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | type metric struct { 15 | Time time.Time `json:"t"` 16 | Temp float32 `json:"T"` 17 | } 18 | 19 | func TestRequestStream_FromSlice(t *testing.T) { 20 | points := []metric{ 21 | { 22 | Time: time.Unix(time.Now().Unix()-1, 0), 23 | Temp: 39, 24 | }, 25 | { 26 | Time: time.Now(), 27 | Temp: 40, 28 | }, 29 | } 30 | 31 | stream := Slice[metric](points) 32 | 33 | // Mock endpoint that captures the request body 34 | var receivedData []byte 35 | testEndpoint := NewEndpoint("webhook-site-request-stream-example"). 36 | Request(BaseURL("https://webhook.site/24e84e8f-75cf-4239-828e-8bed244c0afb")). 37 | Response( 38 | MockedRes(func(res Response) { 39 | res.SetStatus(http.StatusOK) 40 | res.SetBody(io.NopCloser(strings.NewReader(`{"status": "ok"}`))) 41 | }), 42 | ) 43 | 44 | call := NewCall[any](Fasthttp()). 45 | Method(http.MethodPost). 46 | ContentType(ContentTypeJSONEachRow). 47 | RequestSniffed(func(data []byte, err error) { 48 | receivedData = append(receivedData, data...) 49 | t.Logf("Received: '%s', err: %v", string(data), err) 50 | }). 51 | RequestStreamBody( 52 | RequestStreamBody[any, metric](stream), 53 | ). 54 | ExpectedStatusCodes(http.StatusOK) 55 | 56 | err := call.CallEndpoint(context.Background(), testEndpoint) 57 | if err != nil { 58 | t.Fatalf("Failed to call endpoint: %v", err) 59 | } 60 | 61 | if len(receivedData) == 0 { 62 | t.Error("Expected to receive some data from request sniffer") 63 | } 64 | 65 | // Check that we received JSON data 66 | dataStr := string(receivedData) 67 | if !strings.Contains(dataStr, `"T":39`) && !strings.Contains(dataStr, `"T":40`) { 68 | t.Errorf("Expected JSON data containing temperature values, got: %s", dataStr) 69 | } 70 | } 71 | 72 | func TestRequestStream_FromChannel(t *testing.T) { 73 | points := make(chan metric, 2) 74 | 75 | go func() { 76 | points <- metric{ 77 | Time: time.Unix(time.Now().Unix()-1, 0), 78 | Temp: 39, 79 | } 80 | 81 | points <- metric{ 82 | Time: time.Now(), 83 | Temp: 40, 84 | } 85 | 86 | close(points) 87 | }() 88 | 89 | stream := Channel[metric](points) 90 | 91 | var receivedData []byte 92 | testEndpoint := NewEndpoint("webhook-site-request-stream-example"). 93 | Request(BaseURL("https://webhook.site/24e84e8f-75cf-4239-828e-8bed244c0afb")). 94 | Response( 95 | MockedRes(func(res Response) { 96 | res.SetStatus(http.StatusOK) 97 | res.SetBody(io.NopCloser(strings.NewReader(`{"status": "ok"}`))) 98 | }), 99 | ) 100 | 101 | call := NewCall[any](Fasthttp()). 102 | Method(http.MethodPost). 103 | ContentType(ContentTypeJSONEachRow). 104 | RequestSniffed(func(data []byte, err error) { 105 | receivedData = append(receivedData, data...) 106 | t.Logf("Received: '%s', err: %v", string(data), err) 107 | }). 108 | RequestStreamBody( 109 | RequestStreamBody[any, metric](stream), 110 | ). 111 | ExpectedStatusCodes(http.StatusOK) 112 | 113 | err := call.CallEndpoint(context.Background(), testEndpoint) 114 | if err != nil { 115 | t.Fatalf("Failed to call endpoint: %v", err) 116 | } 117 | 118 | if len(receivedData) == 0 { 119 | t.Error("Expected to receive some data from request sniffer") 120 | } 121 | 122 | // Check that we received JSON data 123 | dataStr := string(receivedData) 124 | if !strings.Contains(dataStr, `"T":39`) && !strings.Contains(dataStr, `"T":40`) { 125 | t.Errorf("Expected JSON data containing temperature values, got: %s", dataStr) 126 | } 127 | } 128 | 129 | func TestRequestStream_FromReader(t *testing.T) { 130 | buf := bytes.NewBuffer(nil) 131 | 132 | go func() { 133 | buf.WriteString(`{"t":"2022-09-01T00:58:15+02:00","T":39}`) 134 | buf.WriteByte('\n') 135 | buf.WriteString(`{"t":"2022-09-01T00:59:15+02:00","T":40}`) 136 | buf.WriteByte('\n') 137 | }() 138 | 139 | streamFactory := NewProxyStreamFactory(1 << 10) 140 | stream := NewStreamFromReader(buf, streamFactory) 141 | 142 | var receivedData []byte 143 | testEndpoint := NewEndpoint("webhook-site-request-stream-example"). 144 | Request(BaseURL("https://webhook.site/24e84e8f-75cf-4239-828e-8bed244c0afb")). 145 | Response( 146 | MockedRes(func(res Response) { 147 | res.SetStatus(http.StatusOK) 148 | res.SetBody(io.NopCloser(strings.NewReader(`{"status": "ok"}`))) 149 | }), 150 | ) 151 | 152 | call := NewCall[any](NetHttp()). 153 | Method(http.MethodPost). 154 | RequestSniffed(func(data []byte, err error) { 155 | receivedData = append(receivedData, data...) 156 | t.Logf("Received: '%s', err: %v", string(data), err) 157 | }). 158 | ContentType(ContentTypeJSONEachRow). 159 | RequestStreamBody( 160 | RequestStreamBody[any, []byte](stream), 161 | ). 162 | ExpectedStatusCodes(http.StatusOK) 163 | 164 | err := call.CallEndpoint(context.Background(), testEndpoint) 165 | if err != nil { 166 | t.Fatalf("Failed to call endpoint: %v", err) 167 | } 168 | 169 | if len(receivedData) == 0 { 170 | t.Error("Expected to receive some data from request sniffer") 171 | } 172 | 173 | // Check that we received JSON data 174 | dataStr := string(receivedData) 175 | if !strings.Contains(dataStr, `"T":39`) && !strings.Contains(dataStr, `"T":40`) { 176 | t.Errorf("Expected JSON data containing temperature values, got: %s", dataStr) 177 | } 178 | } 179 | 180 | // Example_requestStream_fromSlice demonstrates streaming data from a slice to the server. 181 | func Example_requestStream_fromSlice() { 182 | type DataPoint struct { 183 | Timestamp int64 `json:"ts"` 184 | Value float64 `json:"val"` 185 | } 186 | 187 | // Sample data to stream 188 | data := []DataPoint{ 189 | {Timestamp: 1672531200, Value: 25.5}, 190 | {Timestamp: 1672531260, Value: 26.1}, 191 | {Timestamp: 1672531320, Value: 24.8}, 192 | } 193 | 194 | stream := Slice[DataPoint](data) 195 | 196 | mockEndpoint := NewEndpoint("DataIngestion"). 197 | Request(BaseURL("https://api.example.com")). 198 | Response( 199 | MockedRes(func(res Response) { 200 | res.SetBody(io.NopCloser(strings.NewReader(`{"status": "received"}`))) 201 | res.SetStatus(http.StatusOK) 202 | }), 203 | ) 204 | 205 | call := NewCall[any](NewMockHttpClientAdapter()). 206 | URI("/data"). 207 | Method(http.MethodPost). 208 | ContentType(ContentTypeJSONEachRow). 209 | RequestStreamBody( 210 | RequestStreamBody[any, DataPoint](stream), 211 | ). 212 | ExpectedStatusCodes(http.StatusOK) 213 | 214 | err := call.CallEndpoint(context.Background(), mockEndpoint) 215 | if err != nil { 216 | fmt.Printf("Error: %v\n", err) 217 | return 218 | } 219 | 220 | fmt.Println("Data streamed successfully") 221 | // Output: Data streamed successfully 222 | } 223 | 224 | // Example_requestStream_withSniffer demonstrates streaming with request sniffing. 225 | func Example_requestStream_withSniffer() { 226 | type Event struct { 227 | Type string `json:"type"` 228 | } 229 | 230 | events := []Event{ 231 | {Type: "click"}, 232 | {Type: "view"}, 233 | } 234 | 235 | stream := Slice[Event](events) 236 | 237 | mockEndpoint := NewEndpoint("Analytics"). 238 | Request(BaseURL("https://api.example.com")). 239 | Response( 240 | MockedRes(func(res Response) { 241 | res.SetBody(io.NopCloser(strings.NewReader(`{"processed": true}`))) 242 | res.SetStatus(http.StatusOK) 243 | }), 244 | ) 245 | 246 | call := NewCall[any](NewMockHttpClientAdapter()). 247 | URI("/events"). 248 | Method(http.MethodPost). 249 | ContentType(ContentTypeJSONEachRow). 250 | RequestSniffed(func(data []byte, err error) { 251 | if err == nil { 252 | // Simplify output to avoid JSON formatting issues 253 | if strings.Contains(string(data), "click") { 254 | fmt.Println("Sending click event") 255 | } else if strings.Contains(string(data), "view") { 256 | fmt.Println("Sending view event") 257 | } 258 | } 259 | }). 260 | RequestStreamBody( 261 | RequestStreamBody[any, Event](stream), 262 | ). 263 | ExpectedStatusCodes(http.StatusOK) 264 | 265 | err := call.CallEndpoint(context.Background(), mockEndpoint) 266 | if err != nil { 267 | fmt.Printf("Error: %v\n", err) 268 | return 269 | } 270 | 271 | fmt.Println("Events processed") 272 | // Output: Sending click event 273 | // Sending view event 274 | // Events processed 275 | } 276 | -------------------------------------------------------------------------------- /examples_response_stream_test.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/sonirico/withttp/csvparser" 12 | ) 13 | 14 | var ( 15 | getRepoStatsEndpoint = NewEndpoint("GetRepoStats"). 16 | Request(BaseURL("http://example.com")). 17 | Response( 18 | MockedRes(func(res Response) { 19 | mockResponse := `rank,repo_name,stars 20 | 1,freeCodeCamp,341271 21 | 2,996.ICU,261139` 22 | res.SetBody(io.NopCloser(strings.NewReader(mockResponse))) 23 | res.SetStatus(http.StatusOK) 24 | }), 25 | ) 26 | ) 27 | 28 | type Repo struct { 29 | Rank int 30 | Name string 31 | Stars int 32 | } 33 | 34 | func TestResponseStream_ParseCSV(t *testing.T) { 35 | ignoreLines := 1 // Skip header line 36 | repos := make([]Repo, 0) 37 | 38 | parser := csvparser.New[Repo]( 39 | csvparser.SeparatorComma, 40 | csvparser.IntCol[Repo]( 41 | csvparser.QuoteNone, 42 | nil, 43 | func(x *Repo, rank int) { x.Rank = rank }, 44 | ), 45 | csvparser.StringCol[Repo]( 46 | csvparser.QuoteNone, 47 | nil, 48 | func(x *Repo, name string) { x.Name = name }, 49 | ), 50 | csvparser.IntCol[Repo]( 51 | csvparser.QuoteNone, 52 | nil, 53 | func(x *Repo, stars int) { x.Stars = stars }, 54 | ), 55 | ) 56 | 57 | call := NewCall[Repo](Fasthttp()). 58 | Method(http.MethodGet). 59 | Header("User-Agent", "withttp/0.6.0 See https://github.com/sonirico/withttp", false). 60 | ParseCSV(ignoreLines, parser, func(r Repo) bool { 61 | repos = append(repos, r) 62 | t.Logf("Repo: %+v", r) 63 | return true // Continue parsing 64 | }). 65 | ExpectedStatusCodes(http.StatusOK) 66 | 67 | err := call.CallEndpoint(context.Background(), getRepoStatsEndpoint) 68 | if err != nil { 69 | t.Fatalf("Failed to call endpoint: %v", err) 70 | } 71 | 72 | // Verify we parsed the expected repositories 73 | if len(repos) != 2 { 74 | t.Errorf("Expected 2 repositories, got %d", len(repos)) 75 | } 76 | 77 | if len(repos) >= 1 { 78 | if repos[0].Rank != 1 || repos[0].Name != "freeCodeCamp" || repos[0].Stars != 341271 { 79 | t.Errorf("First repo incorrect: got %+v", repos[0]) 80 | } 81 | } 82 | 83 | if len(repos) >= 2 { 84 | if repos[1].Rank != 2 || repos[1].Name != "996.ICU" || repos[1].Stars != 261139 { 85 | t.Errorf("Second repo incorrect: got %+v", repos[1]) 86 | } 87 | } 88 | } 89 | 90 | func TestResponseStream_ParseCSVChannel(t *testing.T) { 91 | ignoreLines := 1 // Skip header line 92 | repoChan := make(chan Repo) 93 | repos := make([]Repo, 0) 94 | 95 | // Collect repos from channel 96 | go func() { 97 | for repo := range repoChan { 98 | repos = append(repos, repo) 99 | } 100 | }() 101 | 102 | parser := csvparser.New[Repo]( 103 | csvparser.SeparatorComma, 104 | csvparser.IntCol[Repo]( 105 | csvparser.QuoteNone, 106 | nil, 107 | func(x *Repo, rank int) { x.Rank = rank }, 108 | ), 109 | csvparser.StringCol[Repo]( 110 | csvparser.QuoteNone, 111 | nil, 112 | func(x *Repo, name string) { x.Name = name }, 113 | ), 114 | csvparser.IntCol[Repo]( 115 | csvparser.QuoteNone, 116 | nil, 117 | func(x *Repo, stars int) { x.Stars = stars }, 118 | ), 119 | ) 120 | 121 | call := NewCall[Repo](Fasthttp()). 122 | Method(http.MethodGet). 123 | Header("User-Agent", "withttp/0.6.0 See https://github.com/sonirico/withttp", false). 124 | ParseStreamChan(NewCSVStreamFactory[Repo](ignoreLines, parser), repoChan). 125 | ExpectedStatusCodes(http.StatusOK) 126 | 127 | err := call.CallEndpoint(context.Background(), getRepoStatsEndpoint) 128 | if err != nil { 129 | t.Fatalf("Failed to call endpoint: %v", err) 130 | } 131 | 132 | // Give the goroutine time to process 133 | // In a real test, you might use a sync mechanism 134 | if len(repos) == 0 { 135 | // Channel might still be processing, wait a bit 136 | for i := 0; i < 10 && len(repos) == 0; i++ { 137 | // Small delay to allow goroutine to process 138 | } 139 | } 140 | 141 | // Verify we parsed the expected repositories 142 | if len(repos) != 2 { 143 | t.Errorf("Expected 2 repositories, got %d", len(repos)) 144 | } 145 | 146 | if len(repos) >= 1 { 147 | if repos[0].Rank != 1 || repos[0].Name != "freeCodeCamp" || repos[0].Stars != 341271 { 148 | t.Errorf("First repo incorrect: got %+v", repos[0]) 149 | } 150 | } 151 | 152 | if len(repos) >= 2 { 153 | if repos[1].Rank != 2 || repos[1].Name != "996.ICU" || repos[1].Stars != 261139 { 154 | t.Errorf("Second repo incorrect: got %+v", repos[1]) 155 | } 156 | } 157 | } 158 | 159 | // Example_parseCSV demonstrates parsing CSV response data. 160 | func Example_parseCSV() { 161 | type Repository struct { 162 | Rank int 163 | Name string 164 | Stars int 165 | } 166 | 167 | csvData := `rank,name,stars 168 | 1,awesome-go,75000 169 | 2,gin,65000` 170 | 171 | endpoint := NewEndpoint("RepoStats"). 172 | Request(BaseURL("https://api.example.com")). 173 | Response( 174 | MockedRes(func(res Response) { 175 | res.SetBody(io.NopCloser(strings.NewReader(csvData))) 176 | res.SetStatus(http.StatusOK) 177 | }), 178 | ) 179 | 180 | parser := csvparser.New[Repository]( 181 | csvparser.SeparatorComma, 182 | csvparser.IntCol[Repository]( 183 | csvparser.QuoteNone, 184 | nil, 185 | func(x *Repository, rank int) { x.Rank = rank }, 186 | ), 187 | csvparser.StringCol[Repository]( 188 | csvparser.QuoteNone, 189 | nil, 190 | func(x *Repository, name string) { x.Name = name }, 191 | ), 192 | csvparser.IntCol[Repository]( 193 | csvparser.QuoteNone, 194 | nil, 195 | func(x *Repository, stars int) { x.Stars = stars }, 196 | ), 197 | ) 198 | 199 | call := NewCall[Repository](NewMockHttpClientAdapter()). 200 | URI("/top-repos.csv"). 201 | Method(http.MethodGet). 202 | ParseCSV(1, parser, func(repo Repository) bool { // skip 1 header line 203 | fmt.Printf("Rank %d: %s (%d stars)\n", repo.Rank, repo.Name, repo.Stars) 204 | return true // continue processing 205 | }). 206 | ExpectedStatusCodes(http.StatusOK) 207 | 208 | err := call.CallEndpoint(context.Background(), endpoint) 209 | if err != nil { 210 | fmt.Printf("Error: %v\n", err) 211 | return 212 | } 213 | 214 | // Output: Rank 1: awesome-go (75000 stars) 215 | // Rank 2: gin (65000 stars) 216 | } 217 | -------------------------------------------------------------------------------- /examples_singlecall_test.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | type githubRepoInfo struct { 13 | ID int `json:"id"` 14 | URL string `json:"html_url"` 15 | } 16 | 17 | func getRepoInfo(user, repo string) (githubRepoInfo, error) { 18 | call := NewCall[githubRepoInfo](NetHttp()). 19 | URL(fmt.Sprintf("https://api.github.com/repos/%s/%s", user, repo)). 20 | Method(http.MethodGet). 21 | Header("User-Agent", "withttp/0.5.1 See https://github.com/sonirico/withttp", false). 22 | ParseJSON(). 23 | ExpectedStatusCodes(http.StatusOK) 24 | 25 | err := call.Call(context.Background()) 26 | 27 | return call.BodyParsed, err 28 | } 29 | 30 | func TestSingleCall_Example(t *testing.T) { 31 | // This is primarily an example test - it makes a real API call 32 | // In production tests, you might want to mock this 33 | t.Skip("Skipping live API test - enable manually for integration testing") 34 | 35 | info, err := getRepoInfo("sonirico", "withttp") 36 | if err != nil { 37 | t.Fatalf("Failed to get repo info: %v", err) 38 | } 39 | 40 | if info.ID == 0 { 41 | t.Error("Expected repo ID to be non-zero") 42 | } 43 | 44 | if info.URL == "" { 45 | t.Error("Expected repo URL to be non-empty") 46 | } 47 | 48 | t.Logf("Repo info: ID=%d, URL=%s", info.ID, info.URL) 49 | } 50 | 51 | func TestSingleCall_MockedExample(t *testing.T) { 52 | // This test uses a mocked endpoint to test the functionality without making real API calls 53 | mockEndpoint := NewEndpoint("MockGithubAPI"). 54 | Request(BaseURL("http://example.com")). 55 | Response( 56 | MockedRes(func(res Response) { 57 | res.SetBody( 58 | io.NopCloser( 59 | strings.NewReader( 60 | `{"id": 12345, "html_url": "https://github.com/sonirico/withttp"}`, 61 | ), 62 | ), 63 | ) 64 | res.SetStatus(http.StatusOK) 65 | }), 66 | ) 67 | 68 | call := NewCall[githubRepoInfo](NetHttp()). 69 | URI("repos/sonirico/withttp"). 70 | Method(http.MethodGet). 71 | Header("User-Agent", "withttp/0.5.1 See https://github.com/sonirico/withttp", false). 72 | ParseJSON(). 73 | ExpectedStatusCodes(http.StatusOK) 74 | 75 | err := call.CallEndpoint(context.Background(), mockEndpoint) 76 | if err != nil { 77 | t.Fatalf("Failed to call mocked endpoint: %v", err) 78 | } 79 | 80 | if call.BodyParsed.ID != 12345 { 81 | t.Errorf("Expected ID 12345, got %d", call.BodyParsed.ID) 82 | } 83 | 84 | if call.BodyParsed.URL != "https://github.com/sonirico/withttp" { 85 | t.Errorf("Expected URL 'https://github.com/sonirico/withttp', got %s", call.BodyParsed.URL) 86 | } 87 | } 88 | 89 | // Example_singleCall demonstrates making a simple HTTP GET request. 90 | func Example_singleCall() { 91 | type RepoInfo struct { 92 | ID int `json:"id"` 93 | Name string `json:"name"` 94 | } 95 | 96 | // Create a mocked endpoint for the example 97 | endpoint := NewEndpoint("GithubAPI"). 98 | Request(BaseURL("https://api.github.com")). 99 | Response( 100 | MockedRes(func(res Response) { 101 | res.SetBody(io.NopCloser(strings.NewReader(`{"id": 12345, "name": "withttp"}`))) 102 | res.SetStatus(http.StatusOK) 103 | }), 104 | ) 105 | 106 | call := NewCall[RepoInfo](NewMockHttpClientAdapter()). 107 | URI("/repos/user/repo"). 108 | Method(http.MethodGet). 109 | Header("User-Agent", "withttp-example", false). 110 | ParseJSON(). 111 | ExpectedStatusCodes(http.StatusOK) 112 | 113 | err := call.CallEndpoint(context.Background(), endpoint) 114 | if err != nil { 115 | fmt.Printf("Error: %v\n", err) 116 | return 117 | } 118 | 119 | fmt.Printf("Repo ID: %d, Name: %s\n", call.BodyParsed.ID, call.BodyParsed.Name) 120 | // Output: Repo ID: 12345, Name: withttp 121 | } 122 | 123 | // Example_singleCall_directURL demonstrates making a request directly to a URL without an endpoint. 124 | func Example_singleCall_directURL() { 125 | type SimpleResponse struct { 126 | Status string `json:"status"` 127 | } 128 | 129 | // For this example, we'll simulate a call but skip it in practice 130 | // In real usage, you would just call without the skip 131 | call := NewCall[SimpleResponse](NetHttp()). 132 | URL("https://httpbin.org/json"). 133 | Method(http.MethodGet). 134 | Header("User-Agent", "withttp-example", false). 135 | ParseJSON(). 136 | ExpectedStatusCodes(http.StatusOK) 137 | 138 | // For documentation purposes, we'll show what the call would look like 139 | _ = call // Normally: err := call.Call(context.Background()) 140 | 141 | fmt.Println("This would make a direct HTTP call to the specified URL") 142 | // Output: This would make a direct HTTP call to the specified URL 143 | } 144 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sonirico/withttp 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.6 6 | 7 | require ( 8 | github.com/pkg/errors v0.9.1 9 | github.com/sonirico/vago v0.5.0 10 | github.com/valyala/fasthttp v1.39.0 11 | ) 12 | 13 | require ( 14 | github.com/andybalholm/brotli v1.0.4 // indirect 15 | github.com/klauspost/compress v1.15.9 // indirect 16 | github.com/valyala/bytebufferpool v1.0.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= 2 | github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 3 | github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 4 | github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= 5 | github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= 6 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 7 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 8 | github.com/sonirico/vago v0.5.0 h1:oTy41V+tWv6AFUCIGrbK+mUACSXIXehZs19I4/zEpYU= 9 | github.com/sonirico/vago v0.5.0/go.mod h1:Mp0WjXRi/TKHsgKnC+Pya37maKFSLeFlpLa+CK1DmOs= 10 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 11 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 12 | github.com/valyala/fasthttp v1.39.0 h1:lW8mGeM7yydOqZKmwyMTaz/PH/A+CLgtmmcjv+OORfU= 13 | github.com/valyala/fasthttp v1.39.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I= 14 | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 15 | golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 16 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 17 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 18 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 19 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 20 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 21 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 22 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 23 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 24 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 25 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 26 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 27 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 28 | -------------------------------------------------------------------------------- /stream_factory.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | 9 | "github.com/sonirico/withttp/csvparser" 10 | ) 11 | 12 | type ( 13 | Stream[T any] interface { 14 | Next(ctx context.Context) bool 15 | Data() T 16 | Err() error 17 | } 18 | 19 | StreamFactory[T any] interface { 20 | Get(r io.Reader) Stream[T] 21 | } 22 | 23 | StreamFactoryFunc[T any] func(reader io.Reader) Stream[T] 24 | ) 25 | 26 | func (f StreamFactoryFunc[T]) Get(r io.Reader) Stream[T] { 27 | return f(r) 28 | } 29 | 30 | type ( 31 | JSONEachRowStream[T any] struct { 32 | current T 33 | 34 | inner Stream[[]byte] 35 | 36 | err error 37 | } 38 | 39 | CSVStream[T any] struct { 40 | current T 41 | 42 | inner Stream[[]byte] 43 | 44 | err error 45 | 46 | parser csvparser.Parser[T] 47 | 48 | ignoreLines int 49 | ignoreErr bool 50 | 51 | rowCount int 52 | } 53 | 54 | NewLineStream struct { 55 | current []byte 56 | 57 | scanner *bufio.Scanner 58 | 59 | err error 60 | } 61 | 62 | ProxyStream struct { 63 | err error 64 | current []byte 65 | buffer []byte 66 | reader io.Reader 67 | } 68 | ) 69 | 70 | func (s *ProxyStream) Next(_ context.Context) bool { 71 | s.err = nil 72 | bts := s.buffer[:cap(s.buffer)] 73 | read, err := s.reader.Read(bts) 74 | 75 | if err != nil || read == 0 { 76 | s.err = err 77 | return false 78 | } 79 | 80 | s.current = make([]byte, read) 81 | copy(s.current, bts) 82 | return true 83 | } 84 | 85 | func (s *ProxyStream) Data() []byte { 86 | return s.current 87 | } 88 | 89 | func (s *ProxyStream) Err() error { 90 | return s.err 91 | } 92 | 93 | func (s *NewLineStream) Next(_ context.Context) bool { 94 | if !s.scanner.Scan() { 95 | s.err = s.scanner.Err() 96 | return false 97 | } 98 | s.current = s.scanner.Bytes() 99 | return true 100 | } 101 | 102 | func (s *NewLineStream) Data() []byte { 103 | return s.current 104 | } 105 | 106 | func (s *NewLineStream) Err() error { 107 | if s.err != nil { 108 | return s.err 109 | } 110 | 111 | return s.scanner.Err() 112 | } 113 | 114 | func (s *JSONEachRowStream[T]) Next(ctx context.Context) bool { 115 | if !s.inner.Next(ctx) { 116 | return false 117 | } 118 | 119 | s.err = json.Unmarshal(s.inner.Data(), &s.current) 120 | 121 | return true 122 | } 123 | 124 | func (s *JSONEachRowStream[T]) Data() T { 125 | return s.current 126 | } 127 | 128 | func (s *JSONEachRowStream[T]) Err() error { 129 | if s.err != nil { 130 | return s.err 131 | } 132 | 133 | return s.inner.Err() 134 | } 135 | 136 | func (s *CSVStream[T]) next(ctx context.Context, shouldParse bool) bool { 137 | if !s.inner.Next(ctx) { 138 | return false 139 | } 140 | 141 | if !shouldParse { 142 | return true 143 | } 144 | 145 | var zeroed T // TODO: json too? 146 | line := s.inner.Data() 147 | s.current = zeroed 148 | s.err = s.parser.Parse(line, &s.current) 149 | 150 | if s.err == nil || s.ignoreErr { 151 | s.rowCount++ 152 | } 153 | 154 | return true 155 | } 156 | 157 | func (s *CSVStream[T]) Next(ctx context.Context) bool { 158 | for s.ignoreLines > 0 { 159 | _ = s.next(ctx, false) 160 | s.ignoreLines-- 161 | } 162 | return s.next(ctx, true) 163 | } 164 | 165 | func (s *CSVStream[T]) Data() T { 166 | return s.current 167 | } 168 | 169 | func (s *CSVStream[T]) Err() error { 170 | if s.err != nil { 171 | return s.err 172 | } 173 | 174 | return s.inner.Err() 175 | } 176 | 177 | func NewNewLineStream(r io.Reader) Stream[[]byte] { 178 | return &NewLineStream{scanner: bufio.NewScanner(r)} 179 | } 180 | 181 | func NewNewLineStreamFactory() StreamFactory[[]byte] { 182 | return StreamFactoryFunc[[]byte](func(r io.Reader) Stream[[]byte] { 183 | return NewNewLineStream(r) 184 | }) 185 | } 186 | 187 | func NewProxyStream(r io.Reader, bufferSize int) Stream[[]byte] { 188 | return &ProxyStream{reader: r, buffer: make([]byte, bufferSize)} 189 | } 190 | 191 | func NewProxyStreamFactory(bufferSize int) StreamFactory[[]byte] { 192 | return StreamFactoryFunc[[]byte](func(r io.Reader) Stream[[]byte] { 193 | return NewProxyStream(r, bufferSize) 194 | }) 195 | } 196 | 197 | func NewJSONEachRowStream[T any](r io.Reader) Stream[T] { 198 | return &JSONEachRowStream[T]{ 199 | inner: NewNewLineStream(r), 200 | } 201 | } 202 | 203 | func NewCSVStream[T any](r io.Reader, ignoreLines int, parser csvparser.Parser[T]) Stream[T] { 204 | return &CSVStream[T]{ 205 | inner: NewNewLineStream(r), 206 | parser: parser, 207 | ignoreLines: ignoreLines, 208 | ignoreErr: false, 209 | } 210 | } 211 | 212 | func NewJSONEachRowStreamFactory[T any]() StreamFactory[T] { 213 | return StreamFactoryFunc[T](func(r io.Reader) Stream[T] { 214 | return NewJSONEachRowStream[T](r) 215 | }) 216 | } 217 | 218 | func NewCSVStreamFactory[T any](ignoreLines int, parser csvparser.Parser[T]) StreamFactory[T] { 219 | return StreamFactoryFunc[T](func(r io.Reader) Stream[T] { 220 | return NewCSVStream[T](r, ignoreLines, parser) 221 | }) 222 | } 223 | -------------------------------------------------------------------------------- /streams.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | type ( 9 | rangeable[T any] interface { 10 | Range(func(int, T) bool) 11 | Serialize() bool 12 | } 13 | 14 | Slice[T any] []T 15 | 16 | Channel[T any] chan T 17 | 18 | StreamFromReader struct { 19 | io.Reader 20 | streamFactory StreamFactory[[]byte] 21 | } 22 | ) 23 | 24 | func NewStreamFromReader(r io.Reader, sf StreamFactory[[]byte]) StreamFromReader { 25 | return StreamFromReader{ 26 | Reader: r, 27 | streamFactory: sf, 28 | } 29 | } 30 | 31 | func (s Slice[T]) Range(fn func(int, T) bool) { 32 | for i, x := range s { 33 | if !fn(i, x) { 34 | return 35 | } 36 | } 37 | } 38 | 39 | func (s Slice[T]) Serialize() bool { return true } 40 | 41 | func (c Channel[T]) Range(fn func(int, T) bool) { 42 | i := 0 43 | for { 44 | x, ok := <-c 45 | if !ok { 46 | return 47 | } 48 | 49 | fn(i, x) 50 | 51 | i++ 52 | } 53 | } 54 | 55 | func (c Channel[T]) Serialize() bool { return true } 56 | 57 | func (r StreamFromReader) Range(fn func(int, []byte) bool) { 58 | stream := r.streamFactory.Get(r) 59 | i := 0 60 | for stream.Next(context.TODO()) { 61 | if stream.Err() != nil { 62 | return 63 | } 64 | 65 | fn(i, stream.Data()) 66 | i++ 67 | } 68 | } 69 | 70 | func (r StreamFromReader) Serialize() bool { return false } 71 | -------------------------------------------------------------------------------- /testing.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | func assertError(t *testing.T, expected, actual error) bool { 11 | t.Helper() 12 | 13 | if actual != nil { 14 | if expected != nil { 15 | if !errors.Is(expected, actual) { 16 | t.Errorf("unexpected error, want %s, have %s", 17 | expected, actual) 18 | return false 19 | } 20 | } else { 21 | t.Errorf("unexpected error, want none, have %s", actual) 22 | return false 23 | } 24 | } else { 25 | if expected != nil { 26 | t.Errorf("unexpected error, want %s, have none", 27 | expected) 28 | return false 29 | } 30 | } 31 | 32 | return true 33 | } 34 | 35 | func streamTextJoin(sep string, items []string) []byte { 36 | return []byte(strings.Join(items, sep)) 37 | } 38 | -------------------------------------------------------------------------------- /utils_buf.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import "io" 4 | 5 | type ( 6 | closableReaderWriter struct { 7 | io.ReadWriter 8 | } 9 | ) 10 | 11 | func (b closableReaderWriter) Close() error { 12 | return nil 13 | } 14 | -------------------------------------------------------------------------------- /utils_nalloc.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "unsafe" 5 | ) 6 | 7 | func S2B(s string) []byte { 8 | return unsafe.Slice(unsafe.StringData(s), len(s)) 9 | } 10 | 11 | func B2S(data []byte) string { 12 | return unsafe.String(unsafe.SliceData(data), len(data)) 13 | } 14 | -------------------------------------------------------------------------------- /utils_str.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | ) 7 | 8 | func StrIsset(s string) bool { 9 | return len(strings.TrimSpace(s)) > 0 10 | } 11 | 12 | func BtsIsset(bts []byte) bool { 13 | return len(bytes.TrimSpace(bts)) > 0 14 | } 15 | 16 | func BytesEquals(a, b []byte) bool { 17 | return bytes.Equal(bytes.TrimSpace(a), bytes.TrimSpace(b)) 18 | } 19 | -------------------------------------------------------------------------------- /with_endpoint.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import "net/url" 4 | 5 | func BaseURL(raw string) ReqOption { 6 | return ReqOptionFunc(func(req Request) (err error) { 7 | u, err := url.Parse(raw) 8 | if err != nil { 9 | return err 10 | } 11 | 12 | req.SetURL(u) 13 | 14 | return 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /with_logger.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | type ( 4 | logger interface { 5 | Printf(string, ...any) 6 | } 7 | ) 8 | -------------------------------------------------------------------------------- /with_req.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/base64" 7 | "fmt" 8 | "io" 9 | "net/url" 10 | "sync" 11 | 12 | "github.com/pkg/errors" 13 | 14 | "github.com/sonirico/withttp/codec" 15 | ) 16 | 17 | func Header[T any](k, v string, override bool) CallReqOptionFunc[T] { 18 | return func(_ *Call[T], req Request) error { 19 | return ConfigureHeader(req, k, v, override) 20 | } 21 | } 22 | 23 | func BasicAuth[T any](user, pass string) CallReqOptionFunc[T] { 24 | return func(_ *Call[T], req Request) error { 25 | header, err := CreateAuthorizationHeader(authHeaderKindBasic, user, pass) 26 | if err != nil { 27 | return err 28 | } 29 | return ConfigureHeader(req, "authorization", header, true) 30 | } 31 | } 32 | 33 | func HeaderFunc[T any](fn func() (string, string, bool)) CallReqOptionFunc[T] { 34 | return func(_ *Call[T], req Request) error { 35 | k, v, override := fn() 36 | return ConfigureHeader(req, k, v, override) 37 | } 38 | } 39 | 40 | func ContentType[T any](ct string) CallReqOptionFunc[T] { 41 | return func(c *Call[T], req Request) error { 42 | c.ReqContentType = ct 43 | return ConfigureHeader(req, "content-type", ct, true) 44 | } 45 | } 46 | 47 | type authHeaderKind string 48 | 49 | var ( 50 | authHeaderKindBasic authHeaderKind = "Basic" 51 | ) 52 | 53 | func (a authHeaderKind) Codec() func(...string) (string, error) { 54 | switch a { 55 | case authHeaderKindBasic: 56 | return func(s ...string) (string, error) { 57 | if len(s) < 2 { 58 | return "", errors.Wrapf(ErrAssertion, "header kind: %s", a) 59 | } 60 | user := s[0] 61 | pass := s[1] 62 | 63 | return base64.StdEncoding.EncodeToString(S2B(user + ":" + pass)), nil 64 | } 65 | default: 66 | panic("unknown auth header kind") 67 | } 68 | } 69 | 70 | func CreateAuthorizationHeader(kind authHeaderKind, user, pass string) (string, error) { 71 | fn := kind.Codec() 72 | header, err := fn(user, pass) 73 | if err != nil { 74 | return header, err 75 | } 76 | return fmt.Sprintf("%s %s", kind, header), nil 77 | } 78 | 79 | func ConfigureHeader(req Request, key, value string, override bool) error { 80 | if override { 81 | req.SetHeader(key, value) 82 | } else { 83 | req.AddHeader(key, value) 84 | } 85 | return nil 86 | } 87 | 88 | func Method[T any](method string) CallReqOptionFunc[T] { 89 | return func(_ *Call[T], req Request) (err error) { 90 | req.SetMethod(method) 91 | return 92 | } 93 | } 94 | 95 | func Query[T any](k, v string) CallReqOptionFunc[T] { 96 | return func(_ *Call[T], req Request) (err error) { 97 | u := req.URL() 98 | qs := u.Query() 99 | qs.Set(k, v) 100 | u.RawQuery = qs.Encode() 101 | req.SetURL(u) 102 | return 103 | } 104 | } 105 | 106 | func URL[T any](raw string) CallReqOptionFunc[T] { 107 | return func(_ *Call[T], req Request) (err error) { 108 | u, err := url.Parse(raw) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | req.SetURL(u) 114 | 115 | return 116 | } 117 | } 118 | 119 | func URI[T any](raw string) CallReqOptionFunc[T] { 120 | return func(_ *Call[T], req Request) (err error) { 121 | req.SetURL(req.URL().JoinPath(raw)) 122 | return 123 | } 124 | } 125 | 126 | func RawBody[T any](payload []byte) CallReqOptionFunc[T] { 127 | return func(_ *Call[T], req Request) (err error) { 128 | req.SetBody(payload) 129 | return nil 130 | } 131 | } 132 | 133 | func Body[T any](payload any) CallReqOptionFunc[T] { 134 | return func(c *Call[T], req Request) (err error) { 135 | data, err := EncodeBody(payload, c.ReqContentType) 136 | if err != nil { 137 | return err 138 | } 139 | req.SetBody(data) 140 | return nil 141 | } 142 | } 143 | 144 | func RequestSniffer[T any](fn func([]byte, error)) CallReqOptionFunc[T] { 145 | return func(c *Call[T], req Request) error { 146 | c.ReqShouldSniff = true 147 | c.ReqStreamSniffer = fn 148 | return nil 149 | } 150 | } 151 | 152 | func RequestStreamBody[T, U any](r rangeable[U]) StreamCallReqOptionFunc[T] { 153 | return func(c *Call[T], req Request) error { 154 | c.ReqIsStream = true 155 | 156 | buf := closableReaderWriter{ReadWriter: bytes.NewBuffer(nil)} // TODO: pool buffer 157 | req.SetBodyStream(buf, -1) // TODO: bodySize 158 | 159 | c.ReqStreamWriter = func(ctx context.Context, c *Call[T], req Request, wg *sync.WaitGroup) (err error) { 160 | defer func() { wg.Done() }() 161 | 162 | var encoder codec.Encoder 163 | if r.Serialize() { 164 | encoder, err = ContentTypeCodec(c.ReqContentType) 165 | 166 | if err != nil { 167 | return 168 | } 169 | } else { 170 | encoder = codec.ProxyBytesEncoder 171 | } 172 | 173 | var sniffer func([]byte, error) 174 | 175 | if c.ReqShouldSniff { 176 | sniffer = c.ReqStreamSniffer 177 | } else { 178 | sniffer = func(_ []byte, _ error) {} 179 | } 180 | 181 | err = EncodeStream(ctx, r, req, encoder, sniffer) 182 | 183 | return 184 | } 185 | return nil 186 | } 187 | } 188 | 189 | func BodyStream[T any](rc io.ReadWriteCloser, bodySize int) CallReqOptionFunc[T] { 190 | return func(c *Call[T], req Request) (err error) { 191 | req.SetBodyStream(rc, bodySize) 192 | return nil 193 | } 194 | } 195 | 196 | func EncodeBody(payload any, contentType string) (bts []byte, err error) { 197 | encoder, err := ContentTypeCodec(contentType) 198 | if err != nil { 199 | return 200 | } 201 | bts, err = encoder.Encode(payload) 202 | return 203 | } 204 | 205 | func EncodeStream[T any]( 206 | ctx context.Context, 207 | r rangeable[T], 208 | req Request, 209 | encoder codec.Encoder, 210 | sniffer func([]byte, error), 211 | ) (err error) { 212 | 213 | stream := req.BodyStream() 214 | 215 | defer func() { _ = stream.Close() }() 216 | 217 | var bts []byte 218 | 219 | r.Range(func(i int, x T) bool { 220 | defer func() { 221 | sniffer(bts, err) 222 | }() 223 | 224 | select { 225 | case <-ctx.Done(): 226 | return false 227 | default: 228 | if bts, err = encoder.Encode(x); err != nil { 229 | return false 230 | } 231 | 232 | if _, err = stream.Write(bts); err != nil { 233 | return false 234 | } 235 | 236 | return true 237 | } 238 | }) 239 | 240 | return 241 | } 242 | -------------------------------------------------------------------------------- /with_req_test.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "testing" 9 | ) 10 | 11 | func TestCall_StreamingRequestFromSlice(t *testing.T) { 12 | type ( 13 | payload struct { 14 | Name string `json:"name"` 15 | } 16 | 17 | args struct { 18 | Stream []payload 19 | } 20 | 21 | want struct { 22 | expectedErr error 23 | ReceivedPayload []byte 24 | } 25 | 26 | testCase struct { 27 | name string 28 | args args 29 | want want 30 | } 31 | ) 32 | 33 | tests := []testCase{ 34 | { 35 | name: "one element in the stream", 36 | args: args{ 37 | Stream: []payload{ 38 | { 39 | Name: "I am the first payload", 40 | }, 41 | }, 42 | }, 43 | want: want{ 44 | ReceivedPayload: []byte(`{"name":"I am the first payload"}`), 45 | }, 46 | }, 47 | { 48 | name: "several elements in the stream", 49 | args: args{ 50 | Stream: []payload{ 51 | { 52 | Name: "I am the first payload", 53 | }, 54 | { 55 | Name: "I am the second payload", 56 | }, 57 | }, 58 | }, 59 | want: want{ 60 | ReceivedPayload: streamTextJoin("\n", []string{ 61 | `{"name":"I am the first payload"}`, 62 | `{"name":"I am the second payload"}`, 63 | }), 64 | }, 65 | }, 66 | } 67 | 68 | endpoint := NewEndpoint("mock"). 69 | Response(MockedRes(func(res Response) { 70 | res.SetStatus(http.StatusOK) 71 | res.SetBody(io.NopCloser(bytes.NewReader(nil))) 72 | })) 73 | 74 | for _, test := range tests { 75 | 76 | t.Run(test.name, func(t *testing.T) { 77 | 78 | call := NewCall[any](NewMockHttpClientAdapter()). 79 | ContentType(ContentTypeJSONEachRow). 80 | RequestStreamBody( 81 | RequestStreamBody[any, payload](Slice[payload](test.args.Stream)), 82 | ). 83 | ExpectedStatusCodes(http.StatusOK) 84 | 85 | err := call.CallEndpoint(context.TODO(), endpoint) 86 | 87 | if !assertError(t, test.want.expectedErr, err) { 88 | t.FailNow() 89 | } 90 | actualReceivedBody := call.Req.Body() 91 | 92 | if !BytesEquals(test.want.ReceivedPayload, actualReceivedBody) { 93 | t.Errorf("unexpected received payload\nwant '%s'\nhave '%s'", 94 | string(test.want.ReceivedPayload), string(actualReceivedBody)) 95 | } 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /with_res.go: -------------------------------------------------------------------------------- 1 | package withttp 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | 8 | "github.com/sonirico/vago/slices" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | func CloseBody[T any]() CallResOptionFunc[T] { 14 | return func(c *Call[T], res Response) (err error) { 15 | rc := c.bodyReader(res) 16 | defer func() { _ = rc.Close() }() 17 | return 18 | } 19 | } 20 | 21 | func IgnoredBody[T any]() CallResOptionFunc[T] { 22 | return func(c *Call[T], res Response) (err error) { 23 | rc := c.bodyReader(res) 24 | defer func() { _ = rc.Close() }() 25 | _, err = io.Copy(io.Discard, rc) 26 | return 27 | } 28 | } 29 | 30 | func ParseBodyRaw[T any]() CallResOptionFunc[T] { 31 | return func(c *Call[T], res Response) (err error) { 32 | rc := c.bodyReader(res) 33 | defer func() { _ = rc.Close() }() 34 | c.BodyRaw, err = io.ReadAll(rc) 35 | return 36 | } 37 | } 38 | 39 | func ParseJSON[T any]() CallResOptionFunc[T] { 40 | return func(c *Call[T], res Response) (err error) { 41 | c.BodyParsed, err = ReadJSON[T](c.bodyReader(res)) 42 | return 43 | } 44 | } 45 | 46 | func ParseStream[T any](factory StreamFactory[T], fn func(T) bool) CallResOptionFunc[T] { 47 | return func(c *Call[T], res Response) (err error) { 48 | return ReadStream[T](c.bodyReader(res), factory, fn) 49 | } 50 | } 51 | 52 | func ParseStreamChan[T any](factory StreamFactory[T], out chan<- T) CallResOptionFunc[T] { 53 | return func(c *Call[T], res Response) (err error) { 54 | return ReadStreamChan(c.bodyReader(res), factory, out) 55 | } 56 | } 57 | 58 | func ExpectedStatusCodes[T any](states ...int) CallResOptionFunc[T] { 59 | return Assertion[T](func(res Response) error { 60 | if slices.Includes(states, res.Status()) { 61 | return nil 62 | } 63 | return errors.Wrapf(ErrUnexpectedStatusCode, "want: %v, have: %d", states, res.Status()) 64 | }) 65 | } 66 | 67 | func Assertion[T any](fn func(res Response) error) CallResOptionFunc[T] { 68 | return func(c *Call[T], res Response) error { 69 | if err := fn(res); err != nil { 70 | return errors.Wrapf(ErrAssertion, err.Error()) 71 | } 72 | 73 | return nil 74 | } 75 | } 76 | 77 | func MockedRes(fn func(response Response)) ResOption { 78 | return ResOptionFunc(func(res Response) (err error) { 79 | fn(res) 80 | return 81 | }) 82 | } 83 | 84 | func ReadStreamChan[T any](rc io.ReadCloser, factory StreamFactory[T], out chan<- T) (err error) { 85 | defer func() { 86 | close(out) 87 | }() 88 | err = ReadStream[T](rc, factory, func(item T) bool { 89 | out <- item 90 | return true 91 | }) 92 | 93 | return 94 | } 95 | 96 | func ReadStream[T any](rc io.ReadCloser, factory StreamFactory[T], fn func(T) bool) (err error) { 97 | defer func() { _ = rc.Close() }() 98 | 99 | stream := factory.Get(rc) 100 | keep := true 101 | 102 | for keep && stream.Next(context.TODO()) { 103 | if err = stream.Err(); err != nil { 104 | return 105 | } 106 | 107 | keep = fn(stream.Data()) 108 | } 109 | 110 | return 111 | } 112 | 113 | func ReadJSON[T any](rc io.ReadCloser) (res T, err error) { 114 | defer func() { _ = rc.Close() }() 115 | 116 | if err = json.NewDecoder(rc).Decode(&res); err != nil { 117 | return 118 | } 119 | 120 | return 121 | } 122 | --------------------------------------------------------------------------------