├── .circleci └── config.yml ├── .formatter.exs ├── .gitignore ├── BENCHMARK.md ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── assets ├── mojito-full.png └── mojito.png ├── config ├── config.exs └── test.exs ├── lib ├── mojito.ex └── mojito │ ├── application.ex │ ├── base.ex │ ├── config.ex │ ├── conn.ex │ ├── conn_server.ex │ ├── error.ex │ ├── headers.ex │ ├── pool.ex │ ├── pool │ ├── poolboy.ex │ └── poolboy │ │ ├── manager.ex │ │ └── single.ex │ ├── request.ex │ ├── request │ └── single.ex │ ├── response.ex │ ├── telemetry.ex │ └── utils.ex ├── mix.exs ├── mix.lock └── test ├── headers_test.exs ├── mojito_sync_test.exs ├── mojito_test.exs ├── pool ├── poolboy │ ├── manager_test.exs │ └── single_test.exs └── poolboy_test.exs ├── support ├── cert.pem ├── key.pem └── mojito_test_server.ex └── test_helper.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Elixir CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-elixir/ for more details 4 | version: 2 5 | jobs: 6 | build: 7 | docker: 8 | - image: circleci/elixir:1.11 9 | working_directory: ~/app 10 | steps: 11 | - run: mix local.hex --force 12 | - run: mix local.rebar --force 13 | 14 | - checkout 15 | - run: mix format --check-formatted 16 | 17 | # Read about caching dependencies: https://circleci.com/docs/2.0/caching/ 18 | - restore_cache: # restores saved mix cache 19 | keys: # list of cache keys, in decreasing specificity 20 | - v1-mix-cache-{{ .Branch }}-{{ checksum "mix.lock" }} 21 | - v1-mix-cache-{{ .Branch }} 22 | - v1-mix-cache 23 | - restore_cache: # restores saved build cache 24 | keys: 25 | - v1-build-cache-{{ .Branch }} 26 | - v1-build-cache 27 | - run: mix do deps.get, compile # get updated dependencies & compile them 28 | - save_cache: # generate and store mix cache 29 | key: v1-mix-cache-{{ .Branch }}-{{ checksum "mix.lock" }} 30 | paths: "deps" 31 | - save_cache: # don't forget to save a *build* cache, too 32 | key: v1-build-cache-{{ .Branch }} 33 | paths: "_build" 34 | 35 | - run: mix test 36 | 37 | - store_test_results: # upload junit test results for display in Test Summary 38 | # Read more: https://circleci.com/docs/2.0/collect-test-data/ 39 | path: _build/test/lib/mojito # Replace with the name of your :app 40 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | line_length: 80, 5 | trailing_comma: true 6 | ] 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | mojito-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /BENCHMARK.md: -------------------------------------------------------------------------------- 1 | # Benchmarks 2 | 3 | These benchmarks were conducted using 4 | [gamache/httpc_bench](https://github.com/gamache/httpc_bench). 5 | 6 | GET requests lasting 10 ms are performed. 7 | 8 | Clients ran on AWS m5.4xlarge (16 vCPUs, 64 GB RAM) running Ubuntu 18.04. 9 | 10 | The server ran on a separate m5.4xlarge, configured identically. 11 | 12 | Clients: 13 | 14 | * Mojito 0.3.0 (Mint 0.2.1) 15 | * Buoy `b65c06f` 16 | * MachineGun 0.1.5 (Gun 1.3.0) 17 | * Dlhttpc `1072652` 18 | * Hackney 1.15.1 19 | * Ibrowse 4.4.1 20 | * Httpc (OTP 21.2) 21 | * Mint 0.2.1, without pooling or keepalive, as a reference client. 22 | 23 | ## Current results (2019-05-15) 24 | 25 | This benchmark was run after optimizing the servers for high-concurrency 26 | tasks by executing `ulimit -n 12000000` to increase the allowable number 27 | of open files. This configuration is very different from the default 28 | `ulimit -n 1024` on Ubuntu systems. 29 | 30 | The enhanced configuration showed that Buoy and Gun really shine when 31 | they are allowed to stretch their legs. Mojito performs well but has 32 | catch-up work to do! Likely areas of improvement are in pipelining (we 33 | use none, currently) and concurrency-friendly connection checkout in a 34 | similar manner to Shackle or Dispcount. 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 |
ClientPool CountPool SizeConcurrencyReq/secError %
ClientPool CountPool SizeConcurrencyReq/secError %
Buoy11024409610128079.6
Buoy1102481929702979.7
Buoy1102420489627279.5
Buoy11024163848567279.6
Buoy151240968434589.8
Buoy151220487875789.8
Buoy151281927794590
MachineGun110242048729500
Buoy1512163847224790
MachineGun110244096721080
MachineGun110241024712190
Buoy125640966233294.9
Mojito42564096620680.8
MachineGun110248192612051
Mojito81284096593540.8
Mojito42568192593530.8
Buoy1102410245927379.4
Buoy125620485906994.9
Mojito81288192587070.8
Mojito82568192580660.8
Mojito812816384576900.8
Mojito16642048574680.8
Mojito166416384570980.8
Mojito161288192567120.8
Mojito425616384566900.8
Mojito16648192566450.8
Mojito82564096563250.9
Mojito825616384561980.8
Buoy125681925599395.1
Mojito165124096556330.6
Mojito1610242048552720.7
Mojito45124096551310.9
MachineGun1102416384550552.9
Mojito32321024549930.8
Mojito32328192549430.8
Mojito323216384545000.8
Mojito1610244096543810.8
Mojito32648192542400.8
Mojito1612816384538820.9
Mojito110241024538481.2
Mojito45122048538140.8
Mojito16644096537311
Mojito161284096537020.9
Mojito325124096536220.6
Mojito161282048536100.9
Mojito85124096534880.7
Mojito32322048534510.8
Mojito810242048532830.9
Mojito110242048530911.5
Mojito321282048530060.7
Mojito162564096528440.9
Mojito410244096527890.8
Mojito162562048526500.8
Mojito326416384525110.9
Mojito110244096522970.9
Mojito32642048522470.8
Mojito410241024522351.2
Mojito32644096521690.9
Mojito321284096520160.9
Buoy1256163845178595.1
Mojito810244096517800.7
Mojito82562048517410.9
Mojito410242048516270.8
Mojito45128192513880.9
Mojito325122048513870.8
Mojito85122048513780.9
Mojito85128192510080.8
Mojito322562048508340.8
Mojito165122048506300.8
Mojito810241024502041.3
Mojito42562048501081.3
Mojito325121024500341.1
Mojito32641024499680.9
Mojito3225616384499661.8
Buoy151210244970989.8
Mojito42561024496351.5
Mojito162561024496131
Mojito1625616384493301.1
Mojito85121024492641
Mojito32324096492090.9
Mojito322564096491640.8
Mojito81282048491521.2
Mojito321288192487320.9
Mojito162568192483950.9
Mojito81281024483491.5
Mojito45121024481001.2
Mojito3212816384480671.1
Mojito161281024478591.3
Mojito165121024475761.3
Mojito110248192475730.8
Mojito82561024472181.2
Mojito322568192468451.7
Mojito165128192468111.4
Mojito1610241024467211.3
Mojito410248192461951.3
Mojito1651216384461901.6
Mojito321281024453861.2
Mojito322561024451151.2
Mojito16641024450821.4
Mojito451216384443981.2
Mojito8102416384438902.1
MachineGun15121024435290
MachineGun15122048434910
Mojito41284096434760.9
Mojito8648192433270.9
Mojito86416384433000.9
Mojito41288192432400.9
Mojito41282048430670.9
Mojito16324096429780.9
Mojito16328192429360.9
Mojito412816384428920.9
Mojito163216384428580.9
Mojito325128192428171.4
Mojito8642048427180.9
Mojito8644096426620.9
Mojito41281024425560.9
Mojito15122048424970.9
Mojito16322048423670.9
MachineGun15124096423620
Mojito15124096421870.9
Mojito8641024419790.9
Mojito16321024418760.9
Buoy112840964049297.5
Buoy125610244014894.9
MachineGun15128192394881.1
Mojito15121024393621
Mojito15128192377460.9
MachineGun151216384376133.1
Buoy112820483750797.5
Mojito3210248192369270.7
Buoy112881923647697.5
Mojito1610248192339140.8
Ibrowse1328192338474.4
Mojito3251216384335542.2
Ibrowse1324096330023.9
Ibrowse1322048319582.9
Buoy1128163843195697.6
Mojito3210244096312893.1
Buoy112810243095697.4
Mojito810248192309552.4
Mojito151216384302965.2
Ibrowse13216384300914.9
Mojito1102416384285468.6
Dlhttpc1102410242844370.5
Mojito16102416384279332.4
Mojito851216384277612.2
Ibrowse1321024275890.1
Ibrowse1644096269420.2
Ibrowse1642048263860.4
Ibrowse1648192261330.7
Mojito3210242048259694.4
Mojito32102416384249212.8
Ibrowse16416384246180.4
Ibrowse11284096235370
Dlhttpc1102481922352895.2
Ibrowse1641024233080
MachineGun12561024231340.1
MachineGun12562048230580.1
Mojito4102416384229084.3
MachineGun12564096228160.1
Buoy16440962278298.7
Mojito4644096227591
Mojito12561024227021
Mojito46416384226661
Mojito12562048226371
Mojito8328192226201
Mojito12564096226041
Mojito4648192225981
Mojito83216384224691
Mojito8324096224691
Ibrowse11282048224690.1
Mojito4642048224631
Mojito8322048223841
Ibrowse11288192223300
Hackney12561024223160
Mojito4641024222961
Mojito8321024222391
MachineGun12568192220631
Buoy16420482194198.7
Hackney12562048216940
MachineGun125616384214793.3
Ibrowse112816384214370.1
Mojito12568192212201.1
Buoy16481922112498.7
Hackney12564096210310
Ibrowse11281024203330
Buoy164163842024498.8
Dlhttpc151220481996494.9
Buoy16410241959798.7
Hackney12568192190850
Mojito125616384190675.8
Hackney15121024186430
Ibrowse12564096186260
Dlhttpc11024163841851396.5
Hackney15124096181330
Ibrowse12562048179550
Ibrowse12568192179250
Hackney125616384176330
Dlhttpc1102420481757187.6
Hackney15122048175380
Ibrowse125616384171260
Dlhttpc1102440961695792.6
Ibrowse12561024169480
Mojito43216384158056.2
Mojito3210241024155118.5
Hackney15128192152650
Dlhttpc151281921369997.6
Ibrowse15124096136980
Mojito1128163841328514.7
Ibrowse15128192131450
Ibrowse15122048130780
Ibrowse151216384127050
Hackney151216384125790
Buoy13220481249299.4
Ibrowse15121024123690
Buoy13281921165799.4
MachineGun11281024116150.1
MachineGun11282048116060.1
Mojito11281024116031
Mojito11282048115961
Hackney110242048115730
Mojito4324096115711
MachineGun11284096115700.1
Mojito11284096115681
Hackney110241024115660
Mojito4322048115561
Mojito4328192115501
Mojito4321024115171
Hackney11281024114650
Buoy13240961146399.4
Buoy132163841144399.4
Hackney11282048114380
MachineGun11288192114320.9
Hackney11284096114300
Mojito11288192112491.3
Hackney11288192111520
Buoy13210241095699.4
Hackney110244096107250
Hackney112816384105440
Dlhttpc125620481027097.7
Hackney11024819296620
Dlhttpc12561024911496.8
Hackney110241638485510
Dlhttpc15121024839389.9
Ibrowse11024409683650
Dlhttpc15124096831995.9
Ibrowse11024204881490
Ibrowse11024819281210
Mojito1648192799315.3
Dlhttpc12568192796698.7
Ibrowse110241638479620
Ibrowse11024102478280
Dlhttpc151216384696898.2
Dlhttpc125616384672299
MachineGun164102458100.1
MachineGun164409658070.1
MachineGun164204858070.1
Mojito164204858061
Mojito164102458061
Mojito164409658051
Hackney164102457910
Hackney164204857870
Hackney164409657810
Dlhttpc11281024575398.6
Hackney164819257460
Httpc132102457420
Hackney1641638456820
Dlhttpc11282048568099
Httpc11024204855710
Httpc1256102455680
Mojito1324096556217.4
Httpc164102455600
Httpc1128102455430
Httpc1256409655320
Httpc1512204854880
Httpc1512102454670
Httpc1128409654080
Httpc11024409653970
Httpc11024102453400
Httpc1256204852630
Httpc132204852310
Httpc164204851860
Dlhttpc11284096518499.2
Httpc1128204851430
Httpc132819250820
Httpc132409650310
Httpc1256819250130
Httpc164819250010
Httpc1512409649980
Mojito1328192499131.8
Httpc1512819249730
Httpc164409649710
Httpc11024819247180
Httpc1641638445900
Httpc1128819245400
Httpc12561638445250
Httpc1321638444270
Httpc110241638444080
Dlhttpc11288192436499.4
Httpc11281638442980
Httpc15121638442680
Dlhttpc112816384369399.5
Mint11204834742
Mint11102434402
Dlhttpc12564096337498.4
Mint11819233266.6
Dlhttpc1641024317899.4
Dlhttpc1642048317099.5
Mint11409631148.7
MachineGun132204829060.1
Mojito132204829031
Mojito132102429031
Hackney132102428980
Hackney132409628970
Hackney132204828970
Hackney132819228960
MachineGun132102428910
Hackney1321638428850
Dlhttpc1644096262299.6
MachineGun1324096236242.2
Dlhttpc1648192215599.7
Dlhttpc16416384209199.7
MachineGun1648192187177.1
Dlhttpc1322048168199.8
Mint111638416580
Dlhttpc1321024160699.7
Dlhttpc1324096141199.8
Dlhttpc1328192120999.9
Dlhttpc13216384102299.9
MachineGun11281638446797.1
MachineGun1641638419698.8
Mojito164163849698.8
MachineGun132163849699.4
MachineGun13281924899.4
Mojito132163844799.4
375 | 376 | 377 | 378 | 379 | 380 | ## The original benchmark 381 | 382 | With server specs as above, except using Ubuntu's default `ulimit -n 1024`. 383 | This benchmark rightly showed that Mojito is fast, but wrongly showed it 384 | being faster than Buoy. It does tell us that Mojito excels in 385 | resource-constrained environments, or where configuring custom `ulimit`s 386 | is not possible. 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 |
ClientPool CountPool SizeConcurrencyReq/secError %
ClientPool CountPool SizeConcurrencyReq/secError %
Mojito42562048525191.2
Mojito81284096521051.1
Mojito81282048516211
Mojito812816384515380.9
Mojito81288192512871
Mojito42564096511161.2
Mojito16642048510641.1
Mojito32324096509300.9
Mojito16648192508481
Mojito16644096503360.9
Mojito166416384502941
Mojito42568192491341.1
Mojito323216384484701
Mojito32328192481030.9
Mojito32322048479141
Mojito425616384468661.2
Mojito8648192435310.9
Mojito15122048434370.9
Mojito41288192432240.9
Mojito41282048431830.9
Mojito412816384431280.9
Mojito41284096428450.9
Mojito86416384425370.9
Mojito15124096422550.9
Mojito8642048420200.9
Mojito8644096419000.9
Mojito16324096418420.9
Mojito163216384414800.9
Mojito16328192413180.9
Mojito16322048407020.9
Mojito15128192374071
Buoy151281923683389.8
Buoy1512163843494289.6
Buoy151240963441789.8
Buoy151220483188989.8
Mojito151216384308705.6
Mojito45122048269242
Mojito161282048244941.7
Mojito32642048242101.4
Mojito46416384229231
Mojito12562048228431
Mojito8328192227451
Mojito4642048227271
Mojito4644096226981
Mojito4648192226771
Mojito8324096226771
Mojito83216384226441
Mojito12564096225881
Mojito8322048222821
Hackney12562048218850
Mojito12568192213361.1
Hackney12564096212260
Mojito326416384210548.8
Mojito16128163841961811.6
Dlhttpc151220481949395.1
Mojito125616384193105.4
Hackney12568192190330
Buoy125640961869294.9
Buoy125681921853194.9
Mojito825616384184097.2
Buoy1256163841805294.8
Ibrowse12562048176550
Ibrowse12564096176260
Hackney15122048176190
Hackney125616384172720
Mojito45128192171579.5
Mojito82564096170224.8
Ibrowse12568192170070
Buoy125620481697394.9
Hackney15124096168210
Ibrowse125616384165270
Mojito161284096156363.9
Mojito82562048154591.6
Mojito85122048153852
Mojito165122048153341.8
Mojito82568192153329.7
Mojito45124096153185.1
Mojito32644096151796.2
Hackney15128192149540
Dlhttpc151281921488497.3
Mojito32648192148828.5
Mojito162562048147941.9
Mojito325122048147801.9
Mojito1612881921467310.6
Mojito4512163841463814.8
Ibrowse15122048133180
Ibrowse15124096132910
Hackney151216384130610
Ibrowse15128192127870
Mojito321282048123152.6
Ibrowse151216384122710
Mojito322562048120502.6
Mojito321284096116344.6
Mojito11282048116021
Mojito4322048115831
Ibrowse11282048115670.1
Mojito11284096115521
Mojito4324096115401
Dlhttpc1512163841149398.1
Hackney11282048114910
Ibrowse11284096114880
Mojito32128163841146219.3
Hackney11284096114180
Mojito3212881921136110.8
Mojito1625681921127611.8
Mojito11288192112601.3
Ibrowse11288192112320
Hackney11288192111510
Mojito432163841101548.2
Mojito4328192108551.7
Dlhttpc125620481073797.7
Ibrowse112816384106553.8
Hackney112816384105900
Dlhttpc125640961018198.3
Mojito162564096101624.3
Mojito8512163841015314.6
Mojito32256409698524.9
Mojito16512409696735.7
Buoy11284096962697.4
Mojito8512409696177.9
Mojito85128192951811.3
Mojito112816384942547.4
Buoy11288192940397.4
Buoy11282048903697.4
Buoy112816384862497.6
Mojito1625616384852431.6
Dlhttpc12568192811098.7
Dlhttpc15124096799896.3
Mojito325124096753816.2
Dlhttpc125616384715298.8
Mojito165128192669016.5
Mojito322568192654712.7
Mojito1651216384646717.6
Mojito3225616384623421.8
Mojito164204858101
Mojito164409658091
Ibrowse164409658050
Hackney164204857980
Hackney164409657910
Ibrowse164204857910
Ibrowse164819257883.4
Hackney164819257580
Ibrowse16416384575630
Mojito325128192571524.4
Httpc164204856980
Httpc1128204856910
Hackney1641638456730
Httpc1256204855060
Httpc132409654640
Httpc1512204854380
Httpc1512409654360
Httpc1128409653700
Dlhttpc11282048531999
Httpc164409653000
Httpc132204852650
Httpc1128819251730
Dlhttpc11284096517099.2
Httpc1256409650260
Httpc164819248850
Httpc1512819248840
Mojito1648192487849.6
Httpc11281638448280
Buoy1642048479398.7
Buoy1644096477498.7
Httpc1321638445640
Mojito3251216384452519
Httpc15121638445140
Httpc1256819244800
Buoy1648192442798.8
Httpc132819244210
Httpc12561638443670
Buoy16416384432198.8
Dlhttpc11288192412299.4
Httpc1641638440870
Dlhttpc112816384393499.4
Mojito132204829051
Hackney132204829030
Hackney132409629020
Ibrowse13216384290155.1
Ibrowse132409629003.8
Hackney132819228990
Ibrowse1328192289231.5
Ibrowse132204828870.1
Hackney1321638428830
Dlhttpc1642048286099.5
Dlhttpc1644096274599.6
Mojito1324096247759.5
Buoy1322048238699.4
Dlhttpc1648192228299.7
Buoy1324096220399.4
Buoy1328192216099.4
Buoy13216384214499.4
Dlhttpc16416384207299.7
Mojito1328192189774.2
Dlhttpc1322048159299.8
Dlhttpc1324096150099.8
Dlhttpc1328192119599.8
Dlhttpc13216384111999.8
Mint111638425479
Mint11819222751.9
Mint11409620635
Mojito164163849798.8
Mojito132163844799.4
595 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.7.10 (2021-10-27) 4 | 5 | Fixed a bug around starting pools. Thanks, 6 | [@reisub](https://github.com/reisub)! 7 | 8 | Marked compatibility with Telemetry 1.0. Thanks, 9 | [@jchristgit](https://github.com/jchristgit)! 10 | 11 | ## 0.7.9 (2021-07-20) 12 | 13 | Improved docs. Thanks, [@kianmeng](https://github.com/kianmeng)! 14 | 15 | ## 0.7.8 (2021-07-16) 16 | 17 | Fixed a few bugs around connection handling and chunk sizing. Thanks to 18 | [@reisub](https://github.com/reisub), [@fahchen](https://github.com/fahchen), 19 | [@bmteller](https://github.com/bmteller). 20 | 21 | ## 0.7.7 (2021-02-04) 22 | 23 | Added Mojito.Telemetry. Thanks, 24 | [@andyleclair](https://github.com/andyleclair)! And thanks to the 25 | [Finch](https://github.com/keathley/finch) team, whose telemetry 26 | implementation informed this one. 27 | 28 | ## 0.7.6 (2020-12-10) 29 | 30 | Fixed a bug around HTTP/2 responses larger than 64kB. Thanks for the 31 | reports, [@dch](https://github.com/dch) and 32 | [@jayjun](https://github.com/jayjun)! 33 | 34 | Reduced memory footprint of idle Mojito pools by forcing GC after 35 | requests complete. Thanks for the reports, 36 | [@axelson](https://github.com/axelson) and 37 | [@hubertlepicki](https://github.com/hubertlepicki)! 38 | 39 | ## 0.7.5 (2020-11-06) 40 | 41 | Fixed packaging bug in 0.7.4. 42 | 43 | ## 0.7.4 (2020-11-02) 44 | 45 | Fixed handling of Mint error responses. 46 | Thanks, [@alexandremcosta](https://github.com/alexandremcosta)! 47 | 48 | Fixed a Dialyzer warning around keyword lists. 49 | Thanks, [@Vaysman](https://github.com/Vaysman)! 50 | 51 | ## 0.7.3 (2020-06-22) 52 | 53 | Moved core Mojito functions into separate `Mojito.Base` module for 54 | easier interoperation with mocking libraries like Mox. Thanks, 55 | [@bcardarella](https://github.com/bcardarella)! 56 | 57 | ## 0.7.2 (2020-06-19) 58 | 59 | Fixed typespecs. 60 | 61 | ## 0.7.1 (2020-06-17) 62 | 63 | Fixed bug where Mojito failed to correctly handle responses with 64 | a `connection: close` header. Thanks, 65 | [@bmteller](https://github.com/bmteller)! 66 | 67 | ## 0.7.0 (2020-06-17) 68 | 69 | Added the `:max_body_size` option, to prevent a response body from 70 | growing too large. Thanks, [@rozap](https://github.com/rozap)! 71 | 72 | ## 0.6.4 (2020-05-20) 73 | 74 | Fixed bug where sending an empty string request body would hang certain 75 | HTTP/2 requests. Thanks for the report, 76 | [@Overbryd](https://github.com/Overbryd)! 77 | 78 | ## 0.6.3 (2020-03-17) 79 | 80 | `gzip`ped or `deflate`d responses are automatically expanded by 81 | Mojito. Thanks, [@mogorman](https://github.com/mogorman)! 82 | 83 | The Freedom Formatter has been removed. `mix format` is now applied. 84 | 85 | ## 0.6.2 (2020-03-11) 86 | 87 | Header values are now stringified on their way to Mint. Thanks, 88 | [@egze](https://github.com/egze)! 89 | 90 | Timeouts of `:infinity` are now supported. Thanks, 91 | [@t8rsalad](https://github.com/t8rsalad)! 92 | 93 | ## 0.6.1 (2019-12-20) 94 | 95 | Internal refactor to support different pool implementations. No features 96 | were added or changed. 97 | 98 | Code formatting improvements in docs. Thanks, 99 | [@sotojuan](https://github.com/sotojuan)! 100 | 101 | ## 0.6.0 (2019-11-02) 102 | 103 | Upgraded to Mint 1.0. Thanks, [@esvinson](https://github.com/esvinson)! 104 | 105 | Fixed typo in CHANGELOG. Thanks, [@alappe](https://github.com/alappe)! 106 | 107 | ## 0.5.0 (2019-08-21) 108 | 109 | Fixed bug where timed-out responses could arrive in connection with 110 | the next request from that caller. Thanks for the report and the 111 | test case, [@seanedwards](https://github.com/seanedwards)! 112 | 113 | Refactored to use `%Mojito.Request{}` structs more consistently across 114 | internal Mojito functions. 115 | 116 | ## 0.4.0 (2019-08-13) 117 | 118 | Upgraded to Mint 0.4.0. 119 | 120 | Requests are automatically retried when we attempt to reuse a closed 121 | connection. 122 | 123 | Added `Mojito.Headers.auth_header/2` helper for formintg HTTP Basic 124 | `Authorization` header. 125 | 126 | Don't pass the URL fragment to Mint when making requests. 127 | Thanks [@alappe](https://github.com/alappe)! 128 | 129 | Improved examples and docs around making POST requests. 130 | Thanks [@hubertlepicki](https://github.com/hubertlepicki)! 131 | 132 | Removed noisy debug output. 133 | Thanks for the report, [@bcardarella](https://github.com/bcardarella)! 134 | 135 | ## 0.3.0 (2019-05-08) 136 | 137 | Major refactor. 138 | 139 | All end-user requests pass through `Mojito.request/1`, which now 140 | accepts keyword list input as well. `Mojito.request/5` remains 141 | as an alias, and convenience methods for `get/3`, `post/4`, `put/4`, 142 | `patch/4`, `delete/3`, `head/3`, and `options/3` have been added 143 | (thanks, [@danhuynhdev](https://github.com/danhuynhdev)!). 144 | 145 | Connection pools are handled automatically, sorting requests to the 146 | correct pools, starting pools when necessary, and maintaining 147 | multiple redundant pools for GenServer efficiency. 148 | 149 | ## 0.2.2 (2019-04-26) 150 | 151 | Fixed a bug where long requests could exceed the given timeout without 152 | failing (#17). Thanks for the report, 153 | [@mischov](https://github.com/mischov)! 154 | 155 | Improved documentation about receiving `:tcp` and `:ssl` messages. 156 | Thanks for the report, 157 | [@axelson](https://github.com/axelson)! 158 | 159 | Removed an extra `Task` process creation in `Mojito.Pool.request/2`. 160 | 161 | ## 0.2.1 (2019-04-23) 162 | 163 | Refactored `Mojito.request/5` so it doesn't spawn a process. Now all 164 | TCP messages are handled within the caller process. 165 | 166 | Added `Mojito.request/1` and `Mojito.Pool.request/2`, which accept a 167 | `%Mojito.Request{}` struct as input. 168 | 169 | Removed dependency on Fuzzyurl in favor of built-in URI module. 170 | 171 | ## 0.2.0 (2019-04-19) 172 | 173 | Messages sent by Mojito now contain a `:mojito_response` prefix, to allow 174 | processes to select or ignore these messages with `receive`. 175 | Thanks [@AnilRedshift](https://github.com/AnilRedshift)! 176 | 177 | Upgraded to Mint 0.2.0. 178 | 179 | ## 0.1.1 (2019-03-28) 180 | 181 | `request/5` emits better error messages when confronted with nil or blank 182 | method or url. Thanks [@AnilRedshift](https://github.com/AnilRedshift)! 183 | 184 | ## 0.1.0 (2019-02-25) 185 | 186 | Initial release, based on [Mint](https://github.com/ericmj/mint) 0.1.0. 187 | 188 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2018-2021 Appcues, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 17 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Mojito [![Build Status](https://circleci.com/gh/appcues/mojito.svg?style=svg)](https://circleci.com/gh/appcues/mojito) [![Docs](https://img.shields.io/badge/api-docs-green.svg?style=flat)](https://hexdocs.pm/mojito/Mojito.html) [![Hex.pm Version](http://img.shields.io/hexpm/v/mojito.svg?style=flat)](https://hex.pm/packages/mojito) [![License](https://img.shields.io/hexpm/l/mojito.svg)](https://github.com/appcues/mojito/blob/master/LICENSE.md) 4 | 5 | ## Now Deprecated 6 | 7 | We recommend that you use [Finch](https://github.com/sneako/finch) which 8 | is also built on [Mint](https://github.com/ericmj/mint). The creator 9 | of Finch has an [excellent writeup here](https://elixirforum.com/t/mint-vs-finch-vs-gun-vs-tesla-vs-httpoison-etc/38588/11) 10 | describing the problems with Mojito, and as a result we use Finch 11 | internally at Appcues now. 12 | 13 | ## Original Description 14 | 15 | 16 | 17 | Mojito is an easy-to-use, high-performance HTTP client built using the 18 | low-level [Mint library](https://github.com/ericmj/mint). 19 | 20 | Mojito is built for comfort _and_ for speed. Behind a simple and 21 | predictable interface, there is a sophisticated connection pool manager 22 | that delivers maximum throughput with no intervention from the user. 23 | 24 | Just want to make one request and bail? No problem. Mojito can make 25 | one-off requests as well, using the same process-less architecture as 26 | Mint. 27 | 28 | ## Quickstart 29 | 30 | ```elixir 31 | {:ok, response} = Mojito.request(method: :get, url: "https://github.com") 32 | ``` 33 | 34 | ## Why Mojito? 35 | 36 | Mojito addresses the following design goals: 37 | 38 | * _Little or no configuration needed._ Use Mojito to make requests to as 39 | many different destinations as you like, without thinking about 40 | starting or selecting connection pools. Other clients like 41 | [Hackney](https://github.com/benoitc/hackney) 42 | (and [HTTPoison](https://github.com/edgurgel/httpoison)), 43 | [Ibrowse](https://github.com/cmullaparthi/ibrowse) (and 44 | [HTTPotion](https://github.com/myfreeweb/httpotion)), and 45 | Erlang's built-in [httpc](http://erlang.org/doc/man/httpc.html) 46 | offer this feature, except that... 47 | 48 | * _Connection pools should be used only for a single destination._ 49 | Using a pool for making requests against multiple destinations is less 50 | than ideal, as many of the connections need to be reset before use. 51 | Mojito assigns requests to the correct pools transparently to the user. 52 | Other clients, such as [Buoy](https://github.com/lpgauth/buoy), Hackney/ 53 | HTTPoison, Ibrowse/HTTPotion, etc. force the user to handle this 54 | themselves, which is often inconvenient if the full set of HTTP 55 | destinations is not known at compile time. 56 | 57 | * _Redundant pools to reduce GenServer-related bottlenecks._ Mojito can 58 | serve requests to the same destination from more than one connection 59 | pool, and those pools can be selected by round-robin at runtime in order 60 | to minimize resource contention in the Erlang VM. This feature is 61 | unique to Mojito. 62 | 63 | ## Installation 64 | 65 | Add `mojito` to your deps in `mix.exs`: 66 | 67 | ```elixir 68 | {:mojito, "~> 0.7.10"} 69 | ``` 70 | 71 | ## Configuration 72 | 73 | The following `config.exs` config parameters are supported: 74 | 75 | * `:timeout` (milliseconds, default 5000) -- Default request timeout. 76 | * `:max_body_size` - Max body size in bytes. Defaults to nil in which 77 | case no max size will be enforced. 78 | * `:transport_opts` (`t:Keyword.t`, default `[]`) -- Options to pass to 79 | the `:gen_tcp` or `:ssl` modules. Commonly used to make HTTPS requests 80 | with self-signed TLS server certificates; see below for details. 81 | * `:pool_opts` (`t:pool_opts`, default `[]`) -- Configuration options 82 | for connection pools. 83 | 84 | The following `:pool_opts` options are supported: 85 | 86 | * `:size` (integer) sets the number of steady-state connections per pool. 87 | Default is 5. 88 | * `:max_overflow` (integer) sets the number of additional connections 89 | per pool, opened under conditions of heavy load. 90 | Default is 10. 91 | * `:pools` (integer) sets the maximum number of pools to open for a 92 | single destination host and port (not the maximum number of total 93 | pools to open). Default is 5. 94 | * `:strategy` is either `:lifo` or `:fifo`, and selects which connection 95 | should be checked out of a single pool. Default is `:lifo`. 96 | * `:destinations` (keyword list of `t:pool_opts`) allows these parameters 97 | to be set for individual `:"host:port"` destinations. 98 | 99 | For example: 100 | 101 | ```elixir 102 | use Mix.Config 103 | 104 | config :mojito, 105 | timeout: 2500, 106 | pool_opts: [ 107 | size: 10, 108 | destinations: [ 109 | "example.com:443": [ 110 | size: 20, 111 | max_overflow: 20, 112 | pools: 10 113 | ] 114 | ] 115 | ] 116 | ``` 117 | 118 | Certain configs can be overridden with each request. See `request/1`. 119 | 120 | ## Usage 121 | 122 | Make requests with `Mojito.request/1` or `Mojito.request/5`: 123 | 124 | ```elixir 125 | >>>> Mojito.request(:get, "https://jsonplaceholder.typicode.com/posts/1") 126 | ## or... 127 | >>>> Mojito.request(%{method: :get, url: "https://jsonplaceholder.typicode.com/posts/1"}) 128 | ## or... 129 | >>>> Mojito.request(method: :get, url: "https://jsonplaceholder.typicode.com/posts/1") 130 | 131 | {:ok, 132 | %Mojito.Response{ 133 | body: "{\n \"userId\": 1,\n \"id\": 1,\n \"title\": \"sunt aut facere repellat provident occaecati excepturi optio reprehenderit\",\n \"body\": \"quia et suscipit\\nsuscipit recusandae consequuntur expedita et cum\\nreprehenderit molestiae ut ut quas totam\\nnostrum rerum est autem sunt rem eveniet architecto\"\n}", 134 | headers: [ 135 | {"content-type", "application/json; charset=utf-8"}, 136 | {"content-length", "292"}, 137 | {"connection", "keep-alive"}, 138 | ... 139 | ], 140 | status_code: 200 141 | }} 142 | ``` 143 | 144 | By default, Mojito will use a connection pool for requests, automatically 145 | handling the creation and reuse of pools. If this is not desired, 146 | specify the `pool: false` option with a request to perform a one-off request. 147 | See the documentation for `request/1` for more details. 148 | 149 | ## Self-signed SSL/TLS certificates 150 | 151 | To accept self-signed certificates in HTTPS connections, you can give the 152 | `transport_opts: [verify: :verify_none]` option to `Mojito.request` 153 | or `Mojito.Pool.request`: 154 | 155 | ```elixir 156 | >>>> Mojito.request(method: :get, url: "https://localhost:8443/") 157 | {:error, {:tls_alert, 'bad certificate'}} 158 | 159 | >>>> Mojito.request(method: :get, url: "https://localhost:8443/", opts: [transport_opts: [verify: :verify_none]]) 160 | {:ok, %Mojito.Response{ ... }} 161 | ``` 162 | 163 | ## Telemetry 164 | 165 | Mojito integrates with the standard 166 | [Telemetry](https://github.com/beam-telemetry/telemetry) library. 167 | 168 | See the [Mojito.Telemetry](https://github.com/appcues/mojito/blob/master/lib/mojito/telemetry.ex) 169 | module for more information. 170 | 171 | 172 | 173 | ## Changelog 174 | 175 | See the [CHANGELOG.md](https://github.com/appcues/mojito/blob/master/CHANGELOG.md). 176 | 177 | ## Contributing 178 | 179 | Thanks for considering contributing to this project, and to the free 180 | software ecosystem at large! 181 | 182 | Interested in contributing a bug report? Terrific! Please open a [GitHub 183 | issue](https://github.com/appcues/mojito/issues) and include as much detail 184 | as you can. If you have a solution, even better -- please open a pull 185 | request with a clear description and tests. 186 | 187 | Have a feature idea? Excellent! Please open a [GitHub 188 | issue](https://github.com/appcues/mojito/issues) for discussion. 189 | 190 | Want to implement an issue that's been discussed? Fantastic! Please 191 | open a [GitHub pull request](https://github.com/appcues/mojito/pulls) 192 | and write a clear description of the patch. 193 | We'll merge your PR a lot sooner if it is well-documented and fully 194 | tested. 195 | 196 | Contributors and contributions are listed in the 197 | [changelog](https://github.com/appcues/mojito/blob/master/CHANGELOG.md). 198 | Heartfelt thanks to everyone who's helped make Mojito better. 199 | 200 | ## Copyright and License 201 | 202 | Copyright 2018-2021 Appcues, Inc. 203 | 204 | This software is released under the [MIT License](./LICENSE.md). 205 | -------------------------------------------------------------------------------- /assets/mojito-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appcues/mojito/ca2a99d0810933dfba8a6ad088439425956f6aff/assets/mojito-full.png -------------------------------------------------------------------------------- /assets/mojito.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appcues/mojito/ca2a99d0810933dfba8a6ad088439425956f6aff/assets/mojito.png -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :xhttp_client, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:xhttp_client, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | import_config "#{Mix.env()}*.exs" 31 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :mojito, 4 | test_server_http_port: 18999, 5 | test_server_https_port: 18443, 6 | pool_opts: [ 7 | size: 2, 8 | max_overflow: 2, 9 | pools: 5, 10 | destinations: [ 11 | "localhost:18443": [ 12 | pools: 10 13 | ] 14 | ] 15 | ] 16 | -------------------------------------------------------------------------------- /lib/mojito.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito do 2 | @external_resource "README.md" 3 | @moduledoc File.read!("README.md") 4 | |> String.split(~r//) 5 | |> Enum.fetch!(1) 6 | 7 | use Mojito.Base 8 | end 9 | -------------------------------------------------------------------------------- /lib/mojito/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | def start(_type, _args) do 7 | children = [ 8 | Mojito.Pool.Poolboy.Manager, 9 | {Registry, 10 | keys: :duplicate, 11 | name: Mojito.Pool.Poolboy.Registry, 12 | partitions: System.schedulers_online()} 13 | ] 14 | 15 | opts = [strategy: :one_for_one, name: Mojito.Supervisor] 16 | Supervisor.start_link(children, opts) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/mojito/base.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Base do 2 | @moduledoc ~S""" 3 | Provides a default implementation for Mojito functions. 4 | 5 | This module is meant to be `use`'d in custom modules in order to wrap the 6 | functionalities provided by Mojiti. For example, this is very useful to 7 | build custom API clients around Mojito: 8 | 9 | defmodule CustomAPI do 10 | use Mojito.Base 11 | end 12 | 13 | """ 14 | 15 | @type method :: 16 | :head 17 | | :get 18 | | :post 19 | | :put 20 | | :patch 21 | | :delete 22 | | :options 23 | | String.t() 24 | 25 | @type header :: {String.t(), String.t()} 26 | 27 | @type headers :: [header] 28 | 29 | @type request :: %Mojito.Request{ 30 | method: method, 31 | url: String.t(), 32 | headers: headers | nil, 33 | body: String.t() | nil, 34 | opts: Keyword.t() | nil 35 | } 36 | 37 | @type request_kwlist :: [request_field] 38 | 39 | @type request_field :: 40 | {:method, method} 41 | | {:url, String.t()} 42 | | {:headers, headers} 43 | | {:body, String.t()} 44 | | {:opts, Keyword.t()} 45 | 46 | @type response :: %Mojito.Response{ 47 | status_code: pos_integer, 48 | headers: headers, 49 | body: String.t(), 50 | complete: boolean 51 | } 52 | 53 | @type error :: %Mojito.Error{ 54 | reason: any, 55 | message: String.t() | nil 56 | } 57 | 58 | @type pool_opts :: [pool_opt | {:destinations, [{atom, pool_opts}]}] 59 | 60 | @type pool_opt :: 61 | {:size, pos_integer} 62 | | {:max_overflow, non_neg_integer} 63 | | {:pools, pos_integer} 64 | | {:strategy, :lifo | :fifo} 65 | 66 | @type url :: String.t() 67 | @type body :: String.t() 68 | @type payload :: String.t() 69 | 70 | @callback request(method, url) :: 71 | {:ok, response} | {:error, error} | no_return 72 | @callback request(method, url, headers) :: 73 | {:ok, response} | {:error, error} | no_return 74 | @callback request(method, url, headers, body) :: 75 | {:ok, response} | {:error, error} | no_return 76 | @callback request(method, url, headers, body, Keyword.t()) :: 77 | {:ok, response} | {:error, error} | no_return 78 | @callback request(request | request_kwlist) :: 79 | {:ok, response} | {:error, error} 80 | 81 | @callback head(url) :: {:ok, response} | {:error, error} | no_return 82 | @callback head(url, headers) :: {:ok, response} | {:error, error} | no_return 83 | @callback head(url, headers, Keyword.t()) :: 84 | {:ok, response} | {:error, error} | no_return 85 | 86 | @callback get(url) :: {:ok, response} | {:error, error} | no_return 87 | @callback get(url, headers) :: {:ok, response} | {:error, error} | no_return 88 | @callback get(url, headers, Keyword.t()) :: 89 | {:ok, response} | {:error, error} | no_return 90 | 91 | @callback post(url) :: {:ok, response} | {:error, error} | no_return 92 | @callback post(url, headers) :: {:ok, response} | {:error, error} | no_return 93 | @callback post(url, headers, payload) :: 94 | {:ok, response} | {:error, error} | no_return 95 | @callback post(url, headers, payload, Keyword.t()) :: 96 | {:ok, response} | {:error, error} | no_return 97 | 98 | @callback put(url) :: {:ok, response} | {:error, error} | no_return 99 | @callback put(url, headers) :: {:ok, response} | {:error, error} | no_return 100 | @callback put(url, headers, payload) :: 101 | {:ok, response} | {:error, error} | no_return 102 | @callback put(url, headers, payload, Keyword.t()) :: 103 | {:ok, response} | {:error, error} | no_return 104 | 105 | @callback patch(url) :: {:ok, response} | {:error, error} | no_return 106 | @callback patch(url, headers) :: {:ok, response} | {:error, error} | no_return 107 | @callback patch(url, headers, payload) :: 108 | {:ok, response} | {:error, error} | no_return 109 | @callback patch(url, headers, payload, Keyword.t()) :: 110 | {:ok, response} | {:error, error} | no_return 111 | 112 | @callback delete(url) :: {:ok, response} | {:error, error} | no_return 113 | @callback delete(url, headers) :: 114 | {:ok, response} | {:error, error} | no_return 115 | @callback delete(url, headers, Keyword.t()) :: 116 | {:ok, response} | {:error, error} | no_return 117 | 118 | @callback options(url) :: {:ok, response} | {:error, error} | no_return 119 | @callback options(url, headers) :: 120 | {:ok, response} | {:error, error} | no_return 121 | @callback options(url, headers, Keyword.t()) :: 122 | {:ok, response} | {:error, error} | no_return 123 | 124 | defmacro __using__(_) do 125 | quote do 126 | @behaviour Mojito.Base 127 | 128 | @type method :: Mojito.Base.method() 129 | @type header :: Mojito.Base.header() 130 | @type headers :: Mojito.Base.headers() 131 | @type request :: Mojito.Base.request() 132 | @type request_kwlist :: Mojito.Base.request_kwlist() 133 | @type request_fields :: Mojito.Base.request_field() 134 | @type response :: Mojito.Base.response() 135 | @type error :: Mojito.Base.error() 136 | @type pool_opts :: Mojito.Base.pool_opts() 137 | @type pool_opt :: Mojito.Base.pool_opt() 138 | @type url :: Mojito.Base.url() 139 | @type body :: Mojito.Base.body() 140 | @type payload :: Mojito.Base.payload() 141 | 142 | @doc ~S""" 143 | Performs an HTTP request and returns the response. 144 | 145 | See `request/1` for details. 146 | """ 147 | @spec request(method, url, headers, body | nil, Keyword.t()) :: 148 | {:ok, response} | {:error, error} | no_return 149 | def request(method, url, headers \\ [], body \\ "", opts \\ []) do 150 | %Mojito.Request{ 151 | method: method, 152 | url: url, 153 | headers: headers, 154 | body: body, 155 | opts: opts 156 | } 157 | |> request 158 | end 159 | 160 | @doc ~S""" 161 | Performs an HTTP request and returns the response. 162 | 163 | If the `pool: true` option is given, or `:pool` is not specified, the 164 | request will be made using Mojito's automatic connection pooling system. 165 | For more details, see `Mojito.Pool.request/1`. This is the default 166 | mode of operation, and is recommended for best performance. 167 | 168 | If `pool: false` is given as an option, the request will be made on 169 | a brand new connection. This does not spawn an additional process. 170 | Messages of the form `{:tcp, _, _}` or `{:ssl, _, _}` will be sent to 171 | and handled by the caller. If the caller process expects to receive 172 | other `:tcp` or `:ssl` messages at the same time, conflicts can occur; 173 | in this case, it is recommended to wrap `request/1` in `Task.async/1`, 174 | or use one of the pooled request modes. 175 | 176 | Options: 177 | 178 | * `:pool` - See above. 179 | * `:timeout` - Response timeout in milliseconds, or `:infinity`. 180 | Defaults to `Application.get_env(:mojito, :timeout, 5000)`. 181 | * `:raw` - Set this to `true` to prevent the decompression of 182 | `gzip` or `compress`-encoded responses. 183 | * `:transport_opts` - Options to be passed to either `:gen_tcp` or `:ssl`. 184 | Most commonly used to perform insecure HTTPS requests via 185 | `transport_opts: [verify: :verify_none]`. 186 | """ 187 | @spec request(request | request_kwlist) :: 188 | {:ok, response} | {:error, error} 189 | def request(request) do 190 | with {:ok, valid_request} <- Mojito.Request.validate_request(request), 191 | {:ok, valid_request} <- 192 | Mojito.Request.convert_headers_values_to_string(valid_request) do 193 | case Keyword.get(valid_request.opts, :pool, true) do 194 | true -> 195 | Mojito.Pool.Poolboy.request(valid_request) 196 | 197 | false -> 198 | Mojito.Request.Single.request(valid_request) 199 | 200 | pid when is_pid(pid) -> 201 | Mojito.Pool.Poolboy.Single.request(pid, valid_request) 202 | 203 | impl when is_atom(impl) -> 204 | impl.request(valid_request) 205 | end 206 | |> maybe_decompress(valid_request.opts) 207 | end 208 | end 209 | 210 | defp maybe_decompress({:ok, response}, opts) do 211 | case Keyword.get(opts, :raw) do 212 | true -> 213 | {:ok, response} 214 | 215 | _ -> 216 | case Enum.find(response.headers, fn {k, _v} -> 217 | k == "content-encoding" 218 | end) do 219 | {"content-encoding", "gzip"} -> 220 | {:ok, 221 | %Mojito.Response{response | body: :zlib.gunzip(response.body)}} 222 | 223 | {"content-encoding", "deflate"} -> 224 | {:ok, 225 | %Mojito.Response{ 226 | response 227 | | body: :zlib.uncompress(response.body) 228 | }} 229 | 230 | _ -> 231 | # we don't have a decompressor for this so just returning 232 | {:ok, response} 233 | end 234 | end 235 | end 236 | 237 | defp maybe_decompress(response, _opts) do 238 | response 239 | end 240 | 241 | @doc ~S""" 242 | Performs an HTTP HEAD request and returns the response. 243 | 244 | See `request/1` for documentation. 245 | """ 246 | @spec head(url, headers, Keyword.t()) :: 247 | {:ok, response} | {:error, error} | no_return 248 | def head(url, headers \\ [], opts \\ []) do 249 | request(:head, url, headers, nil, opts) 250 | end 251 | 252 | @doc ~S""" 253 | Performs an HTTP GET request and returns the response. 254 | 255 | ## Examples 256 | 257 | Assemble a URL with a query string params and fetch it with GET request: 258 | 259 | >>>> "https://www.google.com/search" 260 | ...> |> URI.parse() 261 | ...> |> Map.put(:query, URI.encode_query(%{"q" => "mojito elixir"})) 262 | ...> |> URI.to_string() 263 | ...> |> Mojito.get() 264 | {:ok, 265 | %Mojito.Response{ 266 | body: " ...", 267 | complete: true, 268 | headers: [ 269 | {"content-type", "text/html; charset=ISO-8859-1"}, 270 | ... 271 | ], 272 | status_code: 200 273 | }} 274 | 275 | 276 | See `request/1` for detailed documentation. 277 | """ 278 | @spec get(url, headers, Keyword.t()) :: 279 | {:ok, response} | {:error, error} | no_return 280 | def get(url, headers \\ [], opts \\ []) do 281 | request(:get, url, headers, nil, opts) 282 | end 283 | 284 | @doc ~S""" 285 | Performs an HTTP POST request and returns the response. 286 | 287 | ## Examples 288 | 289 | Submitting a form with POST request: 290 | 291 | >>>> Mojito.post( 292 | ...> "http://localhost:4000/messages", 293 | ...> [{"content-type", "application/x-www-form-urlencoded"}], 294 | ...> URI.encode_query(%{"message[subject]" => "Contact request", "message[content]" => "data"})) 295 | {:ok, 296 | %Mojito.Response{ 297 | body: "Thank you!", 298 | complete: true, 299 | headers: [ 300 | {"server", "Cowboy"}, 301 | {"connection", "keep-alive"}, 302 | ... 303 | ], 304 | status_code: 200 305 | }} 306 | 307 | Submitting a JSON payload as POST request body: 308 | 309 | >>>> Mojito.post( 310 | ...> "http://localhost:4000/api/messages", 311 | ...> [{"content-type", "application/json"}], 312 | ...> Jason.encode!(%{"message" => %{"subject" => "Contact request", "content" => "data"}})) 313 | {:ok, 314 | %Mojito.Response{ 315 | body: "{\"message\": \"Thank you!\"}", 316 | complete: true, 317 | headers: [ 318 | {"server", "Cowboy"}, 319 | {"connection", "keep-alive"}, 320 | ... 321 | ], 322 | status_code: 200 323 | }} 324 | 325 | See `request/1` for detailed documentation. 326 | """ 327 | @spec post(url, headers, payload, Keyword.t()) :: 328 | {:ok, response} | {:error, error} | no_return 329 | def post(url, headers \\ [], payload \\ "", opts \\ []) do 330 | request(:post, url, headers, payload, opts) 331 | end 332 | 333 | @doc ~S""" 334 | Performs an HTTP PUT request and returns the response. 335 | 336 | See `request/1` and `post/4` for documentation and examples. 337 | """ 338 | @spec put(url, headers, payload, Keyword.t()) :: 339 | {:ok, response} | {:error, error} | no_return 340 | def put(url, headers \\ [], payload \\ "", opts \\ []) do 341 | request(:put, url, headers, payload, opts) 342 | end 343 | 344 | @doc ~S""" 345 | Performs an HTTP PATCH request and returns the response. 346 | 347 | See `request/1` and `post/4` for documentation and examples. 348 | """ 349 | @spec patch(url, headers, payload, Keyword.t()) :: 350 | {:ok, response} | {:error, error} | no_return 351 | def patch(url, headers \\ [], payload \\ "", opts \\ []) do 352 | request(:patch, url, headers, payload, opts) 353 | end 354 | 355 | @doc ~S""" 356 | Performs an HTTP DELETE request and returns the response. 357 | 358 | See `request/1` for documentation and examples. 359 | """ 360 | @spec delete(url, headers, Keyword.t()) :: 361 | {:ok, response} | {:error, error} | no_return 362 | def delete(url, headers \\ [], opts \\ []) do 363 | request(:delete, url, headers, nil, opts) 364 | end 365 | 366 | @doc ~S""" 367 | Performs an HTTP OPTIONS request and returns the response. 368 | 369 | See `request/1` for documentation. 370 | """ 371 | @spec options(url, headers, Keyword.t()) :: 372 | {:ok, response} | {:error, error} | no_return 373 | def options(url, headers \\ [], opts \\ []) do 374 | request(:options, url, headers, nil, opts) 375 | end 376 | end 377 | end 378 | end 379 | -------------------------------------------------------------------------------- /lib/mojito/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Config do 2 | @moduledoc false 3 | 4 | def timeout do 5 | Application.get_env(:mojito, :timeout, 5000) 6 | end 7 | end 8 | 9 | ## pool_opts are handled in Mojito.Pool 10 | -------------------------------------------------------------------------------- /lib/mojito/conn.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Conn do 2 | @moduledoc false 3 | 4 | alias Mojito.{Error, Telemetry, Utils} 5 | 6 | defstruct conn: nil, 7 | protocol: nil, 8 | hostname: nil, 9 | port: nil 10 | 11 | @type t :: %Mojito.Conn{} 12 | 13 | @doc ~S""" 14 | Connects to the specified endpoint, returning a connection to the server. 15 | No requests are made. 16 | """ 17 | @spec connect(String.t(), Keyword.t()) :: {:ok, t} | {:error, any} 18 | def connect(url, opts \\ []) do 19 | with {:ok, protocol, hostname, port} <- Utils.decompose_url(url) do 20 | connect(protocol, hostname, port, opts) 21 | end 22 | end 23 | 24 | @doc ~S""" 25 | Closes a connection 26 | """ 27 | @spec close(t) :: :ok 28 | def close(conn) do 29 | Mint.HTTP.close(conn.conn) 30 | :ok 31 | end 32 | 33 | @doc ~S""" 34 | Connects to the server specified in the given URL, 35 | returning a connection to the server. No requests are made. 36 | """ 37 | @spec connect(String.t(), String.t(), non_neg_integer, Keyword.t()) :: 38 | {:ok, t} | {:error, any} 39 | def connect(protocol, hostname, port, opts \\ []) do 40 | with meta <- %{host: hostname, port: port}, 41 | start_time <- Telemetry.start(:connect, meta), 42 | {:ok, proto} <- protocol_to_atom(protocol), 43 | {:ok, mint_conn} <- Mint.HTTP.connect(proto, hostname, port, opts) do 44 | Telemetry.stop(:connect, start_time, meta) 45 | 46 | {:ok, 47 | %Mojito.Conn{ 48 | conn: mint_conn, 49 | protocol: proto, 50 | hostname: hostname, 51 | port: port 52 | }} 53 | end 54 | end 55 | 56 | defp protocol_to_atom("http"), do: {:ok, :http} 57 | defp protocol_to_atom("https"), do: {:ok, :https} 58 | defp protocol_to_atom(:http), do: {:ok, :http} 59 | defp protocol_to_atom(:https), do: {:ok, :https} 60 | 61 | defp protocol_to_atom(proto), 62 | do: {:error, %Error{message: "bad protocol #{inspect(proto)}"}} 63 | 64 | @doc ~S""" 65 | Initiates a request on the given connection. Returns the updated Conn and 66 | a reference to this request (which is required when receiving pipelined 67 | responses). 68 | """ 69 | @spec request(t, Mojito.request()) :: {:ok, t, reference} | {:error, any} 70 | def request(conn, request) do 71 | max_body_size = request.opts[:max_body_size] 72 | response = %Mojito.Response{body: [], size: max_body_size} 73 | 74 | with {:ok, relative_url, auth_headers} <- 75 | Utils.get_relative_url_and_auth_headers(request.url), 76 | {:ok, mint_conn, request_ref} <- 77 | Mint.HTTP.request( 78 | conn.conn, 79 | method_to_string(request.method), 80 | relative_url, 81 | auth_headers ++ request.headers, 82 | :stream 83 | ), 84 | {:ok, mint_conn, response} <- 85 | stream_request_body(mint_conn, request_ref, response, request.body) do 86 | {:ok, %{conn | conn: mint_conn}, request_ref, response} 87 | end 88 | end 89 | 90 | defp stream_request_body(mint_conn, request_ref, response, nil) do 91 | stream_request_body(mint_conn, request_ref, response, "") 92 | end 93 | 94 | defp stream_request_body(mint_conn, request_ref, response, "") do 95 | with {:ok, mint_conn} <- 96 | Mint.HTTP.stream_request_body(mint_conn, request_ref, :eof) do 97 | {:ok, mint_conn, response} 98 | end 99 | end 100 | 101 | defp stream_request_body( 102 | %Mint.HTTP1{} = mint_conn, 103 | request_ref, 104 | response, 105 | body 106 | ) do 107 | {chunk, rest} = split_chunk(body, 65_535) 108 | 109 | with {:ok, mint_conn} <- 110 | Mint.HTTP.stream_request_body(mint_conn, request_ref, chunk) do 111 | stream_request_body(mint_conn, request_ref, response, rest) 112 | end 113 | end 114 | 115 | defp stream_request_body( 116 | %Mint.HTTP2{} = mint_conn, 117 | request_ref, 118 | response, 119 | body 120 | ) do 121 | chunk_size = 122 | min( 123 | Mint.HTTP2.get_window_size(mint_conn, {:request, request_ref}), 124 | Mint.HTTP2.get_window_size(mint_conn, :connection) 125 | ) 126 | 127 | {chunk, rest} = split_chunk(body, chunk_size) 128 | 129 | with {:ok, mint_conn} <- 130 | Mint.HTTP.stream_request_body(mint_conn, request_ref, chunk) do 131 | {mint_conn, response} = 132 | if is_nil(rest) do 133 | {mint_conn, response} 134 | else 135 | {:ok, mint_conn, resps} = 136 | receive do 137 | msg -> Mint.HTTP.stream(mint_conn, msg) 138 | end 139 | 140 | {:ok, response} = Mojito.Response.apply_resps(response, resps) 141 | 142 | {mint_conn, response} 143 | end 144 | 145 | if response.complete do 146 | {:ok, mint_conn, response} 147 | else 148 | stream_request_body(mint_conn, request_ref, response, rest) 149 | end 150 | end 151 | end 152 | 153 | defp method_to_string(m) when is_atom(m) do 154 | m |> to_string |> String.upcase() 155 | end 156 | 157 | defp method_to_string(m) when is_binary(m) do 158 | m |> String.upcase() 159 | end 160 | 161 | defp split_chunk(binary, chunk_size) 162 | when is_binary(binary) and is_integer(chunk_size) do 163 | case binary do 164 | <> -> 165 | {chunk, rest} 166 | 167 | _ -> 168 | {binary, nil} 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /lib/mojito/conn_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.ConnServer do 2 | @moduledoc false 3 | 4 | use GenServer 5 | require Logger 6 | 7 | alias Mojito.{Conn, Response, Utils} 8 | 9 | @type state :: map 10 | 11 | @doc ~S""" 12 | Starts a `Mojito.ConnServer`. 13 | 14 | `Mojito.ConnServer` is a GenServer that handles a single 15 | `Mojito.Conn`. It supports automatic reconnection, 16 | connection keep-alive, and request pipelining. 17 | 18 | It's intended for usage through `Mojito.Pool`. 19 | 20 | Example: 21 | 22 | {:ok, pid} = Mojito.ConnServer.start_link() 23 | :ok = GenServer.cast(pid, {:request, self(), :get, "http://example.com", [], "", []}) 24 | receive do 25 | {:ok, response} -> response 26 | after 27 | 1_000 -> :timeout 28 | end 29 | """ 30 | @spec start_link(Keyword.t()) :: {:ok, pid} | {:error, any} 31 | def start_link(args \\ []) do 32 | GenServer.start_link(__MODULE__, args) 33 | end 34 | 35 | @doc ~S""" 36 | Initiates a request. The `reply_to` pid will receive the response in a 37 | message of the format `{:ok, %Mojito.Response{}} | {:error, any}`. 38 | """ 39 | @spec request(pid, Mojito.request(), pid, reference) :: :ok | {:error, any} 40 | def request(server_pid, request, reply_to, response_ref) do 41 | GenServer.call(server_pid, {:request, request, reply_to, response_ref}) 42 | end 43 | 44 | #### GenServer callbacks 45 | 46 | def init(_) do 47 | {:ok, 48 | %{ 49 | conn: nil, 50 | protocol: nil, 51 | hostname: nil, 52 | port: nil, 53 | responses: %{}, 54 | reply_tos: %{}, 55 | response_refs: %{} 56 | }} 57 | end 58 | 59 | def terminate(_reason, state) do 60 | close_connections(state) 61 | end 62 | 63 | def handle_call( 64 | {:request, request, reply_to, response_ref}, 65 | _from, 66 | state 67 | ) do 68 | with {:ok, state, _request_ref} <- 69 | start_request(state, request, reply_to, response_ref) do 70 | {:reply, :ok, state} 71 | else 72 | err -> {:reply, err, close_connections(state)} 73 | end 74 | end 75 | 76 | ## `msg` is an incoming chunk of a response 77 | def handle_info(msg, state) do 78 | if !state.conn do 79 | {:noreply, close_connections(state)} 80 | else 81 | case Mint.HTTP.stream(state.conn.conn, msg) do 82 | {:ok, mint_conn, resps} -> 83 | state_conn = state.conn |> Map.put(:conn, mint_conn) 84 | state = %{state | conn: state_conn} 85 | {:noreply, apply_resps(state, resps)} 86 | 87 | {:error, _mint_conn, _error, _resps} -> 88 | {:noreply, close_connections(state)} 89 | 90 | :unknown -> 91 | {:noreply, state} 92 | end 93 | end 94 | end 95 | 96 | #### Helpers 97 | 98 | @spec close_connections(state) :: state 99 | defp close_connections(state) do 100 | Enum.each(state.reply_tos, fn {_request_ref, reply_to} -> 101 | respond(reply_to, {:error, :closed}) 102 | end) 103 | 104 | %{state | conn: nil, responses: %{}, reply_tos: %{}, response_refs: %{}} 105 | end 106 | 107 | defp apply_resps(state, []), do: state 108 | 109 | defp apply_resps(state, [resp | rest]) do 110 | apply_resp(state, resp) |> apply_resps(rest) 111 | end 112 | 113 | defp apply_resp(state, {:status, request_ref, _status} = msg) do 114 | {:ok, response} = 115 | Map.get(state.responses, request_ref) 116 | |> Response.apply_resp(msg) 117 | 118 | %{state | responses: Map.put(state.responses, request_ref, response)} 119 | end 120 | 121 | defp apply_resp(state, {:headers, request_ref, _headers} = msg) do 122 | {:ok, response} = 123 | Map.get(state.responses, request_ref) 124 | |> Response.apply_resp(msg) 125 | 126 | %{state | responses: Map.put(state.responses, request_ref, response)} 127 | end 128 | 129 | defp apply_resp(state, {:data, request_ref, _chunk} = msg) do 130 | case Map.get(state.responses, request_ref) |> Response.apply_resp(msg) do 131 | {:ok, response} -> 132 | %{state | responses: Map.put(state.responses, request_ref, response)} 133 | 134 | {:error, _} = err -> 135 | halt(state, request_ref, err) 136 | end 137 | end 138 | 139 | defp apply_resp(state, {:error, request_ref, err}) do 140 | halt(state, request_ref, {:error, err}) 141 | end 142 | 143 | defp apply_resp(state, {:done, request_ref}) do 144 | r = Map.get(state.responses, request_ref) 145 | body = :erlang.list_to_binary(r.body) 146 | size = byte_size(body) 147 | response = %{r | complete: true, body: body, size: size} 148 | halt(state, request_ref, {:ok, response}) 149 | end 150 | 151 | defp halt(state, request_ref, response) do 152 | response_ref = state.response_refs |> Map.get(request_ref) 153 | Map.get(state.reply_tos, request_ref) |> respond(response, response_ref) 154 | 155 | %{ 156 | state 157 | | responses: Map.delete(state.responses, request_ref), 158 | reply_tos: Map.delete(state.reply_tos, request_ref), 159 | response_refs: Map.delete(state.response_refs, request_ref) 160 | } 161 | end 162 | 163 | defp respond(pid, message, response_ref \\ nil) do 164 | send(pid, {:mojito_response, response_ref, message}) 165 | end 166 | 167 | @spec start_request( 168 | state, 169 | Mojito.request(), 170 | pid, 171 | reference 172 | ) :: {:ok, state, reference} | {:error, any} 173 | defp start_request(state, request, reply_to, response_ref) do 174 | with {:ok, state} <- ensure_connection(state, request.url, request.opts), 175 | {:ok, conn, request_ref, response} <- Conn.request(state.conn, request) do 176 | case response do 177 | %{complete: true} -> 178 | ## Request was completed by server during stream_request_body 179 | respond(reply_to, {:ok, response}, response_ref) 180 | {:ok, %{state | conn: conn}, request_ref} 181 | 182 | _ -> 183 | responses = state.responses |> Map.put(request_ref, response) 184 | reply_tos = state.reply_tos |> Map.put(request_ref, reply_to) 185 | 186 | response_refs = 187 | state.response_refs |> Map.put(request_ref, response_ref) 188 | 189 | state = %{ 190 | state 191 | | conn: conn, 192 | responses: responses, 193 | reply_tos: reply_tos, 194 | response_refs: response_refs 195 | } 196 | 197 | {:ok, state, request_ref} 198 | end 199 | end 200 | end 201 | 202 | @spec ensure_connection(state, String.t(), Keyword.t()) :: 203 | {:ok, state} | {:error, any} 204 | defp ensure_connection(state, url, opts) do 205 | with {:ok, protocol, hostname, port} <- Utils.decompose_url(url) do 206 | new_destination = 207 | state.protocol != protocol || state.hostname != hostname || 208 | state.port != port 209 | 210 | cond do 211 | !state.conn || new_destination -> 212 | connect(state, protocol, hostname, port, opts) 213 | 214 | :else -> 215 | {:ok, state} 216 | end 217 | end 218 | end 219 | 220 | @spec connect(state, String.t(), String.t(), non_neg_integer, Keyword.t()) :: 221 | {:ok, state} | {:error, any} 222 | defp connect(state, protocol, hostname, port, opts) do 223 | with {:ok, conn} <- Mojito.Conn.connect(protocol, hostname, port, opts) do 224 | {:ok, 225 | %{state | conn: conn, protocol: protocol, hostname: hostname, port: port}} 226 | end 227 | end 228 | end 229 | -------------------------------------------------------------------------------- /lib/mojito/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Error do 2 | @moduledoc false 3 | 4 | defstruct [:reason, :message] 5 | 6 | @type t :: Mojito.error() 7 | end 8 | -------------------------------------------------------------------------------- /lib/mojito/headers.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Headers do 2 | @moduledoc ~S""" 3 | Functions for working with HTTP request and response headers, as described 4 | in the [HTTP 1.1 specification](https://www.w3.org/Protocols/rfc2616/rfc2616.html). 5 | 6 | Headers are represented in Elixir as a list of `{"header_name", "value"}` 7 | tuples. Multiple entries for the same header name are allowed. 8 | 9 | Capitalization of header names is preserved during insertion, 10 | however header names are handled case-insensitively during 11 | lookup and deletion. 12 | """ 13 | 14 | @type headers :: Mojito.headers() 15 | 16 | @doc ~S""" 17 | Returns the value for the given HTTP request or response header, 18 | or `nil` if not found. 19 | 20 | Header names are matched case-insensitively. 21 | 22 | If more than one matching header is found, the values are joined with 23 | `","` as specified in [RFC 2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2). 24 | 25 | Example: 26 | 27 | iex> headers = [ 28 | ...> {"header1", "foo"}, 29 | ...> {"header2", "bar"}, 30 | ...> {"Header1", "baz"} 31 | ...> ] 32 | iex> Mojito.Headers.get(headers, "header2") 33 | "bar" 34 | iex> Mojito.Headers.get(headers, "HEADER1") 35 | "foo,baz" 36 | iex> Mojito.Headers.get(headers, "header3") 37 | nil 38 | """ 39 | @spec get(headers, String.t()) :: String.t() | nil 40 | def get(headers, name) do 41 | case get_values(headers, name) do 42 | [] -> nil 43 | values -> values |> Enum.join(",") 44 | end 45 | end 46 | 47 | @doc ~S""" 48 | Returns all values for the given HTTP request or response header. 49 | Returns an empty list if none found. 50 | 51 | Header names are matched case-insensitively. 52 | 53 | Example: 54 | 55 | iex> headers = [ 56 | ...> {"header1", "foo"}, 57 | ...> {"header2", "bar"}, 58 | ...> {"Header1", "baz"} 59 | ...> ] 60 | iex> Mojito.Headers.get_values(headers, "header2") 61 | ["bar"] 62 | iex> Mojito.Headers.get_values(headers, "HEADER1") 63 | ["foo", "baz"] 64 | iex> Mojito.Headers.get_values(headers, "header3") 65 | [] 66 | """ 67 | @spec get_values(headers, String.t()) :: [String.t()] 68 | def get_values(headers, name) do 69 | get_values(headers, String.downcase(name), []) 70 | end 71 | 72 | defp get_values([], _name, values), do: values 73 | 74 | defp get_values([{key, value} | rest], name, values) do 75 | new_values = 76 | if String.downcase(key) == name do 77 | values ++ [value] 78 | else 79 | values 80 | end 81 | 82 | get_values(rest, name, new_values) 83 | end 84 | 85 | @doc ~S""" 86 | Puts the given header `value` under `name`, removing any values previously 87 | stored under `name`. The new header is placed at the end of the list. 88 | 89 | Header names are matched case-insensitively, but case of `name` is preserved 90 | when adding the header. 91 | 92 | Example: 93 | 94 | iex> headers = [ 95 | ...> {"header1", "foo"}, 96 | ...> {"header2", "bar"}, 97 | ...> {"Header1", "baz"} 98 | ...> ] 99 | iex> Mojito.Headers.put(headers, "HEADER1", "quux") 100 | [{"header2", "bar"}, {"HEADER1", "quux"}] 101 | """ 102 | @spec put(headers, String.t(), String.t()) :: headers 103 | def put(headers, name, value) do 104 | delete(headers, name) ++ [{name, value}] 105 | end 106 | 107 | @doc ~S""" 108 | Removes all instances of the given header. 109 | 110 | Header names are matched case-insensitively. 111 | 112 | Example: 113 | 114 | iex> headers = [ 115 | ...> {"header1", "foo"}, 116 | ...> {"header2", "bar"}, 117 | ...> {"Header1", "baz"} 118 | ...> ] 119 | iex> Mojito.Headers.delete(headers, "HEADER1") 120 | [{"header2", "bar"}] 121 | """ 122 | @spec delete(headers, String.t()) :: headers 123 | def delete(headers, name) do 124 | name = String.downcase(name) 125 | Enum.filter(headers, fn {key, _value} -> String.downcase(key) != name end) 126 | end 127 | 128 | @doc ~S""" 129 | Returns an ordered list of the header names from the given headers. 130 | Header names are returned in lowercase. 131 | 132 | Example: 133 | 134 | iex> headers = [ 135 | ...> {"header1", "foo"}, 136 | ...> {"header2", "bar"}, 137 | ...> {"Header1", "baz"} 138 | ...> ] 139 | iex> Mojito.Headers.keys(headers) 140 | ["header1", "header2"] 141 | """ 142 | @spec keys(headers) :: [String.t()] 143 | def keys(headers) do 144 | keys(headers, []) 145 | end 146 | 147 | defp keys([], names), do: Enum.reverse(names) 148 | 149 | defp keys([{name, _value} | rest], names) do 150 | name = String.downcase(name) 151 | 152 | if name in names do 153 | keys(rest, names) 154 | else 155 | keys(rest, [name | names]) 156 | end 157 | end 158 | 159 | @doc ~S""" 160 | Returns a copy of the given headers where all header names are lowercased 161 | and multiple values for the same header have been joined with `","`. 162 | 163 | Example: 164 | 165 | iex> headers = [ 166 | ...> {"header1", "foo"}, 167 | ...> {"header2", "bar"}, 168 | ...> {"Header1", "baz"} 169 | ...> ] 170 | iex> Mojito.Headers.normalize(headers) 171 | [{"header1", "foo,baz"}, {"header2", "bar"}] 172 | """ 173 | @spec normalize(headers) :: headers 174 | def normalize(headers) do 175 | headers_map = 176 | Enum.reduce(headers, %{}, fn {name, value}, acc -> 177 | name = String.downcase(name) 178 | values = Map.get(acc, name, []) 179 | Map.put(acc, name, values ++ [value]) 180 | end) 181 | 182 | headers 183 | |> keys 184 | |> Enum.map(fn name -> 185 | {name, Map.get(headers_map, name) |> Enum.join(",")} 186 | end) 187 | end 188 | 189 | @doc ~S""" 190 | Returns an HTTP Basic Auth header from the given username and password. 191 | 192 | Example: 193 | 194 | iex> Mojito.Headers.auth_header("hello", "world") 195 | {"authorization", "Basic aGVsbG86d29ybGQ="} 196 | """ 197 | @spec auth_header(String.t(), String.t()) :: Mojito.header() 198 | def auth_header(username, password) do 199 | auth64 = "#{username}:#{password}" |> Base.encode64() 200 | {"authorization", "Basic #{auth64}"} 201 | end 202 | 203 | @doc ~S""" 204 | Convert non string values to string where is possible. 205 | 206 | Example: 207 | 208 | iex> Mojito.Headers.convert_values_to_string([{"content-length", 0}]) 209 | [{"content-length", "0"}] 210 | """ 211 | @spec convert_values_to_string(headers) :: headers 212 | def convert_values_to_string(headers) do 213 | convert_values_to_string(headers, []) 214 | end 215 | 216 | defp convert_values_to_string([], converted_headers), 217 | do: Enum.reverse(converted_headers) 218 | 219 | defp convert_values_to_string([{name, value} | rest], converted_headers) 220 | when is_number(value) or is_atom(value) do 221 | convert_values_to_string(rest, [ 222 | {name, to_string(value)} | converted_headers 223 | ]) 224 | end 225 | 226 | defp convert_values_to_string([headers | rest], converted_headers) do 227 | convert_values_to_string(rest, [headers | converted_headers]) 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /lib/mojito/pool.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Pool do 2 | @moduledoc false 3 | 4 | @callback request(request :: Mojito.request()) :: 5 | {:ok, Mojito.response()} | {:error, Mojito.error()} 6 | 7 | @type pool_opts :: [pool_opt | {:destinations, [pool_opt]}] 8 | 9 | @type pool_opt :: 10 | {:size, pos_integer} 11 | | {:max_overflow, non_neg_integer} 12 | | {:pools, pos_integer} 13 | | {:strategy, :lifo | :fifo} 14 | 15 | @type pool_key :: {String.t(), pos_integer} 16 | 17 | @default_pool_opts [ 18 | size: 5, 19 | max_overflow: 10, 20 | pools: 5, 21 | strategy: :lifo 22 | ] 23 | 24 | ## Returns the configured `t:pool_opts` for the given destination. 25 | @doc false 26 | @spec pool_opts(pool_key) :: Mojito.pool_opts() 27 | def pool_opts({host, port}) do 28 | destination_key = 29 | try do 30 | "#{host}:#{port}" |> String.to_existing_atom() 31 | rescue 32 | _ -> :none 33 | end 34 | 35 | config_pool_opts = Application.get_env(:mojito, :pool_opts, []) 36 | 37 | destination_pool_opts = 38 | config_pool_opts 39 | |> Keyword.get(:destinations, []) 40 | |> Keyword.get(destination_key, []) 41 | 42 | @default_pool_opts 43 | |> Keyword.merge(config_pool_opts) 44 | |> Keyword.merge(destination_pool_opts) 45 | |> Keyword.delete(:destinations) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/mojito/pool/poolboy.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Pool.Poolboy do 2 | @moduledoc false 3 | 4 | ## Mojito.Pool.Poolboy is an HTTP client with high-performance, easy-to-use 5 | ## connection pools based on the Poolboy library. 6 | ## 7 | ## Pools are maintained automatically by Mojito, requests are matched to 8 | ## the correct pool without user intervention, and multiple pools can be 9 | ## used for the same destination in order to reduce concurrency bottlenecks. 10 | ## 11 | ## Config parameters are explained in the `Mojito` moduledocs. 12 | 13 | @behaviour Mojito.Pool 14 | 15 | alias Mojito.{Config, Request, Utils} 16 | require Logger 17 | 18 | @doc ~S""" 19 | Performs an HTTP request using a connection pool, creating that pool if 20 | it didn't already exist. Requests are always matched to a pool that is 21 | connected to the correct destination host and port. 22 | """ 23 | @impl true 24 | def request(%{} = request) do 25 | with {:ok, valid_request} <- Request.validate_request(request), 26 | {:ok, _proto, host, port} <- Utils.decompose_url(valid_request.url), 27 | pool_key <- pool_key(host, port), 28 | {:ok, pool} <- get_pool(pool_key) do 29 | do_request(pool, pool_key, valid_request) 30 | end 31 | end 32 | 33 | defp do_request(pool, pool_key, request) do 34 | case Mojito.Pool.Poolboy.Single.request(pool, request) do 35 | {:error, %{reason: :checkout_timeout}} -> 36 | case start_pool(pool_key) do 37 | {:ok, pid} -> 38 | Mojito.Pool.Poolboy.Single.request(pid, request) 39 | 40 | error -> 41 | error 42 | end 43 | 44 | other -> 45 | other 46 | end 47 | end 48 | 49 | ## Returns a pool for the given destination, starting one or more 50 | ## if necessary. 51 | @doc false 52 | @spec get_pool(any) :: {:ok, pid} | {:error, Mojito.error()} 53 | def get_pool(pool_key) do 54 | case get_pools(pool_key) do 55 | [] -> 56 | opts = Mojito.Pool.pool_opts(pool_key) 57 | 1..opts[:pools] |> Enum.each(fn _ -> start_pool(pool_key) end) 58 | get_pool(pool_key) 59 | 60 | pools -> 61 | {:ok, Enum.random(pools)} 62 | end 63 | end 64 | 65 | ## Returns all pools for the given destination. 66 | @doc false 67 | @spec get_pools(any) :: [pid] 68 | defp get_pools(pool_key) do 69 | Mojito.Pool.Poolboy.Registry 70 | |> Registry.lookup(pool_key) 71 | |> Enum.map(fn {_, pid} -> pid end) 72 | end 73 | 74 | ## Starts a new pool for the given destination. 75 | @doc false 76 | @spec start_pool(any) :: {:ok, pid} | {:error, Mojito.error()} 77 | def start_pool(pool_key) do 78 | old_trap_exit = Process.flag(:trap_exit, true) 79 | 80 | try do 81 | GenServer.call( 82 | Mojito.Pool.Poolboy.Manager, 83 | {:start_pool, pool_key}, 84 | Config.timeout() 85 | ) 86 | rescue 87 | e -> {:error, e} 88 | catch 89 | :exit, _ -> {:error, :checkout_timeout} 90 | after 91 | Process.flag(:trap_exit, old_trap_exit) 92 | end 93 | |> Utils.wrap_return_value() 94 | end 95 | 96 | ## Returns a key representing the given destination. 97 | @doc false 98 | @spec pool_key(String.t(), pos_integer) :: Mojito.Pool.pool_key() 99 | def pool_key(host, port) do 100 | {host, port} 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/mojito/pool/poolboy/manager.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Pool.Poolboy.Manager do 2 | ## I'd prefer to start new pools directly in the caller process, but 3 | ## they'd end up disappearing from the registry when the process 4 | ## terminates. So instead we start new pools from here, a long-lived 5 | ## GenServer, and link them to Mojito.Supervisor instead of to here. 6 | 7 | @moduledoc false 8 | 9 | use GenServer 10 | alias Mojito.Telemetry 11 | 12 | def start_link(args) do 13 | GenServer.start_link(__MODULE__, args, name: __MODULE__) 14 | end 15 | 16 | def init(args) do 17 | {:ok, %{args: args, pools: %{}, last_start_at: %{}}} 18 | end 19 | 20 | defp time, do: System.monotonic_time(:millisecond) 21 | 22 | def handle_call({:start_pool, pool_key}, _from, state) do 23 | pool_opts = Mojito.Pool.pool_opts(pool_key) 24 | max_pools = pool_opts[:pools] 25 | 26 | pools = state.pools |> Map.get(pool_key, []) 27 | npools = Enum.count(pools) 28 | 29 | cond do 30 | npools >= max_pools -> 31 | ## We're at max, don't start a new pool 32 | {:reply, {:ok, Enum.random(pools)}, state} 33 | 34 | :else -> 35 | actually_start_pool(pool_key, pool_opts, pools, npools, state) 36 | end 37 | end 38 | 39 | def handle_call(:get_all_pool_states, _from, state) do 40 | all_pool_states = 41 | state.pools 42 | |> Enum.map(fn {pool_key, pools} -> 43 | {pool_key, pools |> Enum.map(&get_poolboy_state/1)} 44 | end) 45 | |> Enum.into(%{}) 46 | 47 | {:reply, all_pool_states, state} 48 | end 49 | 50 | def handle_call({:get_pool_states, pool_key}, _from, state) do 51 | pools = state.pools |> Map.get(pool_key, []) 52 | pool_states = pools |> Enum.map(&get_poolboy_state/1) 53 | {:reply, pool_states, state} 54 | end 55 | 56 | def handle_call({:get_pools, pool_key}, _from, state) do 57 | {:reply, Map.get(state.pools, pool_key, []), state} 58 | end 59 | 60 | def handle_call(:state, _from, state) do 61 | {:reply, state, state} 62 | end 63 | 64 | defp get_poolboy_state(pool_pid) do 65 | {:state, supervisor, workers, waiting, monitors, size, overflow, 66 | max_overflow, strategy} = :sys.get_state(pool_pid) 67 | 68 | %{ 69 | supervisor: supervisor, 70 | workers: workers, 71 | waiting: waiting, 72 | monitors: monitors, 73 | size: size, 74 | overflow: overflow, 75 | max_overflow: max_overflow, 76 | strategy: strategy 77 | } 78 | end 79 | 80 | ## This is designed to be able to launch pools on-demand, but for now we 81 | ## launch all pools at once in Mojito.Pool. 82 | defp actually_start_pool(pool_key, pool_opts, pools, npools, state) do 83 | {host, port} = pool_key 84 | meta = %{host: host, port: port} 85 | start = Telemetry.start(:pool, meta) 86 | 87 | pool_id = {Mojito.Pool, pool_key, npools} 88 | 89 | child_spec = 90 | pool_opts 91 | |> Keyword.put(:id, pool_id) 92 | |> Mojito.Pool.Poolboy.Single.child_spec() 93 | 94 | with {:ok, pool_pid} <- 95 | Supervisor.start_child(Mojito.Supervisor, child_spec), 96 | {:ok, _} <- 97 | Registry.register(Mojito.Pool.Poolboy.Registry, pool_key, pool_pid) do 98 | state = 99 | state 100 | |> put_in([:pools, pool_key], [pool_pid | pools]) 101 | |> put_in([:last_start_at, pool_key], time()) 102 | 103 | Telemetry.stop(:pool, start, meta) 104 | 105 | {:reply, {:ok, pool_pid}, state} 106 | else 107 | {:error, {msg, _pid}} 108 | when msg in [:already_started, :already_registered] -> 109 | ## There was a race; we lost and that is fine 110 | Telemetry.stop(:pool, start, meta) 111 | {:reply, {:ok, Enum.random(pools)}, state} 112 | 113 | error -> 114 | Telemetry.stop(:pool, start, meta) 115 | {:reply, error, state} 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/mojito/pool/poolboy/single.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Pool.Poolboy.Single do 2 | @moduledoc false 3 | 4 | ## Mojito.Pool.Poolboy.Single provides an HTTP request connection pool based on 5 | ## Mojito and Poolboy. 6 | ## 7 | ## Example: 8 | ## 9 | ## >>>> child_spec = Mojito.Pool.Poolboy.Single.child_spec() 10 | ## >>>> {:ok, pool_pid} = Supervisor.start_child(Mojito.Supervisor, child_spec) 11 | ## >>>> Mojito.Pool.Poolboy.Single.request(pool_pid, :get, "http://example.com") 12 | ## {:ok, %Mojito.Response{...}} 13 | 14 | alias Mojito.{Config, ConnServer, Request, Telemetry, Utils} 15 | 16 | @doc false 17 | @deprecated "Use child_spec/1 instead" 18 | def child_spec(name, opts) do 19 | opts 20 | |> Keyword.put(:name, name) 21 | |> child_spec 22 | end 23 | 24 | @doc ~S""" 25 | Returns a child spec suitable to pass to e.g., `Supervisor.start_link/2`. 26 | 27 | Options: 28 | 29 | * `:name` sets a global name for the pool. Optional. 30 | * `:size` sets the initial pool size. Default is 10. 31 | * `:max_overflow` sets the maximum number of additional connections 32 | under high load. Default is 5. 33 | * `:strategy` sets the pool connection-grabbing strategy. Valid values 34 | are `:fifo` and `:lifo` (default). 35 | """ 36 | def child_spec(opts \\ []) 37 | 38 | def child_spec(name) when is_binary(name) do 39 | child_spec(name: name) 40 | end 41 | 42 | def child_spec(opts) do 43 | name = opts[:name] 44 | 45 | name_opts = 46 | case name do 47 | nil -> [] 48 | name -> [name: name] 49 | end 50 | 51 | poolboy_opts = [{:worker_module, Mojito.ConnServer} | opts] 52 | 53 | poolboy_opts = 54 | case name do 55 | nil -> poolboy_opts 56 | name -> [{:name, {:local, name}} | poolboy_opts] 57 | end 58 | 59 | %{ 60 | id: opts[:id] || {Mojito.Pool, make_ref()}, 61 | start: {:poolboy, :start_link, [poolboy_opts, name_opts]}, 62 | restart: :permanent, 63 | shutdown: 5000, 64 | type: :worker 65 | } 66 | end 67 | 68 | @doc ~S""" 69 | Makes an HTTP request using the given connection pool. 70 | 71 | See `request/2` for documentation. 72 | """ 73 | @spec request( 74 | pid, 75 | Mojito.method(), 76 | String.t(), 77 | Mojito.headers(), 78 | String.t(), 79 | Keyword.t() 80 | ) :: {:ok, Mojito.response()} | {:error, Mojito.error()} 81 | def request(pool, method, url, headers \\ [], body \\ "", opts \\ []) do 82 | req = %Request{ 83 | method: method, 84 | url: url, 85 | headers: headers, 86 | body: body, 87 | opts: opts 88 | } 89 | 90 | request(pool, req) 91 | end 92 | 93 | @doc ~S""" 94 | Makes an HTTP request using the given connection pool. 95 | 96 | Options: 97 | 98 | * `:timeout` - Request timeout in milliseconds. Defaults to 99 | `Application.get_env(:mojito, :timeout, 5000)`. 100 | * `:max_body_size` - Max body size in bytes. Defaults to nil in which 101 | case no max size will be enforced. 102 | * `:transport_opts` - Options to be passed to either `:gen_tcp` or `:ssl`. 103 | Most commonly used to perform insecure HTTPS requests via 104 | `transport_opts: [verify: :verify_none]`. 105 | """ 106 | @spec request(pid, Mojito.request()) :: 107 | {:ok, Mojito.response()} | {:error, Mojito.error()} 108 | def request(pool, request) do 109 | ## TODO refactor so request.url is already a URI struct when it gets here 110 | uri = URI.parse(request.url) 111 | 112 | meta = %{ 113 | host: uri.host, 114 | port: uri.port, 115 | path: uri.path, 116 | method: request.method 117 | } 118 | 119 | start_time = Telemetry.start(:request, meta) 120 | 121 | with {:ok, valid_request} <- Request.validate_request(request) do 122 | timeout = valid_request.opts[:timeout] || Config.timeout() 123 | 124 | case do_request(pool, valid_request) do 125 | {:error, %Mojito.Error{reason: %{reason: :closed}}} -> 126 | ## Retry connection-closed errors as many times as we can 127 | new_timeout = calc_new_timeout(timeout, start_time) 128 | 129 | new_request_opts = 130 | valid_request.opts |> Keyword.put(:timeout, new_timeout) 131 | 132 | request(pool, %{valid_request | opts: new_request_opts}) 133 | 134 | other -> 135 | Telemetry.stop(:request, start_time, meta) 136 | other 137 | end 138 | end 139 | end 140 | 141 | defp do_request(pool, request) do 142 | timeout = request.opts[:timeout] || Config.timeout() 143 | start_time = time() 144 | response_ref = make_ref() 145 | 146 | worker_fn = fn worker -> 147 | case ConnServer.request(worker, request, self(), response_ref) do 148 | :ok -> 149 | new_timeout = calc_new_timeout(timeout, start_time) |> max(0) 150 | 151 | receive do 152 | {:mojito_response, ^response_ref, response} -> 153 | ## reduce memory footprint of idle pool 154 | :erlang.garbage_collect(worker) 155 | 156 | response 157 | after 158 | new_timeout -> {:error, :timeout} 159 | end 160 | 161 | e -> 162 | e 163 | end 164 | end 165 | 166 | old_trap_exit = Process.flag(:trap_exit, true) 167 | 168 | try do 169 | :poolboy.transaction(pool, worker_fn, timeout) 170 | rescue 171 | e -> {:error, e} 172 | catch 173 | :exit, _ -> {:error, :checkout_timeout} 174 | after 175 | Process.flag(:trap_exit, old_trap_exit) 176 | end 177 | |> Utils.wrap_return_value() 178 | end 179 | 180 | defp calc_new_timeout(:infinity, _), do: :infinity 181 | 182 | defp calc_new_timeout(timeout, start_time) do 183 | timeout - (time() - start_time) 184 | end 185 | 186 | defp time, do: System.monotonic_time(:millisecond) 187 | end 188 | -------------------------------------------------------------------------------- /lib/mojito/request.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Request do 2 | @moduledoc false 3 | 4 | defstruct method: nil, 5 | url: nil, 6 | headers: [], 7 | body: "", 8 | opts: [] 9 | 10 | alias Mojito.{Error, Headers, Request} 11 | 12 | @doc ~S""" 13 | Checks for errors and returns a canonicalized version of the request. 14 | """ 15 | @spec validate_request(map | Mojito.request() | Mojito.request_kwlist()) :: 16 | {:ok, Mojito.request()} | {:error, Mojito.error()} 17 | 18 | def validate_request(%{} = request) do 19 | method = Map.get(request, :method) 20 | url = Map.get(request, :url) 21 | headers = Map.get(request, :headers, []) 22 | body = Map.get(request, :body) 23 | opts = Map.get(request, :opts, []) 24 | 25 | cond do 26 | method == nil -> 27 | {:error, %Error{message: "method cannot be nil"}} 28 | 29 | method == "" -> 30 | {:error, %Error{message: "method cannot be blank"}} 31 | 32 | url == nil -> 33 | {:error, %Error{message: "url cannot be nil"}} 34 | 35 | url == "" -> 36 | {:error, %Error{message: "url cannot be blank"}} 37 | 38 | !is_list(headers) -> 39 | {:error, %Error{message: "headers must be a list"}} 40 | 41 | !is_binary(body) && !is_nil(body) -> 42 | {:error, %Error{message: "body must be `nil` or a UTF-8 string"}} 43 | 44 | :else -> 45 | method_atom = method_to_atom(method) 46 | 47 | ## Prevent bug #58, where sending "" with HEAD/GET/OPTIONS 48 | ## can screw up HTTP/2 handling 49 | valid_body = 50 | case method_atom do 51 | :get -> nil 52 | :head -> nil 53 | :delete -> nil 54 | :options -> nil 55 | _ -> request.body || "" 56 | end 57 | 58 | {:ok, 59 | %Request{ 60 | method: method_atom, 61 | url: url, 62 | headers: headers, 63 | body: valid_body, 64 | opts: opts 65 | }} 66 | end 67 | end 68 | 69 | def validate_request(request) when is_list(request) do 70 | request |> Enum.into(%{}) |> validate_request 71 | end 72 | 73 | def validate_request(_request) do 74 | {:error, %Error{message: "request must be a map"}} 75 | end 76 | 77 | defp method_to_atom(method) when is_atom(method), do: method 78 | 79 | defp method_to_atom(method) when is_binary(method) do 80 | method |> String.downcase() |> String.to_atom() 81 | end 82 | 83 | @doc ~S""" 84 | Converts non-string header values to UTF-8 string if possible. 85 | """ 86 | @spec convert_headers_values_to_string(Mojito.request()) :: 87 | {:ok, Mojito.request()} 88 | def convert_headers_values_to_string(%{headers: headers} = request) do 89 | {:ok, %{request | headers: Headers.convert_values_to_string(headers)}} 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/mojito/request/single.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Request.Single do 2 | ## Make a single request, without spawning any processes. 3 | 4 | @moduledoc false 5 | 6 | alias Mojito.{Config, Conn, Error, Request, Response} 7 | require Logger 8 | 9 | @doc ~S""" 10 | Performs a single HTTP request, receiving `:tcp` and `:ssl` messages 11 | in the caller process. 12 | 13 | Options: 14 | 15 | * `:timeout` - Response timeout in milliseconds. Defaults to 16 | `Application.get_env(:mojito, :timeout, 5000)`. 17 | * `:max_body_size` - Max body size in bytes. Defaults to nil in which 18 | case no max size will be enforced. 19 | * `:transport_opts` - Options to be passed to either `:gen_tcp` or `:ssl`. 20 | Most commonly used to perform insecure HTTPS requests via 21 | `transport_opts: [verify: :verify_none]`. 22 | """ 23 | @spec request(Mojito.request()) :: 24 | {:ok, Mojito.response()} | {:error, Mojito.error()} 25 | def request(%Request{} = req) do 26 | with_connection(req, fn conn -> 27 | with {:ok, conn, _ref, response} <- Conn.request(conn, req) do 28 | timeout = req.opts[:timeout] || Config.timeout() 29 | receive_response(conn, response, timeout) 30 | end 31 | end) 32 | end 33 | 34 | defp time, do: System.monotonic_time(:millisecond) 35 | 36 | @doc false 37 | def receive_response(conn, response, timeout) do 38 | start_time = time() 39 | 40 | receive do 41 | {:tcp, _, _} = msg -> 42 | handle_msg(conn, response, timeout, msg, start_time) 43 | 44 | {:tcp_closed, _} = msg -> 45 | handle_msg(conn, response, timeout, msg, start_time) 46 | 47 | {:ssl, _, _} = msg -> 48 | handle_msg(conn, response, timeout, msg, start_time) 49 | 50 | {:ssl_closed, _} = msg -> 51 | handle_msg(conn, response, timeout, msg, start_time) 52 | after 53 | timeout -> {:error, %Error{reason: :timeout}} 54 | end 55 | end 56 | 57 | defp handle_msg(conn, response, timeout, msg, start_time) do 58 | new_timeout = fn -> 59 | case timeout do 60 | :infinity -> 61 | :infinity 62 | 63 | _ -> 64 | time_elapsed = time() - start_time 65 | 66 | case timeout - time_elapsed do 67 | x when x < 0 -> 0 68 | x -> x 69 | end 70 | end 71 | end 72 | 73 | case Mint.HTTP.stream(conn.conn, msg) do 74 | {:ok, mint_conn, resps} -> 75 | conn = %{conn | conn: mint_conn} 76 | 77 | case Response.apply_resps(response, resps) do 78 | {:ok, %{complete: true} = response} -> {:ok, response} 79 | {:ok, response} -> receive_response(conn, response, new_timeout.()) 80 | err -> err 81 | end 82 | 83 | {:error, _, e, _} -> 84 | {:error, %Error{reason: e}} 85 | 86 | :unknown -> 87 | receive_response(conn, response, new_timeout.()) 88 | end 89 | end 90 | 91 | defp with_connection(req, fun) do 92 | with {:ok, req} <- Request.validate_request(req), 93 | {:ok, conn} <- Conn.connect(req.url, req.opts) do 94 | try do 95 | fun.(conn) 96 | after 97 | Conn.close(conn) 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/mojito/response.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Response do 2 | @moduledoc false 3 | 4 | alias Mojito.{Error, Response} 5 | 6 | defstruct status_code: nil, 7 | headers: [], 8 | body: "", 9 | complete: false, 10 | size: 0 11 | 12 | @type t :: Mojito.response() 13 | 14 | @doc ~S""" 15 | Applies responses received from `Mint.HTTP.stream/2` to a `%Mojito.Response{}`. 16 | """ 17 | @spec apply_resps(t, [Mint.Types.response()]) :: {:ok, t} | {:error, any} 18 | def apply_resps(response, []), do: {:ok, response} 19 | 20 | def apply_resps(response, [mint_resp | rest]) do 21 | with {:ok, response} <- apply_resp(response, mint_resp) do 22 | apply_resps(response, rest) 23 | end 24 | end 25 | 26 | @doc ~S""" 27 | Applies a response received from `Mint.HTTP.stream/2` to a `%Mojito.Response{}`. 28 | """ 29 | @spec apply_resps(t, Mint.Types.response()) :: {:ok, t} | {:error, any} 30 | def apply_resp(response, {:status, _request_ref, status_code}) do 31 | {:ok, %{response | status_code: status_code}} 32 | end 33 | 34 | def apply_resp(response, {:headers, _request_ref, headers}) do 35 | {:ok, %{response | headers: headers}} 36 | end 37 | 38 | def apply_resp(response, {:data, _request_ref, chunk}) do 39 | with {:ok, response} <- put_chunk(response, chunk) do 40 | {:ok, response} 41 | end 42 | end 43 | 44 | def apply_resp(response, {:done, _request_ref}) do 45 | body = :erlang.iolist_to_binary(response.body) 46 | size = byte_size(body) 47 | {:ok, %{response | complete: true, body: body, size: size}} 48 | end 49 | 50 | @doc ~S""" 51 | Adds chunks to a response body, respecting the `response.size` field. 52 | `response.size` should be set to the maximum number of bytes to accept 53 | as the response body, or `nil` for no limit. 54 | """ 55 | @spec put_chunk(t, binary) :: {:ok, %Response{}} | {:error, any} 56 | def put_chunk(%Response{size: nil} = response, chunk) do 57 | {:ok, %{response | body: [response.body | [chunk]]}} 58 | end 59 | 60 | def put_chunk(%Response{size: remaining} = response, chunk) do 61 | case remaining - byte_size(chunk) do 62 | over_limit when over_limit < 0 -> 63 | {:error, %Error{reason: :max_body_size_exceeded}} 64 | 65 | new_remaining -> 66 | {:ok, 67 | %{response | body: [response.body | [chunk]], size: new_remaining}} 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/mojito/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Telemetry do 2 | @moduledoc ~S""" 3 | Mojito's [Telemetry](https://github.com/beam-telemetry/telemetry) 4 | integration. 5 | 6 | All time measurements are emitted in `:millisecond` units by 7 | default. A different 8 | [Erlang time unit](https://erlang.org/doc/man/erlang.html#type-time_unit) 9 | can be chosen by setting a config parameter like so: 10 | 11 | ``` 12 | config :mojito, Mojito.Telemetry, time_unit: :microsecond 13 | ``` 14 | 15 | Mojito emits the following Telemetry events: 16 | 17 | * `[:mojito, :pool, :start]` before launching a pool 18 | - Measurements: `:system_time` 19 | - Metadata: `:host`, `:port` 20 | 21 | * `[:mojito, :pool, :stop]` after launching a pool 22 | - Measurements: `:system_time`, `:duration` 23 | - Metadata: `:host`, `:port` 24 | 25 | * `[:mojito, :connect, :start]` before connecting to a host 26 | - Measurements: `:system_time` 27 | - Metadata: `:host`, `:port` 28 | 29 | * `[:mojito, :connect, :stop]` after connecting to a host 30 | - Measurements: `:system_time`, `:duration` 31 | - Metadata: `:host`, `:port` 32 | 33 | * `[:mojito, :request, :start]` before making a request 34 | - Measurements: `:system_time` 35 | - Metadata: `:host`, `:port`, `:path`, `:method` 36 | 37 | * `[:mojito, :request, :stop]` after making a request 38 | - Measurements: `:system_time`, `:duration` 39 | - Metadata: `:host`, `:port`, `:path`, `:method` 40 | 41 | """ 42 | 43 | @typep monotonic_time :: integer 44 | 45 | defp time_unit do 46 | Application.get_env(:mojito, Mojito.Telemetry)[:time_unit] || :millisecond 47 | end 48 | 49 | defp monotonic_time do 50 | :erlang.monotonic_time(time_unit()) 51 | end 52 | 53 | defp system_time do 54 | :erlang.system_time(time_unit()) 55 | end 56 | 57 | @doc false 58 | @spec start(atom, map) :: monotonic_time 59 | def start(name, meta \\ %{}) do 60 | start_time = monotonic_time() 61 | 62 | :telemetry.execute( 63 | [:mojito, name, :start], 64 | %{system_time: system_time()}, 65 | meta 66 | ) 67 | 68 | start_time 69 | end 70 | 71 | @doc false 72 | @spec stop(atom, monotonic_time, map) :: monotonic_time 73 | def stop(name, start_time, meta \\ %{}) do 74 | stop_time = monotonic_time() 75 | duration = stop_time - start_time 76 | 77 | :telemetry.execute( 78 | [:mojito, name, :stop], 79 | %{system_time: system_time(), duration: duration}, 80 | meta 81 | ) 82 | 83 | stop_time 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/mojito/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Utils do 2 | @moduledoc false 3 | 4 | alias Mojito.Error 5 | 6 | @doc ~S""" 7 | Ensures that the return value errors are of the form 8 | `{:error, %Mojito.Error{}}`. Values `:ok` and `{:ok, val}` are 9 | considered successful; other values are treated as errors. 10 | """ 11 | @spec wrap_return_value(any) :: :ok | {:ok, any} | {:error, Mojito.error()} 12 | def wrap_return_value(rv) do 13 | case rv do 14 | :ok -> rv 15 | {:ok, _} -> rv 16 | {:error, %Error{}} -> rv 17 | {:error, {:error, e}} -> {:error, %Error{reason: e}} 18 | {:error, e} -> {:error, %Error{reason: e}} 19 | {:error, _mint_conn, error} -> {:error, %Error{reason: error}} 20 | other -> {:error, %Error{reason: :unknown, message: other}} 21 | end 22 | end 23 | 24 | @doc ~S""" 25 | Returns the protocol, hostname, and port (express or implied) from a 26 | web URL. 27 | 28 | iex> Mojito.Utils.decompose_url("http://example.com:8888/test") 29 | {:ok, "http", "example.com", 8888} 30 | 31 | iex> Mojito.Utils.decompose_url("https://user:pass@example.com") 32 | {:ok, "https", "example.com", 443} 33 | """ 34 | @spec decompose_url(String.t()) :: 35 | {:ok, String.t(), String.t(), non_neg_integer} | {:error, any} 36 | def decompose_url(url) do 37 | try do 38 | uri = URI.parse(url) 39 | 40 | cond do 41 | !uri.scheme || !uri.host || !uri.port -> 42 | {:error, %Error{message: "invalid URL: #{url}"}} 43 | 44 | :else -> 45 | {:ok, uri.scheme, uri.host, uri.port} 46 | end 47 | rescue 48 | e -> {:error, %Error{message: "invalid URL", reason: e}} 49 | end 50 | end 51 | 52 | @doc ~S""" 53 | Returns a relative URL including query parts, excluding the fragment, and any 54 | necessary auth headers (i.e., for HTTP Basic auth). 55 | 56 | iex> Mojito.Utils.get_relative_url_and_auth_headers("https://user:pass@example.com/this/is/awesome?foo=bar&baz") 57 | {:ok, "/this/is/awesome?foo=bar&baz", [{"authorization", "Basic dXNlcjpwYXNz"}]} 58 | 59 | iex> Mojito.Utils.get_relative_url_and_auth_headers("https://example.com/something.html#section42") 60 | {:ok, "/something.html", []} 61 | """ 62 | @spec get_relative_url_and_auth_headers(String.t()) :: 63 | {:ok, String.t(), Mojito.headers()} | {:error, any} 64 | def get_relative_url_and_auth_headers(url) do 65 | try do 66 | uri = URI.parse(url) 67 | 68 | headers = 69 | case uri.userinfo do 70 | nil -> [] 71 | userinfo -> [{"authorization", "Basic #{Base.encode64(userinfo)}"}] 72 | end 73 | 74 | joined_url = 75 | [ 76 | if(uri.path, do: "#{uri.path}", else: ""), 77 | if(uri.query, do: "?#{uri.query}", else: "") 78 | ] 79 | |> Enum.join("") 80 | 81 | relative_url = 82 | if String.starts_with?(joined_url, "/") do 83 | joined_url 84 | else 85 | "/" <> joined_url 86 | end 87 | 88 | {:ok, relative_url, headers} 89 | rescue 90 | e -> {:error, %Error{message: "invalid URL", reason: e}} 91 | end 92 | end 93 | 94 | @doc ~S""" 95 | Returns the correct Erlang TCP transport module for the given protocol. 96 | """ 97 | @spec protocol_to_transport(String.t()) :: {:ok, atom} | {:error, any} 98 | def protocol_to_transport("https"), do: {:ok, :ssl} 99 | 100 | def protocol_to_transport("http"), do: {:ok, :gen_tcp} 101 | 102 | def protocol_to_transport(proto), 103 | do: {:error, "unknown protocol #{inspect(proto)}"} 104 | end 105 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Mojito.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.7.12" 5 | @repo_url "https://github.com/appcues/mojito" 6 | 7 | def project do 8 | [ 9 | app: :mojito, 10 | version: @version, 11 | elixir: "~> 1.7", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | start_permanent: Mix.env() == :prod, 14 | dialyzer: [ 15 | plt_add_apps: [:mix] 16 | ], 17 | deps: deps(), 18 | package: package(), 19 | docs: docs() 20 | ] 21 | end 22 | 23 | defp elixirc_paths(:test), do: ["lib", "test/support"] 24 | defp elixirc_paths(_), do: ["lib"] 25 | 26 | defp package do 27 | [ 28 | description: "Fast, easy to use HTTP client based on Mint", 29 | licenses: ["MIT"], 30 | maintainers: ["pete gamache "], 31 | links: %{ 32 | Changelog: "https://hexdocs.pm/mojito/changelog.html", 33 | GitHub: @repo_url 34 | } 35 | ] 36 | end 37 | 38 | def application do 39 | [ 40 | extra_applications: [:logger], 41 | mod: {Mojito.Application, []} 42 | ] 43 | end 44 | 45 | defp deps do 46 | [ 47 | {:mint, "~> 1.1"}, 48 | {:castore, "~> 0.1"}, 49 | {:poolboy, "~> 1.5"}, 50 | {:telemetry, "~> 0.4 or ~> 1.0"}, 51 | {:ex_spec, "~> 2.0", only: :test}, 52 | {:jason, "~> 1.0", only: :test}, 53 | {:cowboy, "~> 2.0", only: :test}, 54 | {:plug, "~> 1.3", only: :test}, 55 | {:plug_cowboy, "~> 2.0", only: :test}, 56 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 57 | {:dialyxir, "~> 1.0", only: :dev, runtime: false} 58 | ] 59 | end 60 | 61 | defp docs do 62 | [ 63 | extras: [ 64 | "CHANGELOG.md": [title: "Changelog"], 65 | "LICENSE.md": [title: "License"] 66 | ], 67 | assets: "assets", 68 | logo: "assets/mojito.png", 69 | main: "Mojito", 70 | source_url: @repo_url, 71 | source_ref: @version, 72 | formatters: ["html"] 73 | ] 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "0.1.14", "3f6d7c7c1574c402fef29559d3f1a7389ba3524bc6a090a5e9e6abc3af65dcca", [:mix], [], "hexpm", "b34af542eadb727e6c8b37fdf73e18b2e02eb483a4ea0b52fd500bc23f052b7b"}, 3 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, 4 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 5 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, 6 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 7 | "earmark": {:hex, :earmark, "1.4.9", "837e4c1c5302b3135e9955f2bbf52c6c52e950c383983942b68b03909356c0d9", [:mix], [{:earmark_parser, ">= 1.4.9", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "0d72df7d13a3dc8422882bed5263fdec5a773f56f7baeb02379361cb9e5b0d8e"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.18", "e1b2be73eb08a49fb032a0208bf647380682374a725dfb5b9e510def8397f6f2", [:mix], [], "hexpm", "114a0e85ec3cf9e04b811009e73c206394ffecfcc313e0b346de0d557774ee97"}, 9 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 10 | "ex_doc": {:hex, :ex_doc, "0.26.0", "1922164bac0b18b02f84d6f69cab1b93bc3e870e2ad18d5dacb50a9e06b542a3", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2775d66e494a9a48355db7867478ffd997864c61c65a47d31c4949459281c78d"}, 11 | "ex_spec": {:hex, :ex_spec, "2.0.1", "8bdbd6fa85995fbf836ed799571d44be6f9ebbcace075209fd0ad06372c111cf", [:mix], [], "hexpm", "b44fe5054497411a58341ece5bf7756c219d9d6c1303b5ac467f557a0a4c31ac"}, 12 | "freedom_formatter": {:hex, :freedom_formatter, "1.0.0", "b19be4a845082d05d32bb23765e8e7bdc6d51decac13ab64ae44b0f6bf8a66d1", [:mix], [], "hexpm"}, 13 | "fuzzyurl": {:hex, :fuzzyurl, "1.0.1", "389780519adccfc3582ecce8c6608c1619f029367abfdc9d4b6e46491d2fa8d5", [:mix], [], "hexpm"}, 14 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 15 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 18 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 19 | "mint": {:hex, :mint, "1.4.0", "cd7d2451b201fc8e4a8fd86257fb3878d9e3752899eb67b0c5b25b180bde1212", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "10a99e144b815cbf8522dccbc8199d15802440fc7a64d67b6853adb6fa170217"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.0", "b44d75e2a6542dcb6acf5d71c32c74ca88960421b6874777f79153bbbbd7dccc", [:mix], [], "hexpm", "52b2871a7515a5ac49b00f214e4165a40724cf99798d8e4a65e4fd64ebd002c1"}, 21 | "plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"}, 22 | "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, 23 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 24 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, 25 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 26 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 27 | "xhttp": {:git, "https://github.com/ericmj/xhttp.git", "d0606462cba650fdb91b61e779f38e4e9b6383f6", []}, 28 | } 29 | -------------------------------------------------------------------------------- /test/headers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mojito.HeadersTest do 2 | use ExUnit.Case, async: true 3 | doctest Mojito.Headers 4 | alias Mojito.Headers 5 | 6 | @test_headers [ 7 | {"header1", "value1"}, 8 | {"header3", "value3-1"}, 9 | {"header2", "value2"}, 10 | {"HeaDer3", "value3-2"} 11 | ] 12 | 13 | test "Headers.get with no match" do 14 | assert(nil == Headers.get(@test_headers, "header0")) 15 | end 16 | 17 | test "Headers.get with case-sensitive match" do 18 | assert("value1" == Headers.get(@test_headers, "header1")) 19 | assert("value2" == Headers.get(@test_headers, "header2")) 20 | end 21 | 22 | test "Headers.get with case-insensitive match" do 23 | assert("value1" == Headers.get(@test_headers, "HEADER1")) 24 | assert("value2" == Headers.get(@test_headers, "hEaDeR2")) 25 | end 26 | 27 | test "Headers.get with multiple values" do 28 | assert("value3-1,value3-2" == Headers.get(@test_headers, "header3")) 29 | end 30 | 31 | test "Headers.get_values with no match" do 32 | assert([] == Headers.get_values(@test_headers, "header0")) 33 | end 34 | 35 | test "Headers.get_values with case-sensitive match" do 36 | assert(["value1"] == Headers.get_values(@test_headers, "header1")) 37 | assert(["value2"] == Headers.get_values(@test_headers, "header2")) 38 | end 39 | 40 | test "Headers.get_values with case-insensitive match" do 41 | assert(["value1"] == Headers.get_values(@test_headers, "HEADER1")) 42 | assert(["value2"] == Headers.get_values(@test_headers, "hEaDeR2")) 43 | end 44 | 45 | test "Headers.get_values with multiple values" do 46 | assert( 47 | ["value3-1", "value3-2"] == Headers.get_values(@test_headers, "header3") 48 | ) 49 | end 50 | 51 | test "Headers.put when value doesn't exist" do 52 | output = [ 53 | {"header1", "value1"}, 54 | {"header3", "value3-1"}, 55 | {"header2", "value2"}, 56 | {"HeaDer3", "value3-2"}, 57 | {"header4", "new value"} 58 | ] 59 | 60 | assert(output == Headers.put(@test_headers, "header4", "new value")) 61 | end 62 | 63 | test "Headers.put when value exists once" do 64 | output = [ 65 | {"header1", "value1"}, 66 | {"header3", "value3-1"}, 67 | {"HeaDer3", "value3-2"}, 68 | {"heADer2", "new value"} 69 | ] 70 | 71 | assert(output == Headers.put(@test_headers, "heADer2", "new value")) 72 | end 73 | 74 | test "Headers.put when value exists multiple times" do 75 | output = [ 76 | {"header1", "value1"}, 77 | {"header2", "value2"}, 78 | {"HeaDer3", "new value"} 79 | ] 80 | 81 | assert(output == Headers.put(@test_headers, "HeaDer3", "new value")) 82 | end 83 | 84 | test "Headers.delete when value doesn't exist" do 85 | assert(@test_headers == Headers.delete(@test_headers, "nope")) 86 | end 87 | 88 | test "Headers.delete when value exists once" do 89 | output = [ 90 | {"header1", "value1"}, 91 | {"header3", "value3-1"}, 92 | {"HeaDer3", "value3-2"} 93 | ] 94 | 95 | assert(output == Headers.delete(@test_headers, "heADer2")) 96 | end 97 | 98 | test "Headers.delete when value exists multiple times" do 99 | output = [ 100 | {"header1", "value1"}, 101 | {"header2", "value2"} 102 | ] 103 | 104 | assert(output == Headers.delete(@test_headers, "HEADER3")) 105 | end 106 | 107 | test "Headers.keys" do 108 | assert(["header1", "header3", "header2"] == Headers.keys(@test_headers)) 109 | end 110 | 111 | test "normalize_headers" do 112 | output = [ 113 | {"header1", "value1"}, 114 | {"header3", "value3-1,value3-2"}, 115 | {"header2", "value2"} 116 | ] 117 | 118 | assert(output == Headers.normalize(@test_headers)) 119 | end 120 | 121 | test "convert_values_to_string converts numbers and atoms to string" do 122 | input = [ 123 | {"integer", 2}, 124 | {"float", 22.5}, 125 | {"atom", :atom}, 126 | {"list", [1, 2]}, 127 | {"string", "string"} 128 | ] 129 | 130 | output = [ 131 | {"integer", "2"}, 132 | {"float", "22.5"}, 133 | {"atom", "atom"}, 134 | {"list", [1, 2]}, 135 | {"string", "string"} 136 | ] 137 | 138 | assert(output == Headers.convert_values_to_string(input)) 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /test/mojito_sync_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MojitoSyncTest do 2 | use ExSpec, async: false 3 | doctest Mojito 4 | 5 | context "local server tests" do 6 | @http_port Application.get_env(:mojito, :test_server_http_port) 7 | 8 | defp get(path, opts) do 9 | Mojito.get( 10 | "http://localhost:#{@http_port}#{path}", 11 | [], 12 | opts 13 | ) 14 | end 15 | 16 | it "doesn't leak connections with pool: false" do 17 | original_open_ports = length(open_tcp_ports(@http_port)) 18 | assert({:ok, response} = get("/", pool: false)) 19 | assert(200 == response.status_code) 20 | 21 | final_open_ports = length(open_tcp_ports(@http_port)) 22 | assert original_open_ports == final_open_ports 23 | end 24 | end 25 | 26 | defp open_tcp_ports(to_port) do 27 | Enum.filter(tcp_sockets(), fn socket -> 28 | case :inet.peername(socket) do 29 | {:ok, {_ip, ^to_port}} -> true 30 | _error -> false 31 | end 32 | end) 33 | end 34 | 35 | defp tcp_sockets() do 36 | Enum.filter(:erlang.ports(), fn port -> 37 | case :erlang.port_info(port, :name) do 38 | {_, 'tcp_inet'} -> true 39 | _ -> false 40 | end 41 | end) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/mojito_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MojitoTest do 2 | use ExSpec, async: true 3 | doctest Mojito 4 | doctest Mojito.Utils 5 | 6 | alias Mojito.{Error, Headers} 7 | 8 | context "url validation" do 9 | it "fails on url without protocol" do 10 | assert({:error, _} = Mojito.request(:get, "localhost/path")) 11 | assert({:error, _} = Mojito.request(:get, "/localhost/path")) 12 | assert({:error, _} = Mojito.request(:get, "//localhost/path")) 13 | assert({:error, _} = Mojito.request(:get, "localhost//path")) 14 | end 15 | 16 | it "fails on url with bad protocol" do 17 | assert({:error, _} = Mojito.request(:get, "garbage://localhost/path")) 18 | assert({:error, _} = Mojito.request(:get, "ftp://localhost/path")) 19 | end 20 | 21 | it "fails on url without hostname" do 22 | assert({:error, _} = Mojito.request(:get, "http://")) 23 | end 24 | 25 | it "fails on blank url" do 26 | assert({:error, err} = Mojito.request(:get, "")) 27 | assert(is_binary(err.message)) 28 | end 29 | 30 | it "fails on nil url" do 31 | assert({:error, err} = Mojito.request(:get, nil)) 32 | assert(is_binary(err.message)) 33 | end 34 | end 35 | 36 | context "method validation" do 37 | it "fails on blank method" do 38 | assert({:error, err} = Mojito.request("", "https://cool.com")) 39 | assert(is_binary(err.message)) 40 | end 41 | 42 | it "fails on nil method" do 43 | assert({:error, err} = Mojito.request(nil, "https://cool.com")) 44 | assert(is_binary(err.message)) 45 | end 46 | end 47 | 48 | context "local server tests" do 49 | @http_port Application.get_env(:mojito, :test_server_http_port) 50 | @https_port Application.get_env(:mojito, :test_server_https_port) 51 | 52 | defp head(path, opts \\ []) do 53 | Mojito.head( 54 | "http://localhost:#{@http_port}#{path}", 55 | [], 56 | opts 57 | ) 58 | end 59 | 60 | defp get(path, opts \\ []) do 61 | Mojito.get( 62 | "http://localhost:#{@http_port}#{path}", 63 | [], 64 | opts 65 | ) 66 | end 67 | 68 | defp get_with_user(path, user, opts \\ []) do 69 | Mojito.get( 70 | "http://#{user}@localhost:#{@http_port}#{path}", 71 | [], 72 | opts 73 | ) 74 | end 75 | 76 | defp get_with_user_and_pass(path, user, pass, opts \\ []) do 77 | Mojito.get( 78 | "http://#{user}:#{pass}@localhost:#{@http_port}#{path}", 79 | [], 80 | opts 81 | ) 82 | end 83 | 84 | defp post(path, body_obj, opts \\ []) do 85 | body = Jason.encode!(body_obj) 86 | headers = [{"content-type", "application/json"}] 87 | 88 | Mojito.post( 89 | "http://localhost:#{@http_port}#{path}", 90 | headers, 91 | body, 92 | opts 93 | ) 94 | end 95 | 96 | defp put(path, body_obj, opts \\ []) do 97 | body = Jason.encode!(body_obj) 98 | headers = [{"content-type", "application/json"}] 99 | 100 | Mojito.put( 101 | "http://localhost:#{@http_port}#{path}", 102 | headers, 103 | body, 104 | opts 105 | ) 106 | end 107 | 108 | defp patch(path, body_obj, opts \\ []) do 109 | body = Jason.encode!(body_obj) 110 | headers = [{"content-type", "application/json"}] 111 | 112 | Mojito.patch( 113 | "http://localhost:#{@http_port}#{path}", 114 | headers, 115 | body, 116 | opts 117 | ) 118 | end 119 | 120 | defp delete(path, opts \\ []) do 121 | Mojito.delete( 122 | "http://localhost:#{@http_port}#{path}", 123 | [], 124 | opts 125 | ) 126 | end 127 | 128 | defp options(path, opts \\ []) do 129 | Mojito.options( 130 | "http://localhost:#{@http_port}#{path}", 131 | [], 132 | opts 133 | ) 134 | end 135 | 136 | defp get_ssl(path, opts \\ []) do 137 | Mojito.get( 138 | "https://localhost:#{@https_port}#{path}", 139 | [], 140 | [transport_opts: [verify: :verify_none]] ++ opts 141 | ) 142 | end 143 | 144 | it "accepts kwlist input" do 145 | assert( 146 | {:ok, _response} = 147 | Mojito.request(method: :get, url: "http://localhost:#{@http_port}/") 148 | ) 149 | end 150 | 151 | it "accepts pool: true" do 152 | assert( 153 | {:ok, _response} = 154 | Mojito.request( 155 | method: :get, 156 | url: "http://localhost:#{@http_port}/", 157 | opts: [pool: true] 158 | ) 159 | ) 160 | end 161 | 162 | it "accepts pool: false" do 163 | assert( 164 | {:ok, _response} = 165 | Mojito.request( 166 | method: :get, 167 | url: "http://localhost:#{@http_port}/", 168 | opts: [pool: false] 169 | ) 170 | ) 171 | end 172 | 173 | it "accepts pool: pid" do 174 | child_spec = Mojito.Pool.Poolboy.Single.child_spec() 175 | {:ok, pool_pid} = Supervisor.start_child(Mojito.Supervisor, child_spec) 176 | 177 | assert( 178 | {:ok, _response} = 179 | Mojito.request( 180 | method: :get, 181 | url: "http://localhost:#{@http_port}/", 182 | opts: [pool: pool_pid] 183 | ) 184 | ) 185 | end 186 | 187 | it "can make HTTP requests" do 188 | assert({:ok, response} = get("/")) 189 | assert(200 == response.status_code) 190 | assert("Hello world!" == response.body) 191 | assert(12 == response.size) 192 | assert("12" == Headers.get(response.headers, "content-length")) 193 | end 194 | 195 | it "can use HTTP/1.1" do 196 | assert({:ok, response} = get("/", protocols: [:http1])) 197 | assert(200 == response.status_code) 198 | assert("Hello world!" == response.body) 199 | assert(12 == response.size) 200 | assert("12" == Headers.get(response.headers, "content-length")) 201 | end 202 | 203 | it "can use HTTP/2" do 204 | assert({:ok, response} = get("/", protocols: [:http2])) 205 | assert(200 == response.status_code) 206 | assert("Hello world!" == response.body) 207 | assert(12 == response.size) 208 | assert("12" == Headers.get(response.headers, "content-length")) 209 | end 210 | 211 | it "can make HTTPS requests" do 212 | assert({:ok, response} = get_ssl("/")) 213 | assert(200 == response.status_code) 214 | assert("Hello world!" == response.body) 215 | assert(12 == response.size) 216 | assert("12" == Headers.get(response.headers, "content-length")) 217 | end 218 | 219 | it "handles timeouts" do 220 | assert({:ok, _} = get("/", timeout: 100)) 221 | assert({:error, %Error{reason: :timeout}} = get("/wait1", timeout: 100)) 222 | end 223 | 224 | it "handles timeouts even on long requests" do 225 | port = Application.get_env(:mojito, :test_server_http_port) 226 | {:ok, conn} = Mojito.Conn.connect("http://localhost:#{port}") 227 | 228 | mint_conn = 229 | Map.put(conn.conn, :request, %{ 230 | ref: nil, 231 | state: :status, 232 | method: :get, 233 | version: nil, 234 | status: nil, 235 | headers_buffer: [], 236 | content_length: nil, 237 | connection: [], 238 | transfer_encoding: [], 239 | body: nil 240 | }) 241 | 242 | conn = %{conn | conn: mint_conn} 243 | 244 | pid = self() 245 | 246 | spawn(fn -> 247 | socket = conn.conn.socket 248 | Process.sleep(30) 249 | send(pid, {:tcp, socket, "HTTP/1.1 200 OK\r\nserver: Cowboy"}) 250 | Process.sleep(30) 251 | send(pid, {:tcp, socket, "\r\ndate: Thu, 25 Apr 2019 10:48:25"}) 252 | Process.sleep(30) 253 | send(pid, {:tcp, socket, " GMT\r\ncontent-length: 12\r\ncache-"}) 254 | Process.sleep(30) 255 | send(pid, {:tcp, socket, "control: max-age=0, private, must-"}) 256 | Process.sleep(30) 257 | send(pid, {:tcp, socket, "revalidate\r\n\r\nHello world!"}) 258 | end) 259 | 260 | assert( 261 | {:error, %{reason: :timeout}} = 262 | Mojito.Request.Single.receive_response( 263 | conn, 264 | %Mojito.Response{}, 265 | 100 266 | ) 267 | ) 268 | end 269 | 270 | it "can set a max size" do 271 | assert( 272 | {:error, %Mojito.Error{message: nil, reason: :max_body_size_exceeded}} == 273 | get("/infinite", timeout: 10000, max_body_size: 10) 274 | ) 275 | end 276 | 277 | it "handles requests after a timeout" do 278 | assert({:error, %{reason: :timeout}} = get("/wait?d=10", timeout: 1)) 279 | Process.sleep(100) 280 | assert({:ok, %{body: "Hello Alice!"}} = get("?name=Alice")) 281 | end 282 | 283 | it "handles URL query params" do 284 | assert({:ok, %{body: "Hello Alice!"}} = get("/?name=Alice")) 285 | assert({:ok, %{body: "Hello Alice!"}} = get("?name=Alice")) 286 | end 287 | 288 | it "can post data" do 289 | assert({:ok, response} = post("/post", %{name: "Charlie"})) 290 | resp_body = response.body |> Jason.decode!() 291 | assert("Charlie" == resp_body["name"]) 292 | end 293 | 294 | it "handles user+pass in URL" do 295 | assert({:ok, %{status_code: 500}} = get("/auth")) 296 | 297 | assert( 298 | {:ok, %{status_code: 200} = response} = get_with_user("/auth", "hi") 299 | ) 300 | 301 | assert(%{"user" => "hi", "pass" => nil} = Jason.decode!(response.body)) 302 | 303 | assert( 304 | {:ok, %{status_code: 200} = response} = 305 | get_with_user_and_pass("/auth", "hi", "mom") 306 | ) 307 | 308 | assert(%{"user" => "hi", "pass" => "mom"} = Jason.decode!(response.body)) 309 | end 310 | 311 | it "can make HEAD request" do 312 | assert({:ok, response} = head("/")) 313 | assert(200 == response.status_code) 314 | assert("" == response.body) 315 | assert("12" == Headers.get(response.headers, "content-length")) 316 | end 317 | 318 | it "can make PATCH request" do 319 | assert({:ok, response} = patch("/patch", %{name: "Charlie"})) 320 | resp_body = response.body |> Jason.decode!() 321 | assert("Charlie" == resp_body["name"]) 322 | end 323 | 324 | it "can make PUT request" do 325 | assert({:ok, response} = put("/put", %{name: "Charlie"})) 326 | resp_body = response.body |> Jason.decode!() 327 | assert("Charlie" == resp_body["name"]) 328 | end 329 | 330 | it "can make DELETE request" do 331 | assert({:ok, response} = delete("/delete")) 332 | assert(200 == response.status_code) 333 | end 334 | 335 | it "can make OPTIONS request" do 336 | assert({:ok, response} = options("/")) 337 | 338 | assert( 339 | "OPTIONS, GET, HEAD, POST, PATCH, PUT, DELETE" == 340 | Headers.get(response.headers, "allow") 341 | ) 342 | end 343 | 344 | it "expands gzip" do 345 | assert({:ok, response} = get("/gzip")) 346 | assert("{\"ok\":true}\n" == response.body) 347 | 348 | assert({:ok, response} = get("/gzip", raw: true)) 349 | assert("{\"ok\":true}\n" != response.body) 350 | end 351 | 352 | it "expands deflate" do 353 | assert({:ok, response} = get("/deflate")) 354 | assert("{\"ok\":true}\n" == response.body) 355 | 356 | assert({:ok, response} = get("/deflate", raw: true)) 357 | assert("{\"ok\":true}\n" != response.body) 358 | end 359 | 360 | it "handles connection:close response" do 361 | assert({:ok, response} = get("/close", pool: false)) 362 | assert("close" == response.body) 363 | end 364 | 365 | it "handles ssl connection:close response" do 366 | assert({:ok, response} = get_ssl("/close", pool: false)) 367 | assert("close" == response.body) 368 | end 369 | 370 | it "can POST big bodies over HTTP/1" do 371 | big = String.duplicate("x", 5_000_000) 372 | body = %{name: big} 373 | assert({:ok, response} = post("/post", body, protocols: [:http1])) 374 | assert({:ok, map} = Jason.decode(response.body)) 375 | assert(%{"name" => big} == map) 376 | end 377 | 378 | it "can POST big bodies over HTTP/2" do 379 | big = String.duplicate("é", 2_500_000) 380 | body = %{name: big} 381 | assert({:ok, response} = post("/post", body, protocols: [:http2])) 382 | assert({:ok, map} = Jason.decode(response.body)) 383 | assert(%{"name" => big} == map) 384 | end 385 | 386 | it "handles response chunks arriving during stream_request_body" do 387 | ## sending a body this big will trigger a 500 error in Cowboy 388 | ## because we have not configured it otherwise 389 | big = String.duplicate("x", 100_000_000) 390 | body = %{name: big} 391 | 392 | assert( 393 | {:ok, response} = 394 | post("/post", body, protocols: [:http2], timeout: 10_000) 395 | ) 396 | 397 | assert(500 == response.status_code) 398 | end 399 | 400 | it "handles timeouts during stream_request_body" do 401 | big = String.duplicate("x", 5_000_000) 402 | body = %{name: big} 403 | 404 | assert( 405 | {:error, %{reason: :timeout}} = 406 | post("/post", body, protocols: [:http2], timeout: 10) 407 | ) 408 | end 409 | end 410 | 411 | context "external tests" do 412 | it "can make HTTPS requests using proper cert chain by default" do 413 | assert({:ok, _} = Mojito.request(:get, "https://github.com")) 414 | end 415 | end 416 | end 417 | -------------------------------------------------------------------------------- /test/pool/poolboy/manager_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Pool.Poolboy.ManagerTest do 2 | use ExSpec, async: true 3 | 4 | context "calls" do 5 | it "implements get_pools" do 6 | assert( 7 | [] = 8 | GenServer.call( 9 | Mojito.Pool.Poolboy.Manager, 10 | {:get_pools, {"example.com", 80}} 11 | ) 12 | ) 13 | end 14 | 15 | it "implements get_pool_states" do 16 | assert( 17 | [] = 18 | GenServer.call( 19 | Mojito.Pool.Poolboy.Manager, 20 | {:get_pool_states, {"example.com", 80}} 21 | ) 22 | ) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/pool/poolboy/single_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Pool.Poolboy.SingleTest do 2 | use ExSpec, async: true 3 | doctest Mojito.Pool.Poolboy.Single 4 | doctest Mojito.ConnServer 5 | 6 | context "Mojito.Pool.Single" do 7 | @http_port Application.get_env(:mojito, :test_server_http_port) 8 | @https_port Application.get_env(:mojito, :test_server_https_port) 9 | 10 | defp with_pool(fun) do 11 | rand = round(:rand.uniform() * 1_000_000_000) 12 | pool_name = "TestPool#{rand}" |> String.to_atom() 13 | {:ok, pid} = start_pool(pool_name, size: 2, max_overflow: 1) 14 | fun.(pool_name) 15 | GenServer.stop(pid) 16 | end 17 | 18 | defp start_pool(name, opts) do 19 | children = [Mojito.Pool.Poolboy.Single.child_spec([{:name, name} | opts])] 20 | Supervisor.start_link(children, strategy: :one_for_one) 21 | end 22 | 23 | defp get(pool, path, opts \\ []) do 24 | Mojito.Pool.Poolboy.Single.request( 25 | pool, 26 | :get, 27 | "http://localhost:#{@http_port}#{path}", 28 | [], 29 | "", 30 | opts 31 | ) 32 | end 33 | 34 | defp get_ssl(pool, path, opts \\ []) do 35 | Mojito.Pool.Poolboy.Single.request( 36 | pool, 37 | :get, 38 | "https://localhost:#{@https_port}#{path}", 39 | [], 40 | "", 41 | [transport_opts: [verify: :verify_none]] ++ opts 42 | ) 43 | end 44 | 45 | it "can make HTTP requests" do 46 | with_pool(fn pool_name -> 47 | assert({:ok, response} = get(pool_name, "/")) 48 | assert(200 == response.status_code) 49 | end) 50 | end 51 | 52 | it "can make HTTPS requests" do 53 | with_pool(fn pool_name -> 54 | assert({:ok, response} = get_ssl(pool_name, "/")) 55 | assert(200 == response.status_code) 56 | end) 57 | end 58 | 59 | it "can get a max body size error" do 60 | with_pool(fn pool_name -> 61 | assert( 62 | {:error, %Mojito.Error{message: nil, reason: :max_body_size_exceeded}} == 63 | get(pool_name, "/infinite", max_body_size: 10) 64 | ) 65 | end) 66 | end 67 | 68 | it "can saturate pool" do 69 | with_pool(fn pool_name -> 70 | spawn(fn -> get(pool_name, "/wait1") end) 71 | spawn(fn -> get(pool_name, "/wait1") end) 72 | spawn(fn -> get(pool_name, "/wait1") end) 73 | spawn(fn -> get(pool_name, "/wait1") end) 74 | :timer.sleep(100) 75 | 76 | assert( 77 | {:error, %{reason: :checkout_timeout}} = 78 | get(pool_name, "/wait1", timeout: 50) 79 | ) 80 | 81 | ## 0 ready, 1 waiting, 3 in-progress 82 | assert({:full, 0, 1, 3} = :poolboy.status(pool_name)) 83 | 84 | :timer.sleep(1000) 85 | 86 | ## 1 ready, 0 waiting, 1 in-progress (the one previously waiting) 87 | assert({:ready, 1, 0, 1} = :poolboy.status(pool_name)) 88 | 89 | :timer.sleep(1000) 90 | 91 | ## 2 ready, 0 waiting, 0 in progress (all done) 92 | assert({:ready, 2, 0, 0} = :poolboy.status(pool_name)) 93 | end) 94 | end 95 | 96 | it "retries request when connection was closed" do 97 | with_pool(fn pool_name -> 98 | 1..100 99 | |> Enum.each(fn _ -> 100 | assert({:ok, _resp} = get(pool_name, "/")) 101 | end) 102 | end) 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/pool/poolboy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mojito.Pool.PoolboyTest do 2 | use ExSpec, async: false 3 | doctest Mojito.Pool.Poolboy 4 | 5 | context "Mojito.Pool" do 6 | @http_port Application.get_env(:mojito, :test_server_http_port) 7 | @https_port Application.get_env(:mojito, :test_server_https_port) 8 | 9 | defp get(path, opts \\ []) do 10 | Mojito.Pool.Poolboy.request(%Mojito.Request{ 11 | method: :get, 12 | url: "http://localhost:#{@http_port}#{path}", 13 | opts: opts 14 | }) 15 | end 16 | 17 | defp get_ssl(path, opts \\ []) do 18 | Mojito.Pool.Poolboy.request(%Mojito.Request{ 19 | method: :get, 20 | url: "https://localhost:#{@https_port}#{path}", 21 | opts: [transport_opts: [verify: :verify_none]] ++ opts 22 | }) 23 | end 24 | 25 | it "can make HTTP requests" do 26 | assert({:ok, response} = get("/")) 27 | assert(200 == response.status_code) 28 | end 29 | 30 | it "can make HTTPS requests" do 31 | assert({:ok, response} = get_ssl("/")) 32 | assert(200 == response.status_code) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/support/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDwDCCAqgCCQCM+nz93AYZJzANBgkqhkiG9w0BAQsFADCBoDELMAkGA1UEBhMC 3 | VVMxFjAUBgNVBAgMDU1hc3NhY2h1c2V0dHMxDzANBgNVBAcMBkJvc3RvbjEQMA4G 4 | A1UECgwHQXBwY3VlczEfMB0GA1UECwwWRW5naW5lZXJpbmcgRGVwYXJ0bWVudDEU 5 | MBIGA1UEAwwLYXBwY3Vlcy5jb20xHzAdBgkqhkiG9w0BCQEWEHRlYW1AYXBwY3Vl 6 | cy5jb20wIBcNMTgwMzI1MTQxMTAzWhgPMjExODAzMDExNDExMDNaMIGgMQswCQYD 7 | VQQGEwJVUzEWMBQGA1UECAwNTWFzc2FjaHVzZXR0czEPMA0GA1UEBwwGQm9zdG9u 8 | MRAwDgYDVQQKDAdBcHBjdWVzMR8wHQYDVQQLDBZFbmdpbmVlcmluZyBEZXBhcnRt 9 | ZW50MRQwEgYDVQQDDAthcHBjdWVzLmNvbTEfMB0GCSqGSIb3DQEJARYQdGVhbUBh 10 | cHBjdWVzLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMraxsKV 11 | XjFNEqgbaDHlaxm/ZCiSQj8oY/Q9FjeaRjGQNkupmDqaF6HvfPLWQ+dNkQin0t0T 12 | 16iwTjO047SgPvLd3F9zTAxy8cBdlZf4h8suZa+88Jg7otlEuQ4C47Le9iCUrkum 13 | AsWpZXOz3QZ//eoQjYX3V3byovct6mdGOF5gy4e1rCr97WcwaLNkA3upaitKr4tG 14 | 4UBoNAunrB/98NGntXjcNV8JEGUJLNHS3E/BADbIASwpNqX2vs6Yb64/VKUhOpVv 15 | SdoQbyyDkDDtmv9fpe+LtF5XC1lpIaMUPzwD9BXKJ1iUqouiBhlFBXyiPsxlwrWg 16 | BS0kdCv86cnMowkCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAeRHRYGIw4odN6GkG 17 | d+sUNWSFtZ/Bnwv9KOBzzU4A5Rmmkv2dZ3XAC7CZqeozpqQl0pzyy2AAH8U6/YK3 18 | 0ztR91m3IvpU+SP/pAXhxgaBOd0vsYHrSejuB5Dlx3pLPR1jxTp0hRMFcbfg92py 19 | R/QNcvGMHBV6ASVPa5lXgll/lrKLHBqA3BdeUkQKySwo2yDVkAXL4FIfECigUcvx 20 | EOe6ZVKqRjjGevVqPUtDLRM2bEqNCPj+FbaX/xyLlGa5vLr4Ew13kHMPe6w/Y3ji 21 | PSXvWGfv6RufLDHuFUWAFzmmJKl05Ccr/9U+hq045WxbXGt9pz5GSMRErHzIowSS 22 | u6HvlQ== 23 | -----END CERTIFICATE----- 24 | -------------------------------------------------------------------------------- /test/support/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDK2sbClV4xTRKo 3 | G2gx5WsZv2QokkI/KGP0PRY3mkYxkDZLqZg6mheh73zy1kPnTZEIp9LdE9eosE4z 4 | tOO0oD7y3dxfc0wMcvHAXZWX+IfLLmWvvPCYO6LZRLkOAuOy3vYglK5LpgLFqWVz 5 | s90Gf/3qEI2F91d28qL3LepnRjheYMuHtawq/e1nMGizZAN7qWorSq+LRuFAaDQL 6 | p6wf/fDRp7V43DVfCRBlCSzR0txPwQA2yAEsKTal9r7OmG+uP1SlITqVb0naEG8s 7 | g5Aw7Zr/X6Xvi7ReVwtZaSGjFD88A/QVyidYlKqLogYZRQV8oj7MZcK1oAUtJHQr 8 | /OnJzKMJAgMBAAECggEAYL1QyH8fOne9C/p2CEWWe+LwSwDlIuWKNXHkZIPoMb7K 9 | he7NMDVIS+vANLbGD0rIfc47Gz9ZO5NI2BPN+9fn7T6s18BOZily7QA0VRMq/1ST 10 | HeoG+zKFiQPjFLGAEU+PJR6CuITlEYqlXTZLk8v6NWPLejXouksgOKzm+nVccHTz 11 | frqn+tKoz4p560uHevqWqmXZNejY5A365SzYVZ9bQj/aPaM1bNVKrfj4hvc5KnSG 12 | FdVeQVZjMgLNbOZUTyF7XwqQNPc3l7LWNOvx4xXyywG79I32/GrXuYYOYwDtpJeP 13 | krmy7TQRIRIAozeuGY1s6nYdJ2FU056laL8aaFKD2QKBgQDlucHjcNc9he4sKEMf 14 | z01WFOPBgl5+16DDj3dwxDMp4HFJLieGo3ZekvPOuqJ/0A84H4xQvRyYLc+K0f+N 15 | D5wcatoyZyCb5qymt1tQSAZRsboGTaBD2jQ+mI87JXal3Qvgal0jVnBYCaSbJCy6 16 | vxma8aOsHTPq9M7RzlkYeorYuwKBgQDiDkAA1uVofGa1AGDWg7MA9jUZaoK6Z84u 17 | U1WFZqOy9aSaisepFKK0JyQj0heTDZ0JpfC9w0vQ5+1IAgFXvQLm+BhZhVlfT2MU 18 | Uv/tWll4HW5C4DRPWyZagMAmE5j5X3NdnX2laXAikKR3af5ZPqCFYpaliBoXyamm 19 | ClE+TZhJCwKBgQCA1UZpWVU8yami1gmfA1Fp31lDout/00nzorfnZAEVkSu3UM0V 20 | 8wJlU6Cr5XtQlsySOw8kEIrCxZ5JSjA5WfHA9iPcdH2TMTDOZrItOddhZXzgIBSr 21 | OOpn2IMrNn1t06PffYcyVD25Ad9wqj7zlEy12qJh2hbNw/FhNIo+8iqAFQKBgA9J 22 | h2qHHdyDDS8QZ3waS/C0tcKSQWT5wCfB2va6ijeABTGuUPJOQvKL8xW5D38SXJxa 23 | bH1ox6fJB3LnL9APKDMWdA8ZxYF8jObC9ivHAGXvF5XOM7tqHp3gNx5cFOxIWDTs 24 | gaK+DqdHwNeSg3Dlm1Vp5WYsXhddu+tOp0/fT30hAoGBAKUvLqOl1kdVL8L6a6zB 25 | dhRIQmo5IpzeJHhQRoR/vRjwWof9VKgDAOKPMUmDYqcIsDpoCdTxxmkrF3V2NCl2 26 | 74rBVnNOn3T1/kuyr6H18BPdaA5kRd5ufOdlGe+hEkGmsruuOf3yfgaD7/j6WkyV 27 | BvWGYnPUpINUhIoLnb23wndr 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /test/support/mojito_test_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Mojito.TestServer do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | children = [ 6 | {Plug.Cowboy, 7 | scheme: :http, 8 | plug: Mojito.TestServer.PlugRouter, 9 | port: Application.get_env(:mojito, :test_server_http_port)}, 10 | {Plug.Cowboy, 11 | scheme: :https, 12 | plug: Mojito.TestServer.PlugRouter, 13 | port: Application.get_env(:mojito, :test_server_https_port), 14 | keyfile: File.cwd!() <> "/test/support/key.pem", 15 | certfile: File.cwd!() <> "/test/support/cert.pem"} 16 | ] 17 | 18 | Supervisor.start_link(children, strategy: :one_for_one) 19 | end 20 | end 21 | 22 | defmodule Mojito.TestServer.PlugRouter do 23 | use Plug.Router 24 | 25 | plug(Plug.Head) 26 | 27 | plug(:match) 28 | 29 | plug( 30 | Plug.Parsers, 31 | parsers: [:json], 32 | pass: ["application/json"], 33 | json_decoder: Jason 34 | ) 35 | 36 | plug(:dispatch) 37 | 38 | get "/" do 39 | name = conn.params["name"] || "world" 40 | send_resp(conn, 200, "Hello #{name}!") 41 | end 42 | 43 | get "/close" do 44 | {adapter, req} = conn.adapter 45 | 46 | {:ok, req} = 47 | :cowboy_req.reply( 48 | 200, 49 | %{"connection" => "close"}, 50 | "close", 51 | req 52 | ) 53 | 54 | %{conn | adapter: {adapter, req}} 55 | end 56 | 57 | post "/post" do 58 | name = conn.body_params["name"] || "Bob" 59 | send_resp(conn, 200, Jason.encode!(%{name: name})) 60 | end 61 | 62 | patch "/patch" do 63 | name = conn.body_params["name"] || "Bob" 64 | send_resp(conn, 200, Jason.encode!(%{name: name})) 65 | end 66 | 67 | put "/put" do 68 | name = conn.body_params["name"] || "Bob" 69 | send_resp(conn, 200, Jason.encode!(%{name: name})) 70 | end 71 | 72 | delete "/delete" do 73 | send_resp(conn, 200, "") 74 | end 75 | 76 | options _ do 77 | conn 78 | |> merge_resp_headers([ 79 | {"Allow", "OPTIONS, GET, HEAD, POST, PATCH, PUT, DELETE"} 80 | ]) 81 | |> send_resp(200, "") 82 | end 83 | 84 | get "/auth" do 85 | ["Basic " <> auth64] = Plug.Conn.get_req_header(conn, "authorization") 86 | creds = auth64 |> Base.decode64!() |> String.split(":", parts: 2) 87 | user = creds |> Enum.at(0) 88 | pass = creds |> Enum.at(1) 89 | send_resp(conn, 200, Jason.encode!(%{user: user, pass: pass})) 90 | end 91 | 92 | get "/wait" do 93 | delay = (conn.params["d"] || "100") |> String.to_integer() 94 | :timer.sleep(delay) 95 | send_resp(conn, 200, "ok") 96 | end 97 | 98 | get "/wait1" do 99 | :timer.sleep(1000) 100 | send_resp(conn, 200, "ok") 101 | end 102 | 103 | get "/wait10" do 104 | :timer.sleep(10000) 105 | send_resp(conn, 200, "ok") 106 | end 107 | 108 | get "/infinite" do 109 | Stream.unfold(send_chunked(conn, 200), fn 110 | conn -> 111 | {:ok, conn} = chunk(conn, "bytes") 112 | {nil, conn} 113 | end) 114 | |> Stream.run() 115 | end 116 | 117 | @gzip_body "H4sICOnTcF4AA3Jlc3BvbnNlAKtWys9WsiopKk2t5QIAiEF/wgwAAAA=" 118 | |> Base.decode64!() 119 | 120 | get "/gzip" do 121 | conn 122 | |> put_resp_header("content-encoding", "gzip") 123 | |> put_resp_header("content-type", "application/json") 124 | |> send_resp(200, @gzip_body) 125 | end 126 | 127 | @deflate_body "eJyrVsrPVrIqKSpNreUCABr+BBs=" |> Base.decode64!() 128 | 129 | get "/deflate" do 130 | conn 131 | |> put_resp_header("content-encoding", "deflate") 132 | |> put_resp_header("content-type", "application/json") 133 | |> send_resp(200, @deflate_body) 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Logger.remove_backend(:console) 2 | 3 | Mojito.TestServer.start([], []) 4 | 5 | if System.get_env("SLOW_TESTS"), do: :timer.sleep(1000) 6 | 7 | ExUnit.start() 8 | --------------------------------------------------------------------------------