├── .github └── workflows │ └── CI.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── README.md ├── backoff.go ├── backoff_test.go ├── client.go ├── client_test.go ├── docs └── ravenTree-logo.png ├── err_collections.go ├── go.mod ├── go.sum ├── options.go └── wrapper.go /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: Quality 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: Test with Coverage 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: Install Go 10 | uses: actions/setup-go@v3 11 | with: 12 | go-version: '1.22' 13 | 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | 17 | - name: Install golangci-lint 18 | run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 19 | 20 | - name: Run golangci-lint 21 | run: golangci-lint run ./... 22 | 23 | - name: Run Unit tests 24 | run: | 25 | go test -race -covermode atomic -coverprofile=covprofile ./... 26 | 27 | - name: Install goveralls 28 | run: go install github.com/mattn/goveralls@latest 29 | 30 | - name: Send coverage 31 | uses: shogo82148/actions-goveralls@v1 32 | with: 33 | path-to-profile: covprofile 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/go,goland,goland+all,goland+iml,intellij,intellij+all,intellij+iml,windows,macos,linux 2 | # Edit at https://www.toptal.com/developers/gitignore\?templates\=go,goland,goland+all,goland+iml,intellij,intellij+all,intellij+iml,windows,macos,linux 3 | 4 | ### Go ### 5 | # If you prefer the allow list template instead of the deny list, see community template: 6 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 7 | # 8 | # Binaries for programs and plugins 9 | *.exe 10 | *.exe~ 11 | *.dll 12 | *.so 13 | *.dylib 14 | 15 | # Test binary, built with `go test -c` 16 | *.test 17 | 18 | # Output of the go coverage tool, specifically when used with LiteIDE 19 | *.out 20 | 21 | # Dependency directories (remove the comment below to include it) 22 | # vendor/ 23 | 24 | # Go workspace file 25 | go.work 26 | 27 | ### GoLand ### 28 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 29 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 30 | 31 | # User-specific stuff 32 | .idea/**/workspace.xml 33 | .idea/**/tasks.xml 34 | .idea/**/usage.statistics.xml 35 | .idea/**/dictionaries 36 | .idea/**/shelf 37 | 38 | # AWS User-specific 39 | .idea/**/aws.xml 40 | 41 | # Generated files 42 | .idea/**/contentModel.xml 43 | 44 | # Sensitive or high-churn files 45 | .idea/**/dataSources/ 46 | .idea/**/dataSources.ids 47 | .idea/**/dataSources.local.xml 48 | .idea/**/sqlDataSources.xml 49 | .idea/**/dynamic.xml 50 | .idea/**/uiDesigner.xml 51 | .idea/**/dbnavigator.xml 52 | 53 | # Gradle 54 | .idea/**/gradle.xml 55 | .idea/**/libraries 56 | 57 | # Gradle and Maven with auto-import 58 | # When using Gradle or Maven with auto-import, you should exclude module files, 59 | # since they will be recreated, and may cause churn. Uncomment if using 60 | # auto-import. 61 | # .idea/artifacts 62 | # .idea/compiler.xml 63 | # .idea/jarRepositories.xml 64 | # .idea/modules.xml 65 | # .idea/*.iml 66 | # .idea/modules 67 | # *.iml 68 | # *.ipr 69 | 70 | # CMake 71 | cmake-build-*/ 72 | 73 | # Mongo Explorer plugin 74 | .idea/**/mongoSettings.xml 75 | 76 | # File-based project format 77 | *.iws 78 | 79 | # IntelliJ 80 | out/ 81 | 82 | # mpeltonen/sbt-idea plugin 83 | .idea_modules/ 84 | 85 | # JIRA plugin 86 | atlassian-ide-plugin.xml 87 | 88 | # Cursive Clojure plugin 89 | .idea/replstate.xml 90 | 91 | # SonarLint plugin 92 | .idea/sonarlint/ 93 | 94 | # Crashlytics plugin (for Android Studio and IntelliJ) 95 | com_crashlytics_export_strings.xml 96 | crashlytics.properties 97 | crashlytics-build.properties 98 | fabric.properties 99 | 100 | # Editor-based Rest Client 101 | .idea/httpRequests 102 | 103 | # Android studio 3.1+ serialized cache file 104 | .idea/caches/build_file_checksums.ser 105 | 106 | ### GoLand Patch ### 107 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186\#issuecomment-215987721 108 | 109 | # *.iml 110 | # modules.xml 111 | # .idea/misc.xml 112 | # *.ipr 113 | 114 | # Sonarlint plugin 115 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 116 | .idea/**/sonarlint/ 117 | 118 | # SonarQube Plugin 119 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 120 | .idea/**/sonarIssues.xml 121 | 122 | # Markdown Navigator plugin 123 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 124 | .idea/**/markdown-navigator.xml 125 | .idea/**/markdown-navigator-enh.xml 126 | .idea/**/markdown-navigator/ 127 | 128 | # Cache file creation bug 129 | # See https://youtrack.jetbrains.com/issue/JBR-2257 130 | .idea/$CACHE_FILE$ 131 | 132 | # CodeStream plugin 133 | # https://plugins.jetbrains.com/plugin/12206-codestream 134 | .idea/codestream.xml 135 | 136 | # Azure Toolkit for IntelliJ plugin 137 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 138 | .idea/**/azureSettings.xml 139 | 140 | ### GoLand+all ### 141 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 142 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 143 | 144 | # User-specific stuff 145 | 146 | # AWS User-specific 147 | 148 | # Generated files 149 | 150 | # Sensitive or high-churn files 151 | 152 | # Gradle 153 | 154 | # Gradle and Maven with auto-import 155 | # When using Gradle or Maven with auto-import, you should exclude module files, 156 | # since they will be recreated, and may cause churn. Uncomment if using 157 | # auto-import. 158 | # .idea/artifacts 159 | # .idea/compiler.xml 160 | # .idea/jarRepositories.xml 161 | # .idea/modules.xml 162 | # .idea/*.iml 163 | # .idea/modules 164 | # *.iml 165 | # *.ipr 166 | 167 | # CMake 168 | 169 | # Mongo Explorer plugin 170 | 171 | # File-based project format 172 | 173 | # IntelliJ 174 | 175 | # mpeltonen/sbt-idea plugin 176 | 177 | # JIRA plugin 178 | 179 | # Cursive Clojure plugin 180 | 181 | # SonarLint plugin 182 | 183 | # Crashlytics plugin (for Android Studio and IntelliJ) 184 | 185 | # Editor-based Rest Client 186 | 187 | # Android studio 3.1+ serialized cache file 188 | 189 | ### GoLand+all Patch ### 190 | # Ignore everything but code style settings and run configurations 191 | # that are supposed to be shared within teams. 192 | 193 | .idea/* 194 | 195 | !.idea/codeStyles 196 | !.idea/runConfigurations 197 | 198 | ### GoLand+iml ### 199 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 200 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 201 | 202 | # User-specific stuff 203 | 204 | # AWS User-specific 205 | 206 | # Generated files 207 | 208 | # Sensitive or high-churn files 209 | 210 | # Gradle 211 | 212 | # Gradle and Maven with auto-import 213 | # When using Gradle or Maven with auto-import, you should exclude module files, 214 | # since they will be recreated, and may cause churn. Uncomment if using 215 | # auto-import. 216 | # .idea/artifacts 217 | # .idea/compiler.xml 218 | # .idea/jarRepositories.xml 219 | # .idea/modules.xml 220 | # .idea/*.iml 221 | # .idea/modules 222 | # *.iml 223 | # *.ipr 224 | 225 | # CMake 226 | 227 | # Mongo Explorer plugin 228 | 229 | # File-based project format 230 | 231 | # IntelliJ 232 | 233 | # mpeltonen/sbt-idea plugin 234 | 235 | # JIRA plugin 236 | 237 | # Cursive Clojure plugin 238 | 239 | # SonarLint plugin 240 | 241 | # Crashlytics plugin (for Android Studio and IntelliJ) 242 | 243 | # Editor-based Rest Client 244 | 245 | # Android studio 3.1+ serialized cache file 246 | 247 | ### GoLand+iml Patch ### 248 | # Reason: https://github.com/joeblau/gitignore.io/issues/186\#issuecomment-249601023 249 | 250 | *.iml 251 | modules.xml 252 | .idea/misc.xml 253 | *.ipr 254 | 255 | ### Intellij ### 256 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 257 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 258 | 259 | # User-specific stuff 260 | 261 | # AWS User-specific 262 | 263 | # Generated files 264 | 265 | # Sensitive or high-churn files 266 | 267 | # Gradle 268 | 269 | # Gradle and Maven with auto-import 270 | # When using Gradle or Maven with auto-import, you should exclude module files, 271 | # since they will be recreated, and may cause churn. Uncomment if using 272 | # auto-import. 273 | # .idea/artifacts 274 | # .idea/compiler.xml 275 | # .idea/jarRepositories.xml 276 | # .idea/modules.xml 277 | # .idea/*.iml 278 | # .idea/modules 279 | # *.iml 280 | # *.ipr 281 | 282 | # CMake 283 | 284 | # Mongo Explorer plugin 285 | 286 | # File-based project format 287 | 288 | # IntelliJ 289 | 290 | # mpeltonen/sbt-idea plugin 291 | 292 | # JIRA plugin 293 | 294 | # Cursive Clojure plugin 295 | 296 | # SonarLint plugin 297 | 298 | # Crashlytics plugin (for Android Studio and IntelliJ) 299 | 300 | # Editor-based Rest Client 301 | 302 | # Android studio 3.1+ serialized cache file 303 | 304 | ### Intellij Patch ### 305 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186\#issuecomment-215987721 306 | 307 | # *.iml 308 | # modules.xml 309 | # .idea/misc.xml 310 | # *.ipr 311 | 312 | # Sonarlint plugin 313 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 314 | 315 | # SonarQube Plugin 316 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 317 | 318 | # Markdown Navigator plugin 319 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 320 | 321 | # Cache file creation bug 322 | # See https://youtrack.jetbrains.com/issue/JBR-2257 323 | 324 | # CodeStream plugin 325 | # https://plugins.jetbrains.com/plugin/12206-codestream 326 | 327 | # Azure Toolkit for IntelliJ plugin 328 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 329 | 330 | ### Intellij+all ### 331 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 332 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 333 | 334 | # User-specific stuff 335 | 336 | # AWS User-specific 337 | 338 | # Generated files 339 | 340 | # Sensitive or high-churn files 341 | 342 | # Gradle 343 | 344 | # Gradle and Maven with auto-import 345 | # When using Gradle or Maven with auto-import, you should exclude module files, 346 | # since they will be recreated, and may cause churn. Uncomment if using 347 | # auto-import. 348 | # .idea/artifacts 349 | # .idea/compiler.xml 350 | # .idea/jarRepositories.xml 351 | # .idea/modules.xml 352 | # .idea/*.iml 353 | # .idea/modules 354 | # *.iml 355 | # *.ipr 356 | 357 | # CMake 358 | 359 | # Mongo Explorer plugin 360 | 361 | # File-based project format 362 | 363 | # IntelliJ 364 | 365 | # mpeltonen/sbt-idea plugin 366 | 367 | # JIRA plugin 368 | 369 | # Cursive Clojure plugin 370 | 371 | # SonarLint plugin 372 | 373 | # Crashlytics plugin (for Android Studio and IntelliJ) 374 | 375 | # Editor-based Rest Client 376 | 377 | # Android studio 3.1+ serialized cache file 378 | 379 | ### Intellij+all Patch ### 380 | # Ignore everything but code style settings and run configurations 381 | # that are supposed to be shared within teams. 382 | 383 | 384 | 385 | ### Intellij+iml ### 386 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 387 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 388 | 389 | # User-specific stuff 390 | 391 | # AWS User-specific 392 | 393 | # Generated files 394 | 395 | # Sensitive or high-churn files 396 | 397 | # Gradle 398 | 399 | # Gradle and Maven with auto-import 400 | # When using Gradle or Maven with auto-import, you should exclude module files, 401 | # since they will be recreated, and may cause churn. Uncomment if using 402 | # auto-import. 403 | # .idea/artifacts 404 | # .idea/compiler.xml 405 | # .idea/jarRepositories.xml 406 | # .idea/modules.xml 407 | # .idea/*.iml 408 | # .idea/modules 409 | # *.iml 410 | # *.ipr 411 | 412 | # CMake 413 | 414 | # Mongo Explorer plugin 415 | 416 | # File-based project format 417 | 418 | # IntelliJ 419 | 420 | # mpeltonen/sbt-idea plugin 421 | 422 | # JIRA plugin 423 | 424 | # Cursive Clojure plugin 425 | 426 | # SonarLint plugin 427 | 428 | # Crashlytics plugin (for Android Studio and IntelliJ) 429 | 430 | # Editor-based Rest Client 431 | 432 | # Android studio 3.1+ serialized cache file 433 | 434 | ### Intellij+iml Patch ### 435 | # Reason: https://github.com/joeblau/gitignore.io/issues/186\#issuecomment-249601023 436 | 437 | 438 | ### Linux ### 439 | *~ 440 | 441 | # temporary files which can be created if a process still has a handle open of a deleted file 442 | .fuse_hidden* 443 | 444 | # KDE directory preferences 445 | .directory 446 | 447 | # Linux trash folder which might appear on any partition or disk 448 | .Trash-* 449 | 450 | # .nfs files are created when an open file is removed but is still being accessed 451 | .nfs* 452 | 453 | ### macOS ### 454 | # General 455 | .DS_Store 456 | .AppleDouble 457 | .LSOverride 458 | 459 | # Icon must end with two 460 | Icon 461 | 462 | 463 | # Thumbnails 464 | ._* 465 | 466 | # Files that might appear in the root of a volume 467 | .DocumentRevisions-V100 468 | .fseventsd 469 | .Spotlight-V100 470 | .TemporaryItems 471 | .Trashes 472 | .VolumeIcon.icns 473 | .com.apple.timemachine.donotpresent 474 | 475 | # Directories potentially created on remote AFP share 476 | .AppleDB 477 | .AppleDesktop 478 | Network Trash Folder 479 | Temporary Items 480 | .apdisk 481 | 482 | ### macOS Patch ### 483 | # iCloud generated files 484 | *.icloud 485 | 486 | ### Windows ### 487 | # Windows thumbnail cache files 488 | Thumbs.db 489 | Thumbs.db:encryptable 490 | ehthumbs.db 491 | ehthumbs_vista.db 492 | 493 | # Dump file 494 | *.stackdump 495 | 496 | # Folder config file 497 | [Dd]esktop.ini 498 | 499 | # Recycle Bin used on file shares 500 | $RECYCLE.BIN/ 501 | 502 | # Windows Installer files 503 | *.cab 504 | *.msi 505 | *.msix 506 | *.msm 507 | *.msp 508 | 509 | # Windows shortcuts 510 | *.lnk 511 | 512 | # End of https://www.toptal.com/developers/gitignore/api/go,goland,goland+all,goland+iml,intellij,intellij+all,intellij+iml,windows,macos,linux 513 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable-all: true 3 | enable: 4 | - unused 5 | - goconst 6 | - gofmt 7 | - goimports 8 | - mnd 9 | - gosimple 10 | - govet 11 | - unparam 12 | - nilerr 13 | - errcheck 14 | - errorlint 15 | - exhaustive 16 | - ineffassign 17 | - predeclared 18 | - typecheck 19 | - asciicheck 20 | - lll 21 | - wsl 22 | - prealloc 23 | - nestif 24 | - misspell 25 | - makezero 26 | - gocognit 27 | - varnamelen 28 | - noctx 29 | - copyloopvar 30 | 31 | linters-settings: 32 | varnamelen: 33 | min-name-length: 2 34 | ignore-decls: 35 | - c echo.Context 36 | - t testing.T 37 | - w http.ResponseWriter 38 | - r *http.Request 39 | - s strategy 40 | - b *Backoff 41 | 42 | 43 | 44 | issues: 45 | exclude-dirs: 46 | - mocks 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Andres Puello 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Raven Tree 2 | 3 | --- 4 |

5 | 6 |

7 | Latest Version 8 | 9 | Coverage Status 10 | 11 | 12 | 13 |

14 | 15 | --- 16 | Is a lightweight Go library designed to simplify HTTP requests by providing an easy-to-use interface, built-in support 17 | for various HTTP methods, accepting retry handling, and more. 18 | 19 | ## Installation 20 | 21 | To use can install it via `go get`: 22 | 23 | ```bash 24 | go get github.com/AndresXLP/ravenTree 25 | ``` 26 | 27 | --- 28 | 29 | ## Usage 30 | 31 | ```go 32 | package main 33 | 34 | import ( 35 | "context" 36 | "fmt" 37 | "log" 38 | "net/http" 39 | "time" 40 | 41 | "github.com/AndresXLP/ravenTree" 42 | ) 43 | 44 | func main() { 45 | tree := ravenTree.NewRavensTree() 46 | 47 | options := &ravenTree.Options{ 48 | Host: "http://localhost:8080", 49 | Path: "/api/resource", 50 | Method: http.MethodGet, 51 | QueryParams: map[string]string{"code": "123"}, 52 | Headers: map[string]string{"Authorization": "Bearer 1234"}, 53 | Timeout: 5 * time.Second, 54 | RetryCount: 3, 55 | Backoff: ravenTree.NewBackoff( 56 | ravenTree.WithStrategy(ravenTree.Exponential), 57 | ravenTree.WithBackoffDelay(3*time.Second), 58 | ravenTree.WithMaxDelay(10*time.Second), 59 | ), 60 | } 61 | 62 | resp, err := tree.SendRaven(context.Background(), options) 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | 67 | fmt.Println(resp.ParseBodyToString()) 68 | } 69 | 70 | ``` 71 | 72 | --- 73 | 74 | ### Methods Provided 75 | 76 | SendRaven: This method sends an HTTP request based on the provided Options. It supports different HTTP methods such as 77 | GET, POST, PUT, DELETE, etc. 78 | 79 | ### Body Management 80 | 81 | The Body field in the Options struct can accept any type of data that can be marshaled into JSON. The library 82 | automatically handles the marshaling of the Body when sending the request. 83 | 84 | ### Headers and Query Parameters 85 | 86 | By default, the ***Content-Type*** header is set to ***application/json***. 87 | 88 | You can add additional headers and query parameters using the **Headers** and **QueryParams** fields in the Options 89 | struct. 90 | 91 | ### Timeout and Retry Options 92 | 93 | - **Timeout**: Specifies the maximum duration for a request. If the request takes longer than this duration, it will be 94 | aborted, and an error will be returned. 95 |

96 | - **RetryCount**: Specifies the number of times to retry the request if it fails. This is useful for handling transient 97 | errors or network issues. The library will automatically retry the request up to the specified number of attempts. 98 |

99 | 100 | ### Backoff Options 101 | 102 | The `Backoff` struct defines the strategy for implementing backoff delays in retry operations with the following fields: 103 | 104 | - **BackoffDelay**: Specifies the duration to wait before the next retry. 105 | - **MaxDelay**: Specifies the maximum duration for backoff delays. 106 | - **Strategy**: Determines the type of backoff (Default, Linear, or Exponential). 107 | 108 | ### Creating a New Backoff 109 | 110 | Use the `NewBackoff` function to create a new `Backoff` with optional parameters: 111 | 112 | - If no options are provided, it defaults to: 113 | - `BackoffDelay`: 0 seconds 114 | - `MaxDelay`: 10 seconds 115 | - `Strategy`: Default 116 |

117 | - Note: If `MaxDelay` is set to a value less than `BackoffDelay`, `MaxDelay` will be updated to match `BackoffDelay` to ensure 118 | valid configuration. 119 | 120 | ### Example Usage 121 | 122 | ```go 123 | package main 124 | 125 | import ( 126 | "time" 127 | 128 | "github.com/AndresXLP/ravenTree" 129 | ) 130 | 131 | func main() { 132 | options := &ravenTree.Options{ 133 | Backoff: ravenTree.NewBackoff( 134 | WithStrategy(Linear), 135 | WithBackoffDelay(2*time.Second), 136 | WithMaxDelay(30*time.Second), 137 | ), 138 | } 139 | 140 | } 141 | 142 | ``` 143 | 144 | ### Error Handling 145 | 146 | Always check for errors after calling SendRaven. If the request fails, the error will provide information about what 147 | went wrong. 148 | 149 | ### Thematic Inspiration 150 | 151 | The name **Raven Tree** reflects the connection to the mystical ravens that serve as messengers in both Game of Thrones 152 | and Norse mythology, symbolizing communication, wisdom, and the passage of information. 153 | 154 | Just as these ravens carry messages across great distances, **Raven Tree** aims to facilitate seamless communication 155 | between your application and external APIs. 156 | 157 | --- 158 | 159 | ## Authors 160 | 161 | - [@andresxlp](https://www.github.com/andresxlp) 162 | 163 | --- 164 | 165 | ### Contributing 166 | 167 | Contributions are welcome! Please open an issue or submit a pull request for any features or fixes you want to add. 168 | 169 | ## License 170 | 171 | The project is licensed under the [MIT License](https://choosealicense.com/licenses/mit/) 172 | -------------------------------------------------------------------------------- /backoff.go: -------------------------------------------------------------------------------- 1 | package ravenTree 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type strategy int 8 | 9 | const ( 10 | // Default sets BackoffDelay to 0 seconds. 11 | // This strategy does not impose any additional delay between retries. 12 | Default strategy = iota 13 | 14 | // Lineal sets BackoffDelay to 1 second and increases the delay linearly with each retry. 15 | // For example, the delays will be 1s, 2s, 3s, etc., until MaxDelay is reached. 16 | Lineal 17 | 18 | // Exponential sets BackoffDelay to 1 second and doubles the delay with each retry. 19 | // For example, the delays will be 1s, 2s, 4s, 8s, etc., until MaxDelay is reached. 20 | Exponential 21 | ) 22 | 23 | const ( 24 | tenSecond = 10 * time.Second 25 | oneSecond = 1 * time.Second 26 | ) 27 | 28 | // Backoff defines the strategy for implementing backoff delays in retry operations. 29 | // Fields: 30 | // - BackoffDelay: Specifies the duration to wait before the next retry. 31 | // - MaxDelay: Specifies the maximum duration to 32 | // - Strategy: Determines the type of backoff (Default, Lineal, or Exponential). 33 | type Backoff struct { 34 | BackoffDelay time.Duration 35 | MaxDelay time.Duration 36 | Strategy strategy 37 | } 38 | 39 | type BackoffOptions func(*Backoff) 40 | 41 | // NewBackoff creates a new Backoff with optional parameters. 42 | // 43 | // If no options are provided, it defaults to: 44 | // - BackoffDelay: 0 seconds 45 | // - MaxDelay: 10 seconds 46 | // - strategy: Default 47 | // 48 | // If MaxDelay is set to a value less than BackoffDelay, MaxDelay will be updated 49 | // to match BackoffDelay to ensure valid configuration. 50 | func NewBackoff(opts ...BackoffOptions) *Backoff { 51 | backoff := &Backoff{ 52 | BackoffDelay: time.Duration(0), 53 | MaxDelay: tenSecond, 54 | Strategy: Default, 55 | } 56 | 57 | for _, opt := range opts { 58 | opt(backoff) 59 | } 60 | 61 | if backoff.MaxDelay < backoff.BackoffDelay { 62 | backoff.MaxDelay = backoff.BackoffDelay 63 | } 64 | 65 | return backoff 66 | } 67 | 68 | // WithStrategy sets the backoff strategy type (Default, Lineal, Exponential). 69 | // 70 | // If s is not a valid strategy, the function will default to the Default strategy. 71 | func WithStrategy(s strategy) BackoffOptions { 72 | return func(b *Backoff) { 73 | switch s { 74 | case Lineal, Exponential: 75 | b.Strategy = s 76 | b.BackoffDelay = oneSecond 77 | case Default: 78 | b.Strategy = Default 79 | default: 80 | b.Strategy = Default 81 | } 82 | } 83 | } 84 | 85 | // WithBackoffDelay sets an initial backoff delay. 86 | func WithBackoffDelay(delay time.Duration) BackoffOptions { 87 | return func(b *Backoff) { 88 | b.BackoffDelay = delay 89 | } 90 | } 91 | 92 | // WithMaxDelay set the maximum backoff delay. 93 | func WithMaxDelay(delay time.Duration) BackoffOptions { 94 | return func(b *Backoff) { 95 | b.MaxDelay = delay 96 | } 97 | } 98 | 99 | // Next applies the specified backoff strategy to wait before the next retry, adjusting the delay 100 | // based on the strategy (Default, Lineal, or Exponential). 101 | // 102 | // The delay will not exceed maxDelay. 103 | func (b *Backoff) Next() { 104 | switch b.Strategy { 105 | case Default: 106 | time.Sleep(b.BackoffDelay) 107 | 108 | case Lineal: 109 | time.Sleep(b.BackoffDelay) 110 | b.BackoffDelay += oneSecond 111 | 112 | case Exponential: 113 | time.Sleep(b.BackoffDelay) 114 | b.BackoffDelay *= 2 115 | } 116 | 117 | if b.BackoffDelay >= b.MaxDelay { 118 | b.BackoffDelay = b.MaxDelay 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /backoff_test.go: -------------------------------------------------------------------------------- 1 | package ravenTree_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | tree "github.com/AndresXLP/ravenTree" 8 | ) 9 | 10 | func TestNewBackoff_WithDefaultValues(t *testing.T) { 11 | maxBackoffDelay := 10 * time.Second 12 | backoff := tree.NewBackoff( 13 | tree.WithStrategy(tree.Default)) 14 | 15 | if backoff.BackoffDelay != 0 { 16 | t.Errorf("Expected BackoffDelay to be 0, got %v", backoff.BackoffDelay) 17 | } 18 | 19 | if backoff.MaxDelay != maxBackoffDelay { 20 | t.Errorf("Expected MaxDelay to be %v, got %v", maxBackoffDelay, backoff.MaxDelay) 21 | } 22 | 23 | if backoff.Strategy != tree.Default { 24 | t.Errorf("Expected strategy to be Default, got %v", backoff.Strategy) 25 | } 26 | 27 | since := time.Now() 28 | 29 | backoff.Next() 30 | 31 | elapsed := time.Since(since) 32 | 33 | tolerance := 50 * time.Microsecond 34 | if elapsed > tolerance { 35 | t.Errorf("Expected elapsed to be within %v, got %v", tolerance, elapsed) 36 | } 37 | } 38 | 39 | func TestNewBackoff_WithBackoffOptionsLinealStrategy(t *testing.T) { 40 | customBackoffDelay := 3 * time.Second 41 | customMaxDelay := 5 * time.Second 42 | backoff := tree.NewBackoff( 43 | tree.WithStrategy(tree.Lineal), 44 | tree.WithBackoffDelay(customBackoffDelay), 45 | tree.WithMaxDelay(customMaxDelay), 46 | ) 47 | 48 | if backoff.BackoffDelay != customBackoffDelay { 49 | t.Errorf("Expected BackoffDelay to be %v, got %v", customBackoffDelay, backoff.BackoffDelay) 50 | } 51 | 52 | if backoff.MaxDelay != customMaxDelay { 53 | t.Errorf("Expected MaxDelay to be %v, got %v", customMaxDelay, backoff.MaxDelay) 54 | } 55 | 56 | if backoff.Strategy != tree.Lineal { 57 | t.Errorf("Expected Strategy to be %v, got %v", tree.Lineal, backoff.Strategy) 58 | } 59 | 60 | since := time.Now() 61 | 62 | backoff.Next() 63 | 64 | elapsed := time.Since(since) 65 | 66 | tolerance := customBackoffDelay + 50*time.Millisecond 67 | if elapsed > tolerance { 68 | t.Errorf("Expected elapsed to be %v, got %v", tolerance, elapsed) 69 | } 70 | } 71 | 72 | func TestNewBackoff_WithBackoffOptionsExponentialStrategy(t *testing.T) { 73 | backoffDelayExpected := 1 * time.Second 74 | backoff := tree.NewBackoff( 75 | tree.WithStrategy(tree.Exponential), 76 | ) 77 | 78 | if backoff.BackoffDelay != backoffDelayExpected { 79 | t.Errorf("Expected BackoffDelay to be %v, got %v", backoffDelayExpected, backoff.BackoffDelay) 80 | } 81 | 82 | if backoff.Strategy != tree.Exponential { 83 | t.Errorf("Expected Strategy to be %v, got %v", tree.Exponential, backoff.Strategy) 84 | } 85 | 86 | since := time.Now() 87 | 88 | backoff.Next() 89 | 90 | elapsed := time.Since(since) 91 | 92 | tolerance := backoffDelayExpected + 50*time.Millisecond 93 | if elapsed > tolerance { 94 | t.Errorf("Expected elapsed to be %v, got %v", tolerance, elapsed) 95 | } 96 | } 97 | 98 | func TestNewBackoff_WithBackoffOptionsMaxDelayLessThanBackoffDelay(t *testing.T) { 99 | overrideDelay := 5 * time.Second 100 | backoff := tree.NewBackoff( 101 | tree.WithStrategy(tree.Lineal), 102 | tree.WithBackoffDelay(overrideDelay), 103 | tree.WithMaxDelay(1*time.Second), 104 | ) 105 | 106 | if backoff.MaxDelay != overrideDelay { 107 | t.Errorf("Expected MaxDelay to be %v, got %v", overrideDelay, backoff.MaxDelay) 108 | } 109 | 110 | since := time.Now() 111 | 112 | backoff.Next() 113 | 114 | elapsed := time.Since(since) 115 | 116 | tolerance := overrideDelay + 50*time.Millisecond 117 | if elapsed > tolerance { 118 | t.Errorf("Expected elapsed to be %v, got %v", tolerance, elapsed) 119 | } 120 | } 121 | 122 | func TestNewBackoff_WithBackoffOptionsInvalidStrategy(t *testing.T) { 123 | backoff := tree.NewBackoff( 124 | tree.WithStrategy(5), 125 | ) 126 | 127 | if backoff.Strategy != tree.Default { 128 | t.Errorf("Expected Strategy to be %v, got %v", tree.Default, backoff.Strategy) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package ravenTree 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | const ( 10 | zero = 0 11 | defaultTimeout = 30 * time.Second 12 | HeaderContentType = "Content-Type" 13 | // MIMEApplicationJSON JavaScript Object Notation (JSON) https://www.rfc-editor.org/rfc/rfc8259 14 | MIMEApplicationJSON = "application/json" 15 | ) 16 | 17 | // Tree defines the methods that any implementation of a RavenTree must provide. 18 | // 19 | // The Tree interface requires a single method, SendRaven, which sends options 20 | // based on the provided Options and returns a WrapperResponse. 21 | type Tree interface { 22 | // SendRaven sends a raven to a specified URL using the provided Options. 23 | // 24 | // This method constructs an HTTP request based on the given context and Options. 25 | // 26 | // By default, it sets the Content-Type header to application/json. 27 | // 28 | // It sends the request using an HTTP client and returns a 29 | // WrapperResponse that encapsulates the HTTP response. 30 | // 31 | // Parameters: 32 | // - ctx: A context.Context to control the request's lifecycle and manage timeouts. 33 | // - opt: A pointer to an Options struct that contains the necessary configuration 34 | // for the request. 35 | // 36 | // Returns: 37 | // - WrapperResponse: A wrapper around the HTTP response. 38 | // - error: An error if the request fails at any point, or nil if the request is successful. 39 | SendRaven(ctx context.Context, opt *Options) (WrapperResponse, error) 40 | } 41 | 42 | type raven struct { 43 | client *http.Client 44 | } 45 | 46 | // NewRavensTree creates and returns a new instance of the RavenTree interface. 47 | // 48 | // This function acts as a constructor for the RavenTree implementation, returning 49 | // an instance of `raven`, a private struct that implements the `Tree` interface. 50 | // The returned instance includes an internal `http.Client` configured with a default timeout. 51 | // 52 | // Returns: 53 | // - Tree: An object that implements the RavenTree interface with a pre-configured HTTP client. 54 | func NewRavensTree() Tree { 55 | return &raven{ 56 | client: &http.Client{ 57 | Timeout: defaultTimeout, 58 | }, 59 | } 60 | } 61 | 62 | func (r *raven) SendRaven(ctx context.Context, opt *Options) (WrapperResponse, error) { 63 | URL, err := opt.buildURL() 64 | if err != nil { 65 | return WrapperResponse{}, err 66 | } 67 | 68 | body, err := opt.bodyToBufferBody() 69 | if err != nil { 70 | return WrapperResponse{}, err 71 | } 72 | 73 | opt.defaultOptions() 74 | 75 | resp := &http.Response{} 76 | errs := ErrCollections{} 77 | 78 | if opt.Timeout != time.Duration(0) { 79 | r.client.Timeout = opt.Timeout 80 | } 81 | 82 | for i := 0; i < opt.RetryCount; i++ { 83 | req, err := http.NewRequestWithContext(ctx, opt.Method, URL, &body) 84 | if err != nil { 85 | return WrapperResponse{}, err 86 | } 87 | 88 | req.Header.Add(HeaderContentType, MIMEApplicationJSON) 89 | 90 | if len(opt.Headers) > zero { 91 | for key, value := range opt.Headers { 92 | req.Header.Add(key, value) 93 | } 94 | } 95 | 96 | resp, err = r.client.Do(req) 97 | if err != nil { 98 | errs.Add(err.Error()) 99 | opt.Backoff.Next() 100 | 101 | continue 102 | } 103 | 104 | if resp.StatusCode >= http.StatusInternalServerError { 105 | opt.Backoff.Next() 106 | 107 | continue 108 | } 109 | 110 | errs.CleanCollection() 111 | 112 | break 113 | } 114 | 115 | return WrapperResponse{resp}, errs.HasError() 116 | } 117 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package ravenTree_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log" 7 | "net/http" 8 | "net/http/httptest" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "github.com/AndresXLP/ravenTree" 14 | "github.com/stretchr/testify/suite" 15 | ) 16 | 17 | type response struct { 18 | Data []string `json:"data"` 19 | } 20 | 21 | var ( 22 | queryParams = map[string]string{ 23 | "email": "test@test.com", 24 | "username": "tester", 25 | "test": "true", 26 | } 27 | 28 | headers = map[string]string{ 29 | "Authorization": "Bearer token", 30 | } 31 | 32 | responsePrettyStringExpected = "{\"data\":[\"test@test.com\",\"tester\",\"true\"]}\n" 33 | 34 | responseQueryParamsExpected = response{Data: []string{ 35 | "test@test.com", "tester", "true", 36 | }} 37 | 38 | wrongBody = struct { 39 | Data chan struct{} 40 | }{ 41 | Data: make(chan struct{}), 42 | } 43 | 44 | ctx = context.Background() 45 | ) 46 | 47 | type ravenTreeTestSuite struct { 48 | suite.Suite 49 | underTest ravenTree.Tree 50 | } 51 | 52 | func TestRavenTreeSuite(t *testing.T) { 53 | suite.Run(t, new(ravenTreeTestSuite)) 54 | } 55 | func (suite *ravenTreeTestSuite) SetupTest() { 56 | suite.underTest = ravenTree.NewRavensTree() 57 | } 58 | 59 | func (suite *ravenTreeTestSuite) TestSendRaven_SuccessWithDefaultOptions() { 60 | handler := func(w http.ResponseWriter, r *http.Request) { 61 | // Check request path 62 | suite.Equal("/api/default", r.URL.Path) 63 | // Check request method 64 | suite.Equal(http.MethodGet, r.Method) 65 | 66 | w.WriteHeader(http.StatusOK) 67 | } 68 | 69 | server := httptest.NewServer(http.HandlerFunc(handler)) 70 | defer server.Close() 71 | 72 | options := &ravenTree.Options{ 73 | Host: server.URL, 74 | Path: "/api/default", 75 | Method: http.MethodGet, 76 | } 77 | 78 | resp, err := suite.underTest.SendRaven(ctx, options) 79 | suite.NoError(err) 80 | suite.Equal(http.StatusOK, resp.StatusCode) 81 | } 82 | 83 | func (suite *ravenTreeTestSuite) TestSendRaven_SuccessWithHeadersAndQueryParams() { 84 | handler := func(w http.ResponseWriter, r *http.Request) { 85 | // Check request path 86 | suite.Equal("/api/query-params", r.URL.Path) 87 | // Check request method 88 | suite.Equal(http.MethodGet, r.Method) 89 | // Check expected header 90 | suite.Equal(headers["Authorization"], r.Header.Get("Authorization")) 91 | 92 | // Extract query parameters 93 | email := r.URL.Query().Get("email") 94 | username := r.URL.Query().Get("username") 95 | test := r.URL.Query().Get("test") 96 | 97 | // Create the response struct 98 | resp := response{ 99 | Data: []string{email, username, test}, 100 | } 101 | 102 | w.WriteHeader(http.StatusOK) 103 | 104 | // Encode the response into JSON and write it to the response writer 105 | _ = json.NewEncoder(w).Encode(resp) 106 | } 107 | 108 | server := httptest.NewServer(http.HandlerFunc(handler)) 109 | defer server.Close() 110 | 111 | options := &ravenTree.Options{ 112 | Host: server.URL, 113 | Path: "/api/query-params", 114 | Method: http.MethodGet, 115 | QueryParams: queryParams, 116 | Headers: headers, 117 | } 118 | 119 | resp, err := suite.underTest.SendRaven(ctx, options) 120 | 121 | suite.NoError(err) 122 | suite.Equal(http.StatusOK, resp.StatusCode) 123 | 124 | data := response{} 125 | err = resp.ParseBodyTo(&data) 126 | 127 | suite.NoError(err) 128 | suite.Equal(responseQueryParamsExpected, data) 129 | 130 | stringData := resp.ParseBodyToString() 131 | suite.Equal(responsePrettyStringExpected, stringData) 132 | } 133 | 134 | func (suite *ravenTreeTestSuite) TestSendRaven_SuccessWhenRetryWithoutBackoffStrategy() { 135 | try := 1 136 | mu := &sync.Mutex{} // To prevent race conditions when updating 'try' 137 | 138 | handler := func(w http.ResponseWriter, r *http.Request) { 139 | // Check request path 140 | suite.Equal("/api/retry", r.URL.Path) 141 | // Check request method 142 | suite.Equal(http.MethodGet, r.Method) 143 | 144 | mu.Lock() 145 | if try == 4 { 146 | log.Printf("Successful request on attempt # %d", try) 147 | w.WriteHeader(http.StatusOK) 148 | _, _ = w.Write([]byte(`{}`)) // Empty JSON response 149 | 150 | return 151 | } 152 | 153 | log.Printf("Attempt # %d", try) 154 | 155 | try++ 156 | mu.Unlock() 157 | 158 | time.Sleep(3 * time.Second) // Simulate delay for retries 159 | } 160 | 161 | server := httptest.NewServer(http.HandlerFunc(handler)) 162 | defer server.Close() 163 | 164 | options := &ravenTree.Options{ 165 | Host: server.URL, 166 | Path: "/api/retry", 167 | Method: http.MethodGet, 168 | Timeout: 1 * time.Second, 169 | RetryCount: 5, 170 | } 171 | 172 | since := time.Now() 173 | _, err := suite.underTest.SendRaven(ctx, options) 174 | suite.NoError(err) 175 | 176 | duration := time.Since(since) 177 | expectedDuration := 3 * time.Second 178 | suite.True(duration > expectedDuration && duration < expectedDuration+50*time.Millisecond) 179 | } 180 | 181 | func (suite *ravenTreeTestSuite) TestSendRaven_SuccessWhenRetryWithBackoffLineal() { 182 | try := 1 183 | mu := &sync.Mutex{} // To prevent race conditions when updating 'try' 184 | 185 | handler := func(w http.ResponseWriter, r *http.Request) { 186 | // Check request path 187 | suite.Equal("/api/retry", r.URL.Path) 188 | // Check request method 189 | suite.Equal(http.MethodGet, r.Method) 190 | 191 | mu.Lock() 192 | if try == 4 { 193 | log.Printf("Successful request on attempt # %d", try) 194 | w.WriteHeader(http.StatusOK) 195 | _, _ = w.Write([]byte(`{}`)) // Empty JSON response 196 | 197 | return 198 | } 199 | 200 | log.Printf("Attempt # %d", try) 201 | 202 | try++ 203 | mu.Unlock() 204 | 205 | time.Sleep(3 * time.Second) // Simulate delay for retries 206 | } 207 | 208 | server := httptest.NewServer(http.HandlerFunc(handler)) 209 | defer server.Close() 210 | 211 | options := &ravenTree.Options{ 212 | Host: server.URL, 213 | Path: "/api/retry", 214 | Method: http.MethodGet, 215 | Timeout: 1 * time.Second, 216 | RetryCount: 5, 217 | Backoff: ravenTree.NewBackoff( 218 | ravenTree.WithStrategy(ravenTree.Lineal), 219 | ), 220 | } 221 | 222 | since := time.Now() 223 | _, err := suite.underTest.SendRaven(ctx, options) 224 | suite.NoError(err) 225 | 226 | duration := time.Since(since) 227 | expectedDuration := 9 * time.Second 228 | suite.True(duration > expectedDuration && duration < expectedDuration+50*time.Millisecond) 229 | } 230 | 231 | func (suite *ravenTreeTestSuite) TestSendRaven_SuccessWhenRetryWithBackoffExponential() { 232 | try := 1 233 | mu := &sync.Mutex{} // To prevent race conditions when updating 'try' 234 | 235 | handler := func(w http.ResponseWriter, r *http.Request) { 236 | // Check request path 237 | suite.Equal("/api/retry", r.URL.Path) 238 | // Check request method 239 | suite.Equal(http.MethodGet, r.Method) 240 | 241 | mu.Lock() 242 | if try == 5 { 243 | log.Printf("Successful request on attempt # %d", try) 244 | w.WriteHeader(http.StatusOK) 245 | _, _ = w.Write([]byte(`{}`)) // Empty JSON response 246 | 247 | return 248 | } 249 | 250 | log.Printf("Attempt # %d", try) 251 | 252 | try++ 253 | mu.Unlock() 254 | 255 | time.Sleep(3 * time.Second) // Simulate delay for retries 256 | } 257 | 258 | server := httptest.NewServer(http.HandlerFunc(handler)) 259 | defer server.Close() 260 | 261 | options := &ravenTree.Options{ 262 | Host: server.URL, 263 | Path: "/api/retry", 264 | Method: http.MethodGet, 265 | Timeout: 1 * time.Second, 266 | RetryCount: 5, 267 | Backoff: ravenTree.NewBackoff( 268 | ravenTree.WithStrategy(ravenTree.Exponential), 269 | ravenTree.WithMaxDelay(15*time.Second), 270 | ), 271 | } 272 | 273 | since := time.Now() 274 | _, err := suite.underTest.SendRaven(ctx, options) 275 | suite.NoError(err) 276 | 277 | duration := time.Since(since) 278 | expectedDuration := 19 * time.Second 279 | suite.True(duration > expectedDuration && duration < expectedDuration+50*time.Millisecond) 280 | } 281 | 282 | func (suite *ravenTreeTestSuite) TestSendRaven_FailWhenTimedOut() { 283 | handler := func(w http.ResponseWriter, r *http.Request) { 284 | // Check request path 285 | suite.Equal("/api/timeout", r.URL.Path) 286 | // Check request method 287 | suite.Equal(http.MethodGet, r.Method) 288 | 289 | time.Sleep(2 * time.Second) 290 | } 291 | 292 | server := httptest.NewServer(http.HandlerFunc(handler)) 293 | defer server.Close() 294 | 295 | options := &ravenTree.Options{ 296 | Host: server.URL, 297 | Path: "/api/timeout", 298 | Method: http.MethodGet, 299 | Timeout: 1 * time.Second, 300 | } 301 | 302 | _, err := suite.underTest.SendRaven(ctx, options) 303 | 304 | suite.Error(err) 305 | suite.ErrorContains(err, "context deadline exceeded (Client.Timeout exceeded while awaiting headers)") 306 | } 307 | 308 | func (suite *ravenTreeTestSuite) TestSendRaven_FailWhenInvalidURL() { 309 | options := &ravenTree.Options{ 310 | Host: ":foo", 311 | } 312 | 313 | _, err := suite.underTest.SendRaven(ctx, options) 314 | suite.Error(err) 315 | } 316 | 317 | func (suite *ravenTreeTestSuite) TestSendRaven_FailWhenInvalidPath() { 318 | options := &ravenTree.Options{ 319 | Host: "http://localhost:8080", 320 | Path: ":foo", 321 | } 322 | 323 | _, err := suite.underTest.SendRaven(ctx, options) 324 | suite.Error(err) 325 | } 326 | 327 | func (suite *ravenTreeTestSuite) TestSendRaven_FailRequestInvalidMethod() { 328 | options := &ravenTree.Options{ 329 | Host: "http://localhost:8080", 330 | Path: "/api", 331 | Method: "😰", 332 | } 333 | 334 | _, err := suite.underTest.SendRaven(ctx, options) 335 | suite.Error(err) 336 | } 337 | 338 | func (suite *ravenTreeTestSuite) TestSendRaven_FailWhenInvalidBody() { 339 | options := &ravenTree.Options{ 340 | Host: "http://localhost:8080", 341 | Path: "/api/retry", 342 | Method: http.MethodGet, 343 | Body: wrongBody, 344 | } 345 | 346 | _, err := suite.underTest.SendRaven(ctx, options) 347 | suite.Error(err) 348 | } 349 | 350 | func (suite *ravenTreeTestSuite) TestSendRaven_FailWhenInternalServerError() { 351 | handler := func(w http.ResponseWriter, r *http.Request) { 352 | w.WriteHeader(http.StatusInternalServerError) 353 | } 354 | 355 | server := httptest.NewServer(http.HandlerFunc(handler)) 356 | defer server.Close() 357 | 358 | options := &ravenTree.Options{ 359 | Host: server.URL, 360 | Path: "/api/error", 361 | Method: http.MethodGet, 362 | } 363 | 364 | resp, err := suite.underTest.SendRaven(ctx, options) 365 | suite.NoError(err) 366 | suite.Equal(http.StatusInternalServerError, resp.StatusCode) 367 | } 368 | -------------------------------------------------------------------------------- /docs/ravenTree-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndresXLP/ravenTree/34ed28adf20771ff8ac84b2f5b12154b73f59f42/docs/ravenTree-logo.png -------------------------------------------------------------------------------- /err_collections.go: -------------------------------------------------------------------------------- 1 | package ravenTree 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | "sync" 8 | ) 9 | 10 | type ErrCollections struct { 11 | mutex sync.Mutex 12 | errs []error 13 | } 14 | 15 | func (ec *ErrCollections) Add(errString string) { 16 | ec.mutex.Lock() 17 | defer ec.mutex.Unlock() 18 | ec.errs = append(ec.errs, errors.New(errString)) 19 | } 20 | 21 | func (ec *ErrCollections) Error() string { 22 | errsMessages := make([]string, len(ec.errs)) 23 | ec.mutex.Lock() 24 | defer ec.mutex.Unlock() 25 | 26 | for i, e := range ec.errs { 27 | errsMessages[i] = fmt.Sprintf("%s\n", e.Error()) 28 | } 29 | 30 | return strings.Join(errsMessages, "") 31 | } 32 | 33 | func (ec *ErrCollections) HasError() error { 34 | ec.mutex.Lock() 35 | defer ec.mutex.Unlock() 36 | 37 | if len(ec.errs) == 0 { 38 | return nil 39 | } 40 | 41 | return ec 42 | } 43 | 44 | func (ec *ErrCollections) CleanCollection() { 45 | ec.mutex.Lock() 46 | defer ec.mutex.Unlock() 47 | ec.errs = []error{} 48 | } 49 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/AndresXLP/ravenTree 2 | 3 | go 1.22 4 | 5 | require github.com/stretchr/testify v1.9.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 6 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package ravenTree 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/url" 7 | "time" 8 | ) 9 | 10 | const ( 11 | defaultRetryCount = 1 12 | ) 13 | 14 | type Options struct { 15 | Host string 16 | Path string 17 | Method string 18 | Body interface{} 19 | QueryParams map[string]string 20 | Headers map[string]string 21 | Timeout time.Duration 22 | RetryCount int 23 | Backoff *Backoff 24 | } 25 | 26 | // bodyToBufferBody serializes the Body field into JSON and stores it in a bytes.Buffer. 27 | // 28 | // It marshals the `Body` field of the `Options` struct into a JSON byte slice. If an error occurs 29 | // during the marshalling process, it returns an empty buffer and the error. 30 | // 31 | // Returns: 32 | // - bytes.Buffer: A buffer containing the serialized JSON of the `Body` field. 33 | // - error: An error that occurs during the JSON marshalling process, if any. 34 | func (o *Options) bodyToBufferBody() (bytes.Buffer, error) { 35 | bytesJson, err := json.Marshal(o.Body) 36 | if err != nil { 37 | return bytes.Buffer{}, err 38 | } 39 | 40 | return *bytes.NewBuffer(bytesJson), nil 41 | } 42 | 43 | // buildURL constructs a full URL by combining the Host, Path, and QueryParams fields from the Options struct. 44 | // 45 | // It first parses the `Host` and `Path` fields into URLs. Then, it resolves the path relative to the base URL. 46 | // If `QueryParams` are provided, they are appended to the final URL as query parameters. 47 | // 48 | // Returns: 49 | // - string: The fully constructed URL as a string. 50 | // - error: An error if there is a problem parsing the Host or Path. 51 | func (o *Options) buildURL() (string, error) { 52 | baseURL, err := url.Parse(o.Host) 53 | if err != nil { 54 | return "", err 55 | } 56 | 57 | pathUrl, err := url.Parse(o.Path) 58 | if err != nil { 59 | return "", err 60 | } 61 | 62 | finalURL := baseURL.ResolveReference(pathUrl) 63 | 64 | if len(o.QueryParams) > 0 { 65 | query := finalURL.Query() 66 | for key, value := range o.QueryParams { 67 | query.Add(key, value) 68 | } 69 | 70 | finalURL.RawQuery = query.Encode() 71 | } 72 | 73 | return finalURL.String(), nil 74 | } 75 | 76 | // defaultOptions sets default values for any unset fields in the Options struct. 77 | // If certain fields are not initialized by the user, this method assigns sensible defaults: 78 | // - Backoff: Default to the standard backoff. 79 | // - RetryCount: Defaults to 1. 80 | func (o *Options) defaultOptions() { 81 | if o.Backoff == nil { 82 | o.Backoff = NewBackoff() 83 | } 84 | 85 | if o.RetryCount == zero { 86 | o.RetryCount = defaultRetryCount 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /wrapper.go: -------------------------------------------------------------------------------- 1 | package ravenTree 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | type WrapperResponse struct { 11 | *http.Response 12 | } 13 | 14 | // ParseBodyTo parses the HTTP response body into the provided destination (dest). 15 | // 16 | // This method reads the response body (w.Body) as a byte slice, then attempts 17 | // to unmarshal the JSON content into the provided `dest` interface. 18 | // 19 | // The response body (w.Body) is then restored so it can be read again later, if necessary. 20 | // 21 | // Parameters: 22 | // - dest (interface{}): A pointer to the destination where the parsed JSON 23 | // from the response body will be stored. It can be a struct or map that matches 24 | // the JSON structure. 25 | // 26 | // Returns: 27 | // - error: Returns an error if the reading of the body or the unmarshalling process fails. 28 | func (w *WrapperResponse) ParseBodyTo(dest interface{}) error { 29 | bodyBytes, _ := io.ReadAll(w.Body) 30 | w.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 31 | 32 | return json.Unmarshal(bodyBytes, dest) 33 | } 34 | 35 | // ParseBodyToString reads the HTTP response body and returns it as a string. 36 | // 37 | // This method reads the entire response body (w.Body) into a byte slice, converts it 38 | // to a string, and then restores the body so it can be read again later if needed. 39 | // 40 | // Returns: 41 | // - string: The response body as a string. 42 | func (w *WrapperResponse) ParseBodyToString() string { 43 | bodyBytes, _ := io.ReadAll(w.Body) 44 | w.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 45 | 46 | return string(bodyBytes) 47 | } 48 | --------------------------------------------------------------------------------