├── .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 | [](https://github.com/sonirico/withttp/actions/workflows/go.yml)
  8 | [](https://goreportcard.com/report/github.com/sonirico/withttp)
  9 | [](https://godoc.org/github.com/sonirico/withttp)
 10 | [](https://opensource.org/licenses/MIT)
 11 | [](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 | 
--------------------------------------------------------------------------------