diff --git a/DESCRIPTION b/DESCRIPTION
index dff94d1..35282b8 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -1,6 +1,6 @@
Package: httr2
Title: Perform HTTP Requests and Process the Responses
-Version: 1.0.7
+Version: 1.1.0
Authors@R: c(
person("Hadley", "Wickham", , "hadley@posit.co", role = c("aut", "cre")),
person("Posit Software, PBC", role = c("cph", "fnd")),
@@ -14,7 +14,7 @@ License: MIT + file LICENSE
URL: https://httr2.r-lib.org, https://github.com/r-lib/httr2
BugReports: https://github.com/r-lib/httr2/issues
Depends: R (>= 4.0)
-Imports: cli (>= 3.0.0), curl (>= 6.0.1), glue, lifecycle, magrittr,
+Imports: cli (>= 3.0.0), curl (>= 6.1.0), glue, lifecycle, magrittr,
openssl, R6, rappdirs, rlang (>= 1.1.0), vctrs (>= 0.6.3),
withr
Suggests: askpass, bench, clipr, covr, docopt, httpuv, jose, jsonlite,
@@ -24,14 +24,14 @@ VignetteBuilder: knitr
Config/Needs/website: tidyverse/tidytemplate
Config/testthat/edition: 3
Config/testthat/parallel: true
-Config/testthat/start-first: resp-stream, req-perform
+Config/testthat/start-first: multi-req, resp-stream, req-perform
Encoding: UTF-8
RoxygenNote: 7.3.2
NeedsCompilation: no
-Packaged: 2024-11-26 13:45:28 UTC; hadleywickham
+Packaged: 2025-01-18 14:13:16 UTC; hadleywickham
Author: Hadley Wickham [aut, cre],
Posit Software, PBC [cph, fnd],
Maximilian Girlich [ctb]
Maintainer: Hadley Wickham
Repository: CRAN
-Date/Publication: 2024-11-26 23:00:07 UTC
+Date/Publication: 2025-01-18 16:20:02 UTC
diff --git a/MD5 b/MD5
index 6e1b9c1..34b9f08 100644
--- a/MD5
+++ b/MD5
@@ -1,79 +1,81 @@
-0d75514cf746a231e092af3764657e9c *DESCRIPTION
+e6e86345963a58ee76d77ff813f077e3 *DESCRIPTION
67c32986371e772e798cc5d9f2dcc504 *LICENSE
-36f2f39c39236643e54f18829b8d71d6 *NAMESPACE
-376a33c2fa28a3539a9ad7cb38918ea3 *NEWS.md
+9ac49d8446e70d982280916433c7a340 *NAMESPACE
+253445ff6cdb70f079b34f4d1132d503 *NEWS.md
1658ee72d6e44a999ae79903bcd25229 *R/content-type.R
-000654278bff779897500fd3ecf69e17 *R/curl.R
-3dd7ea652be40280bb2f0727674c88b3 *R/headers.R
-9f36afc7c707845ac1ce98694b3b5b70 *R/httr2-package.R
+6514ffb9abc6617d02d7c94e53578d3b *R/curl.R
+d93afa7275aedc64ef3e71d57f655adc *R/headers.R
+10a8901c555fcc9fc38df0eb0a097fee *R/httr2-package.R
c80a9eb1427c585807cecf618b6f3870 *R/import-standalone-obj-type.R
17bb123964057b839a42eda1c3da214b *R/import-standalone-purrr.R
c40f882046a958444c6058a9e2cb9a3b *R/import-standalone-types-check.R
+507abe6ac4547126f8fa5d727bed378e *R/is-online.R
9b30eaec39a5108b8d433a20f9634c02 *R/iterate-helpers.R
-6a481f63dd4378d148d3fead8e0f170e *R/iterate-responses.R
+e27a45dc3c3644ad419b94e9c0ea12de *R/iterate-responses.R
2d943bdad354e34cbd4a45100b0d1e6b *R/iterate.R
b9d534f99dcda91551c3fcd746e2a445 *R/jwt.R
89a47587293ef9a5c2a290cdda214944 *R/multi-req.R
b1ae849ccaeae58d217eed06275a9b16 *R/oauth-client.R
-d9db7d2520133a6ac3aeac7f9119ae70 *R/oauth-flow-auth-code.R
+7a7e997e32344970cfb1fe7092fbe049 *R/oauth-flow-auth-code.R
a83498a469807c9f97bb6ccc4ea35a1a *R/oauth-flow-client-credentials.R
8fb1c52ebc692107974ec313bde9dcdb *R/oauth-flow-device.R
5c091bad88025c39d795d0c50a69997a *R/oauth-flow-jwt.R
c7d09f1525f2f600e9b55631e34e8cbe *R/oauth-flow-password.R
4861c984d0dfe47cb1a0db1ce21b4b71 *R/oauth-flow-refresh.R
947401cd8e1d67a790685757392fb50c *R/oauth-flow-token-exchange.R
-69b08195db7d117f9aa252d243a4446a *R/oauth-flow.R
+304974c85e1a0dec4738a95e9548fa6b *R/oauth-flow.R
e61af568972d4a7c08105046de860163 *R/oauth-token.R
81a6bc1821551a54e013134e75e022a0 *R/oauth.R
750539f93e92d55bcccfcf9960f78b8d *R/parse.R
-9b92feda3bc2daee5fd60f5d1418fe03 *R/progress-bars.R
+3019a5a632f44a7a0035cb59d5b206ca *R/progress-bars.R
ce08586490a871eff3cc5154fb92f8f1 *R/progress.R
-9d74c74340e929a4ef1d926f147f639b *R/req-auth-aws.R
+95bd5190615dff5795fecc3f7f56772e *R/req-auth-aws.R
d783da3750bbb4858a7a1593a0836f3c *R/req-auth-sign.R
-0ad8a12bf7f3993e45e4e4df582f5bea *R/req-auth.R
-8c7a2b332b3da42ca98c110ee4845028 *R/req-body.R
+ef23d2e4aff0606bddf477cf00bf20d1 *R/req-auth.R
+c70563cd2f402a195ae514e4d9122e55 *R/req-body.R
ab0f0916cc7dd698b9c7719a6dd7445e *R/req-cache.R
fc33319973b4af684d277c84f6b05613 *R/req-cookies.R
57cdca5cee4acb32444edc8f31dcb308 *R/req-error.R
-f541ab8aed99da114e2587ea95740268 *R/req-headers.R
+ec15960db4e67c23e6ca3fb48488964c *R/req-headers.R
c64e21ca81ec2590c380e74b89a9119f *R/req-method.R
-b2d2fbde7e21f1998a435a1030a9e6d8 *R/req-mock.R
-432edc5de1c29c3713cac719d59283fa *R/req-options.R
-01c39cfad634d71fbdec1d8322288cdd *R/req-perform-connection.R
-cf459caaf28612288d4049f8fd184431 *R/req-perform-stream.R
-603b107c17ac8cd8965db9002e5a75ae *R/req-perform.R
+c892d24ea5aea680b8d203a2a5718e3b *R/req-mock.R
+f5a0f0cff888758e195d984cc6d441e8 *R/req-options.R
+6f9368749624d4539e94231783bab509 *R/req-perform-connection.R
+c3169dc84805a1e40510761aa2718c6f *R/req-perform-stream.R
+d69b03c55a0d390c5314c4ee2419240c *R/req-perform.R
06612e2c3c9ca2995093c9174a3761ed *R/req-policy.R
-5a4147ff54d90b5d2ae2527ea1abaab8 *R/req-promise.R
-750078e7065abbe239d52a45b56d5b6e *R/req-retries.R
+3cab57060a89290541e3a3677cd8cb0a *R/req-promise.R
+5f8e4063ee130b4a9c1d5589d97e671f *R/req-retries.R
55ed14b760ba2f18d64d4c69ba064611 *R/req-template.R
9ce715edfb171a80d520c538f25e29ed *R/req-throttle.R
-09e8a5d0e0b1ea1bf62d8cc7ec66b8ed *R/req-url.R
+39df0c5d2c8b0fa179e421a49d7d95fc *R/req-url.R
13c13cea034751a029afaa80f629fa6c *R/req.R
a3998f317ba316c845455ce58a73951c *R/resp-body.R
-f7ce50805d872ea60ef6ba41f6c24aa6 *R/resp-headers.R
+d36d5bd0e31867e8f580fee242c06fb5 *R/resp-headers.R
+e3273082cbfdb95d21e7d3090a286d54 *R/resp-request.R
458cc0a7eb6a6f17289bb64f5ba3be5e *R/resp-status.R
-b13c7d2cd4f8481a9579e6c7641a8587 *R/resp-stream-aws.R
-ac236c510797c9e8123efe6a727cbeb9 *R/resp-stream.R
+929ad53e91d9fe3f59b399a78d223952 *R/resp-stream-aws.R
+c58e9126564b463084bb440e4b4312c8 *R/resp-stream.R
4de6118af4838858409a4f91bf6294b6 *R/resp-url.R
ff36eca1c2f4c905713060138ff33c2c *R/resp.R
8f02cbe77f0b346ddaf3a7e5158e387c *R/roxygen2.R
c50272978398261ca3af6973a716f457 *R/secret.R
-b120c3e290c54c23ca898a16afbbaa75 *R/sequential.R
+4dd469f37a74b4892d2f42b7e05f751c *R/sequential.R
18fbe8fc2497eef4d6f13c7e45f5508f *R/sysdata.rda
-afd0b7ec7c23651c632a0db4cdf17a98 *R/test.R
-9956c44afcedd46015e3f850fffda96a *R/url.R
-3e468224dbe2421903303c28f1912a4f *R/utils-multi.R
+1c33e6a0ddd76ebe77a2cd6fffb32f86 *R/test.R
+9e85d6175d0dec3b64f93d5d538b74a9 *R/url.R
+46898a02a6ba2dfeb06d0dd363279b06 *R/utils-multi.R
b56ff2b193ecba52f9ec7f3b10cca008 *R/utils-pipe.R
-4dc1cfb0f44bbaad06909586afb0259b *R/utils.R
+53fed380459feb8c073b1b197069f987 *R/utils.R
059a3c630cbf1d8cba089f78486fa4d9 *R/zzz.R
-21eb0f1ba57098c6a253787dc3b72f79 *README.md
+f9f24424e238b15c3a186d47a79f444b *README.md
ed7097e327faf147198f31bb3553c326 *build/vignette.rds
de3fc7fc6d5f0ec1ddf799c84d04a63e *cleanup
b547518249e0da4528616c8ed2382036 *configure
757ef8281ef2a1ebcbcc8235aeee2d0e *configure.win
62d1743a605c7aa827b5742a25950087 *inst/doc/httr2.R
5cc7d3c34b6a4fcfde1bde0e741c7823 *inst/doc/httr2.Rmd
-eadde17e78df0b46485869084b0065f0 *inst/doc/httr2.html
+9044e751c5441047bf9ff6efcf205d0e *inst/doc/httr2.html
8ac6a7b7aac968e8a0e93c58049261ef *man/curl_translate.Rd
fc06ec6c9f17492418496a7875111a55 *man/example_url.Rd
a1cbaf3f328e8d74e747faacf640c7fc *man/figures/lifecycle-archived.svg
@@ -87,10 +89,11 @@ ed42e3fbd7cc30bc6ca8fa9b658e24a8 *man/figures/lifecycle-stable.svg
bf2f1ad432ecccee3400afe533404113 *man/figures/lifecycle-superseded.svg
5533c65601c00e2027a5b4f2f11d460c *man/figures/logo.png
ebab256683f284e8b780f139c2c0f0cc *man/httr2-package.Rd
+49d626df322d7cf662377e1c01b06f2d *man/is_online.Rd
a6b2fc640f68348bb1df1e055a55710a *man/iterate_with_offset.Rd
36e14f11e9656c1a80e51c6f137ea052 *man/jwt_claim.Rd
bbdae5dea29b21d47d7c7069c0d46ab1 *man/last_response.Rd
-3a9bb92dd4c4edee672a4ec7d2ec71c6 *man/multi_req_perform.Rd
+6a3dc58851540795e0dec9b8d2d975b6 *man/multi_req_perform.Rd
04804ae5afb484f583664a8ef5bf51e4 *man/oauth_cache_clear.Rd
9b54af46994b24405f4751fd1086c06f *man/oauth_cache_path.Rd
28127c166aacb0222ee462c49822f29e *man/oauth_client.Rd
@@ -101,40 +104,40 @@ e8432ee09c99bc777397e20e6181d444 *man/oauth_token.Rd
2af58cfe96d548ef9086a791d1570a91 *man/oauth_token_cached.Rd
622607c5f5df647186110c9b1ce435b5 *man/obfuscate.Rd
8f4aad003a999fae004ba9361f9a99d6 *man/pipe.Rd
-4cfc1fa751b1d593aad4a6911539bfc2 *man/progress_bars.Rd
+e68629ab3d3b207dcf7346a6385b0393 *man/progress_bars.Rd
a785e57eddb1cd148bc0b0070bace521 *man/req_auth_aws_v4.Rd
-50ed8972f31a273d13ddfe39e634a7b6 *man/req_auth_basic.Rd
+b9258b3b58269decb6e84b3d0e9d544b *man/req_auth_basic.Rd
e62ad6ff1724af6693421e1722d323ee *man/req_auth_bearer_token.Rd
-bab65521b1355bba1ffe6f4358387dc5 *man/req_body.Rd
+3921f52aec7ba33f0f5c91151ecdc70a *man/req_body.Rd
58ddbc800a0f9676c146ff3a9cfb964c *man/req_cache.Rd
9ff896ff8069ceb86a9dd115d454aedc *man/req_cookie_preserve.Rd
-c3511461abf203e6051d0cce349fe475 *man/req_dry_run.Rd
+9cae1589f3996495f6acfdbc38a43481 *man/req_dry_run.Rd
20f4b9126f19b176e452de860a8f427c *man/req_error.Rd
-014d664aff294cdc7906257a1c5c92b8 *man/req_headers.Rd
+38a9080eba606368cde72933e029bcd7 *man/req_headers.Rd
016775046384c8579f22ad7b8cd54f6a *man/req_method.Rd
bb00e2e79745986b4cef04e6b8e56e5c *man/req_oauth.Rd
-ce7d4687bbafeb001c6fa3ee8fd02658 *man/req_oauth_auth_code.Rd
+ee2167ae16d025385e6eb9f09204dd39 *man/req_oauth_auth_code.Rd
c474b629b73cde26b2184c763aef00ae *man/req_oauth_bearer_jwt.Rd
c537e872c7e8daa6e9d2aeae0c1cf119 *man/req_oauth_client_credentials.Rd
f625f409275c2bd63080bb856cb37d0e *man/req_oauth_device.Rd
-2c7320823145170c2102cc72e4679209 *man/req_oauth_password.Rd
+9ee09168c57126cd537ab66e3328b2f4 *man/req_oauth_password.Rd
74b982010cb6f5505de3888f7dd79461 *man/req_oauth_refresh.Rd
551e3d4aead599d0f7794a73cc52368d *man/req_oauth_token_exchange.Rd
d0bfcfa3163dd92ba9dc87393077fda3 *man/req_options.Rd
538e1b1c5a396431a6ab35259e5c7af6 *man/req_perform.Rd
-b37b4a851f08a3ff36b47921fa8927b5 *man/req_perform_connection.Rd
-89f5a7fcb0463641f5dfce8ddb817df0 *man/req_perform_iterative.Rd
-7059e093f4f97de7e5999914843c2185 *man/req_perform_parallel.Rd
-39c42ba22649fb8e8fedd03ec055f2d3 *man/req_perform_promise.Rd
-d4785d6379db8d180e7e3b5b1830f588 *man/req_perform_sequential.Rd
-e83ae46099e9766ee7bd0cb2d982070d *man/req_perform_stream.Rd
+1c7114ac7f6c37f0256df98a910ad977 *man/req_perform_connection.Rd
+66dd7c639de6e281e409c49bd35e4083 *man/req_perform_iterative.Rd
+612a79beb81dddee30e3938010dbf58b *man/req_perform_parallel.Rd
+08791d0c4086425135222aeb2fa3f325 *man/req_perform_promise.Rd
+1ef11a8be998045ba273a592247205ba *man/req_perform_sequential.Rd
+f702b9e086842784d3d5d682f31b9840 *man/req_perform_stream.Rd
b41d5e8f316515288ff886df0e4881d7 *man/req_progress.Rd
01cf8fa1cbbe0443d56be0b3ca48dfb4 *man/req_proxy.Rd
-1f14f440aa429db5435d120486770fd2 *man/req_retry.Rd
+de20dee6cc2aec1e82d723789e9eb9d8 *man/req_retry.Rd
e99c0b063c7a0493acd4000fa7de6690 *man/req_template.Rd
3095f9a645fbcb15c5cf42a02ff13e53 *man/req_throttle.Rd
00f3f81313e9c0587e9449a7973fa117 *man/req_timeout.Rd
-6139a2835d121de7435de9655b73eeb8 *man/req_url.Rd
+6881d56f9d08c5819d3855fb87a0abdd *man/req_url.Rd
04f71709a8a1f84dc93cc18cad22363e *man/req_user_agent.Rd
bf9457f0a9d87135253910d6eb2571ef *man/req_verbose.Rd
da6d32cfb889bd0cf0857b565ccd2594 *man/request.Rd
@@ -145,21 +148,25 @@ eda7b85b891d9d84c032fd47b7700129 *man/resp_date.Rd
fedeb826affa2bceabef362c4e25d9e3 *man/resp_headers.Rd
1c66073d260f82878bb3c2f342717def *man/resp_link_url.Rd
db26222bc778e6cedf51a806c65ab392 *man/resp_raw.Rd
+affdd5eba3a4b5d5aa1727b6a0998faa *man/resp_request.Rd
2ce94385e1e1652829a0f366f9631189 *man/resp_retry_after.Rd
2b0209c459a4595d3624cbcd2e23618f *man/resp_status.Rd
-695b76dc3c3aa2fd47a3a7bfcee2bea0 *man/resp_stream_raw.Rd
+da1ea5336af84d41afedcf1227f0690f *man/resp_stream_raw.Rd
38f37482fa2a5fac8a3e743e342336fd *man/resp_url.Rd
712d1929f961ef8b93224aab5238fd30 *man/response.Rd
-7149070f6fbc8cbf62db975f8c0c468e *man/resps_successes.Rd
+2b10262b289e5556af9332f3bbd281a3 *man/resps_successes.Rd
06114c024f153dea1dfdb3b2d373eadf *man/secrets.Rd
ddef6b65c32aabc60f757ed193a88134 *man/signal_total_pages.Rd
3045c501a77cbffeda91c520dfccfe1f *man/throttle_status.Rd
-8cb5433fa4b42103e4039005fc54278c *man/url_parse.Rd
+56d8688106b975ba2be8a09c3682adc8 *man/url_build.Rd
+73b86e58e8d148fd4f962884890fd271 *man/url_modify.Rd
+6ba9eb8f20eadbe80eecc2e479ba2133 *man/url_parse.Rd
+6ede730cfcced60bbcfbdd87b60cd00f *man/url_query_parse.Rd
1db5ba24b3f55a0ab371859f591c26ba *man/with_mocked_responses.Rd
ee022e3c977f1c527ec0ec29a0c22eb6 *man/with_verbosity.Rd
c2b2cef37a5921be7c43b192f6ab8cae *tests/testthat.R
2abbb09e4412155bb8c71d2f5d01f842 *tests/testthat/_snaps/content-type.md
-1d96277aefa951c04ab5e854cfc54aa9 *tests/testthat/_snaps/curl.md
+2d22c26ed0f194f6a4a6a9997afafe0e *tests/testthat/_snaps/curl.md
bd0372ba1a8a3abbcd4986ce7b05fb59 *tests/testthat/_snaps/headers.md
80f7f8dd80eaec7d4dd7dcbf843b5522 *tests/testthat/_snaps/iterate-helpers.md
7fed77e81c3f22fa048ff542e406e40d *tests/testthat/_snaps/iterate.md
@@ -177,77 +184,79 @@ e69cb8912ae3ebe10d09c87ed1a286a9 *tests/testthat/_snaps/req-cache.md
3e31359c6520222447d6233f39f415b9 *tests/testthat/_snaps/req-cookies.md
f9c831a6fd93a36222a2f8b026dcb933 *tests/testthat/_snaps/req-error.md
bc00406611a4379e24b3a5e20c84ba1e *tests/testthat/_snaps/req-headers.md
-f9590c20318dd7d97c35ad3c6410040d *tests/testthat/_snaps/req-mock.md
+ec8a652884a6bd3a561f705a2cb1f57f *tests/testthat/_snaps/req-mock.md
5197a993461f4b825ee8a646be0d96cf *tests/testthat/_snaps/req-options.md
7320994deee7fe8397fe38984a289ab5 *tests/testthat/_snaps/req-perform-connection.md
304742dbf0ddb5549a47bddcb27b8b6b *tests/testthat/_snaps/req-perform-stream.md
94f451fd8a53349fc62f3d59e6f4f53c *tests/testthat/_snaps/req-perform.md
9b07d019e4eab37f0f6d97245e61535f *tests/testthat/_snaps/req-policy.md
-9f7760e1e3a993b03ed824c5d6f6ef92 *tests/testthat/_snaps/req-promise.md
-6ad8c0cece748dec6f7a511414408857 *tests/testthat/_snaps/req-retries.md
+709f80c1ac1823018b98f2f6416afe4c *tests/testthat/_snaps/req-promise.md
+2e1a950eef2130c8e6f55831779589be *tests/testthat/_snaps/req-retries.md
a6aa2b17597e91ded969ff83d8ae1aa7 *tests/testthat/_snaps/req-template.md
-76cbea12ce426ece85dbe3e03a9a12ef *tests/testthat/_snaps/req-url.md
-05349394ee4361f76aaf4c3519bccfb0 *tests/testthat/_snaps/req.md
+90f8f6da1dae21b0bc53afbb60bfe02f *tests/testthat/_snaps/req-url.md
+1cc1dbb126597203dd6a734385cd462e *tests/testthat/_snaps/req.md
60e6374c33a9a6251ee9662ec1eab2cb *tests/testthat/_snaps/resp-body.md
dd83ab51d56154baedd0fce1794d4b78 *tests/testthat/_snaps/resp-status.md
5d9dcaab39402b44c55500061ecadb10 *tests/testthat/_snaps/resp-stream-aws.md
-f417eaf75c2d9b7ed51d22f6423e3260 *tests/testthat/_snaps/resp-stream.md
+1beefe7ee4fdd0a088645ae3cad3a7bc *tests/testthat/_snaps/resp-stream.md
2f39783d8d60a0b9e37069ea62beb1b5 *tests/testthat/_snaps/resp.md
9a2997f35ff08553b17ad7ffd5445ee2 *tests/testthat/_snaps/secret.md
23197b884a9489a8ba9f505cf7502382 *tests/testthat/_snaps/sequential.md
-a09476510fc0c89f7e10f102561a24a0 *tests/testthat/_snaps/url.md
+14e117a66571f3c4ae385b0f8e1e368b *tests/testthat/_snaps/url.md
44c5d6d7c37249a3c71939b83eafa622 *tests/testthat/_snaps/utils-multi.md
5e637a98c47c01cc54e95cbdf515cc49 *tests/testthat/_snaps/utils.md
da0824f5ba70df4cb80983244d7a07d6 *tests/testthat/azure-cert.rds
6d46dd9362a66c2dc7a7b7ca04e24b66 *tests/testthat/azure-key.rds
+5f7373b1f0b2b11efdb832831fc475d3 *tests/testthat/helper-promise.R
6dda7928deb28c8a2939a2fcbc54e082 *tests/testthat/helper-webfakes.R
-a7242fd178fb212ec4159995f8aa724e *tests/testthat/helper.R
+5655db9da51ccc1133a92f50bb4adfe0 *tests/testthat/helper.R
63864fa9e8c9e485bdf6026c2aeb5f1a *tests/testthat/test-content-type.R
-8708c86d4aef6d15f08a6ca669bc549d *tests/testthat/test-curl.R
+c9586ba099126e1819ba10cb2ab4dc46 *tests/testthat/test-curl.R
bb05f379e71b020064f3b0db5a23922f *tests/testthat/test-headers.R
5be02d74f062de3487e54c8d4bfa25d7 *tests/testthat/test-iterate-helpers.R
6d3ec8a9144133090eb42a027c84d50b *tests/testthat/test-iterate-responses.R
6c02a6d5d34e2b3c1c33906f40a90bc5 *tests/testthat/test-iterate.R
-6d1d3570ef8748497c45a4ddd788ac5a *tests/testthat/test-multi-req.R
+c184e494aa79e82876f5c41993c55cb8 *tests/testthat/test-multi-req.R
da1c04bca53e199b761be5b06efde1a8 *tests/testthat/test-oauth-client.R
-b9f2d0c46a1f0c0c6cbc35c3538c3845 *tests/testthat/test-oauth-flow-auth-code.R
+c40907499399974a0d15a724c14f1486 *tests/testthat/test-oauth-flow-auth-code.R
29b4f80b4448eb91aa5e19966bd890b6 *tests/testthat/test-oauth-flow-jwt-google.rds
bca4aa0a15213a62d6a70f6f8597ae75 *tests/testthat/test-oauth-flow-jwt.R
1a61b2fbcbfbf3e08fc4c3c3f5a86e76 *tests/testthat/test-oauth-flow-refresh.R
-38e56020e2cd8f27a920c997515c41a6 *tests/testthat/test-oauth-flow.R
+fa281ad7b70b8987d371d1dc08faeb7c *tests/testthat/test-oauth-flow.R
3b6a5ecf363085dbaf2e9bb26f82bdb9 *tests/testthat/test-oauth-token.R
b4eb40ea8f3923f2a17c426b1accb683 *tests/testthat/test-oauth.R
7aafd3f87651aad2096256f730ca3f96 *tests/testthat/test-parse.R
-dfb8665cea4d355c8023f94f867aa58a *tests/testthat/test-req-auth-aws.R
+038d9fe8380aba66894070cf5d8be287 *tests/testthat/test-req-auth-aws.R
9731ce2fae5c9084c1797c99dd5245a3 *tests/testthat/test-req-auth.R
fec6f6bf4456ce1452a73f9050a0d409 *tests/testthat/test-req-body.R
7b77b91744d179da2e77f6961e3bfe79 *tests/testthat/test-req-cache.R
7ea4295cb8bfc2f4b127191dc374193b *tests/testthat/test-req-cookies.R
94aad378e2187ab554ee0b46cf005ac3 *tests/testthat/test-req-error.R
-4ca49c15d21c65abc3a132754e40139b *tests/testthat/test-req-headers.R
+298c3a5c3f52cab75afc2cd8ad6f85e8 *tests/testthat/test-req-headers.R
abe3ec676219640db627c485562b04c3 *tests/testthat/test-req-method.R
-1fc31f502e426bc8f24fd38551bb23b5 *tests/testthat/test-req-mock.R
+a0b363b87327500f0d924478f5043d85 *tests/testthat/test-req-mock.R
da98932f68102ee2b1cf07eca3ae614b *tests/testthat/test-req-options.R
0ae9ae926545591c692f33c9c46c96f0 *tests/testthat/test-req-perform-connection.R
622a71b5652729264b4b858f5b4ff8bf *tests/testthat/test-req-perform-stream.R
-307eb769ec31e16a78046956bc91f6fd *tests/testthat/test-req-perform.R
+f10989ae82cde825d9d80dbe60e2f485 *tests/testthat/test-req-perform.R
8e5b70954049fa379b900743857d68d0 *tests/testthat/test-req-policy.R
-8805578b840c04aa5a67a68b754a768b *tests/testthat/test-req-promise.R
-c3c7553bc1472f7138b7cc4d9b59b01f *tests/testthat/test-req-retries.R
+4b27b8fe612a15a8cd17ae2970f8591e *tests/testthat/test-req-promise.R
+8d441c2ab83574236eb2a380b33e46da *tests/testthat/test-req-retries.R
d8ebf8812ec156c0c00443c71d6e65e8 *tests/testthat/test-req-template.R
7f8382c81e7208a3adbaf6e0beb58946 *tests/testthat/test-req-throttle.R
-7dbc34e12b4fc2507f844c9fff1edf24 *tests/testthat/test-req-url.R
-6ad8ca4a0cdaa0d99da0ea8f43387d92 *tests/testthat/test-req.R
+986d06bf1e471a3c14eb7b30f3a1517f *tests/testthat/test-req-url.R
+75cd4425b038e917ec04921e5dd051e5 *tests/testthat/test-req.R
df705107d13e79dfb0401bd655df7f65 *tests/testthat/test-resp-body.R
-02cfdcfb6810eaddb3c4dd329eaf9cbc *tests/testthat/test-resp-headers.R
+e0cfb159e674c5cd3ecfdee5e2e94b4d *tests/testthat/test-resp-headers.R
+f0c4119da95f8859efd0a6580f240750 *tests/testthat/test-resp-request.R
306705998dbf3b108cbeb46449a01ec5 *tests/testthat/test-resp-status.R
8823fcea7255ac7e0f69b26413435c82 *tests/testthat/test-resp-stream-aws.R
-977f97b683e621a2477553c397be413d *tests/testthat/test-resp-stream.R
+7a4caeb485d9c995f9950bb12455bfe6 *tests/testthat/test-resp-stream.R
2a891e35620fe4003801eb3be2faceed *tests/testthat/test-resp-url.R
73c6a817d1876b11d5e1f972837f3c54 *tests/testthat/test-resp.R
6e1cf597de2078da4f7cad93112d6b9e *tests/testthat/test-secret.R
57013c2bbff9e24e5d76e5f86b163217 *tests/testthat/test-sequential.R
-22cd2141eafbe522695d75e4e7006375 *tests/testthat/test-url.R
+4872fc4a9ef74c2161da4f7d4abcb10a *tests/testthat/test-url.R
2f68cfd0571464f93d4e04e46824605a *tests/testthat/test-utils-multi.R
f060f901cda6a431960eebc30e636906 *tests/testthat/test-utils.R
04b0a09ebe1847f15c2b36527ba834ca *tools/examples.R
diff --git a/NAMESPACE b/NAMESPACE
index 0613c62..05a7599 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -18,6 +18,7 @@ export(curl_help)
export(curl_translate)
export(example_github_client)
export(example_url)
+export(is_online)
export(iterate_with_cursor)
export(iterate_with_link_url)
export(iterate_with_offset)
@@ -67,6 +68,7 @@ export(req_cookies_set)
export(req_dry_run)
export(req_error)
export(req_headers)
+export(req_headers_redacted)
export(req_method)
export(req_oauth)
export(req_oauth_auth_code)
@@ -95,6 +97,7 @@ export(req_url)
export(req_url_path)
export(req_url_path_append)
export(req_url_query)
+export(req_url_relative)
export(req_user_agent)
export(req_verbose)
export(request)
@@ -115,10 +118,12 @@ export(resp_headers)
export(resp_is_error)
export(resp_link_url)
export(resp_raw)
+export(resp_request)
export(resp_retry_after)
export(resp_status)
export(resp_status_desc)
export(resp_stream_aws)
+export(resp_stream_is_complete)
export(resp_stream_lines)
export(resp_stream_raw)
export(resp_stream_sse)
@@ -143,7 +148,12 @@ export(secret_write_rds)
export(signal_total_pages)
export(throttle_status)
export(url_build)
+export(url_modify)
+export(url_modify_query)
+export(url_modify_relative)
export(url_parse)
+export(url_query_build)
+export(url_query_parse)
export(with_mock)
export(with_mocked_responses)
export(with_verbosity)
diff --git a/NEWS.md b/NEWS.md
index 6396c60..b8aa670 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,3 +1,63 @@
+# httr2 1.1.0
+
+## Lifecycle changes
+
+* `req_perform_stream()` is superseded in favor of `req_perform_connection()`,
+ which is no longer experimental (#625).
+
+* `with_mock()` and `local_mock()` are defunct and will be removed in the next
+ release.
+
+## New features
+
+* `is_online()` wraps `curl::has_internet()`, making it easy to tell if you're
+ currently online (#512).
+
+* `req_headers_redacted()` makes it easier to redact sensitive headers (#561).
+
+* `req_retry()` implements "circuit breaking", which immediatelys error after
+ multiple failures to the same server (e.g. because the server is down)
+ (#370).
+
+* `req_url_relative()` navigates to a relative URL (#449).
+
+* `resp_request()` returns the request associated with a response; this can
+ be useful when debugging (#604).
+
+* `resp_stream_is_complete()` checks if data remains in the stream (#559).
+
+* `url_modify()`, `url_modify_query()`, and `url_modify_relative()` modify
+ URLs (#464); `url_query_parse()` and `url_query_build()` parse and build
+ query strings (#425).
+
+## Bug fixes and minor improvements
+
+* OAuth response parsing errors now have a dedicated `httr2_oauth_parse` error
+ class that includes the original response object (@atheriel, #596).
+
+* `curl_translate()` converts cookie headers to `req_cookies_set()` (#431)
+ and JSON data to `req_body_json_modify()` calls (#258).
+
+* `print.request()` escapes `{}` in headers (#586).
+
+* `req_auth_aws_v4()` formats the AWS Authorization header correctly (#627).
+
+* `req_retry()` defaults to `max_tries = 2` when nethier `max_tries` nor
+ `max_seconds` is set. If you want to disable retries, set `max_tries = 1`.
+
+* `req_perform_connection()` gains a `verbosity` argument, which is useful for
+ understanding exactly how data is streamed back to you (#599).
+ `req_perform_promise()` also gains a `verbosity` argument.
+
+* `req_url_query()` can control how spaces are encoded with `.space` (#432).
+
+* `resp_link_url()` handles multiple `Link` headers (#587).
+
+* `resp_stream_sse()` will warn if it recieves a partial event.
+
+* `url_parse()` parses relative URLs with new `base_url` argument (#449) and
+ the uses faster and more correct `curl::curl_parse_url()` (#577).
+
# httr2 1.0.7
* `req_perform_promise()` upgraded to use event-driven async based on waiting efficiently on curl socket activity (#579).
diff --git a/R/curl.R b/R/curl.R
index 6016638..a978965 100644
--- a/R/curl.R
+++ b/R/curl.R
@@ -54,17 +54,32 @@ curl_translate <- function(cmd, simplify_headers = TRUE) {
steps <- add_curl_step(steps, "req_url_query", dots = query)
+ # Cookies
+ cookies <- data$headers$`Cookie`
+ data$headers$`Cookie` <- NULL
+ if (!is.null(cookies)) {
+ steps <- add_curl_step(steps, "req_cookies_set", dots = cookies_parse(cookies))
+ }
+
# Content type set with data
type <- data$headers$`Content-Type`
- data$headers$`Content-Type` <- NULL
+ if (!identical(data$data, "")) {
+ data$headers$`Content-Type` <- NULL
+ }
headers <- curl_simplify_headers(data$headers, simplify_headers)
steps <- add_curl_step(steps, "req_headers", dots = headers)
if (!identical(data$data, "")) {
type <- type %||% "application/x-www-form-urlencoded"
- body <- data$data
- steps <- add_curl_step(steps, "req_body_raw", main_args = c(body, type))
+ if (type == "application/json" && idempotent_json(data$data)) {
+ json <- jsonlite::parse_json(data$data)
+ args <- list(data = I(deparse1(json)))
+ steps <- add_curl_step(steps, "req_body_json", dots = args)
+ } else {
+ body <- data$data
+ steps <- add_curl_step(steps, "req_body_raw", main_args = c(body, type))
+ }
}
steps <- add_curl_step(steps, "req_auth_basic", main_args = unname(data$auth))
@@ -146,6 +161,11 @@ curl_normalize <- function(cmd, error_call = caller_env()) {
method <- NULL
}
+ if (has_name(args, "--json")) {
+ args <- c(args, list(`--data-raw` = args[["--json"]]))
+ headers[["Content-Type"]] <- "application/json"
+ }
+
# https://curl.se/docs/manpage.html#-d
# --data-ascii, --data
# * if first element is @, treat as path to read from, stripping CRLF
@@ -206,6 +226,7 @@ curl_opts <- "Usage: curl [] [-H ...] [-d ...] [options] [<
--data-ascii HTTP POST ASCII data
--data-binary HTTP POST binary data
--data-urlencode HTTP POST data url encoded
+ --json HTTP POST JSON
-G, --get Put the post data in the URL and use GET
-I, --head Show document info only
-H, --header Pass custom header(s) to server
@@ -262,11 +283,9 @@ quote_name <- function(x) {
add_curl_step <- function(steps,
f,
- ...,
main_args = NULL,
dots = NULL,
keep_if_empty = FALSE) {
- check_dots_empty0(...)
args <- c(main_args, dots)
if (is_empty(args) && !keep_if_empty) {
@@ -274,7 +293,7 @@ add_curl_step <- function(steps,
}
names <- quote_name(names2(args))
- string <- vapply(args, is.character, logical(1L))
+ string <- map_lgl(args, function(x) is.character(x) && !inherits(x, "AsIs"))
values <- unlist(args)
values <- ifelse(string, encode_string2(values), values)
@@ -316,3 +335,22 @@ encode_string2 <- function(x) {
names(out) <- names(x)
out
}
+
+cookies_parse <- function(x) {
+ pairs <- strsplit(x, "; ?")[[1]]
+ cookies <- parse_name_equals_value(pairs)
+
+ if (length(cookies) == 0) {
+ return(NULL)
+ }
+
+ out <- as.list(curl::curl_unescape(cookies))
+ names(out) <- curl::curl_unescape(names(cookies))
+ out
+}
+
+idempotent_json <- function(old) {
+ args <- formals(req_body_json)[c("auto_unbox", "null", "digits")]
+ new <- exec(jsonlite::toJSON, jsonlite::parse_json(old), !!!args)
+ jsonlite::minify(old) == jsonlite::minify(new)
+}
diff --git a/R/headers.R b/R/headers.R
index 6fdc5dd..c5c4057 100644
--- a/R/headers.R
+++ b/R/headers.R
@@ -1,15 +1,11 @@
as_headers <- function(x, error_call = caller_env()) {
if (is.character(x) || is.raw(x)) {
- headers <- curl::parse_headers(x)
- headers <- headers[grepl(":", headers, fixed = TRUE)]
+ parsed <- curl::parse_headers(x)
+ valid <- parsed[grepl(":", parsed, fixed = TRUE)]
+ halves <- parse_in_half(valid, ":")
- equals <- regexpr(":", headers, fixed = TRUE)
- pieces <- regmatches(headers, equals, invert = TRUE)
-
- names <- map_chr(pieces, "[[", 1)
- values <- as.list(trimws(map_chr(pieces, "[[", 2)))
-
- new_headers(set_names(values, names), error_call = error_call)
+ headers <- set_names(trimws(halves$right), halves$left)
+ new_headers(as.list(headers), error_call = error_call)
} else if (is.list(x)) {
new_headers(x, error_call = error_call)
} else {
diff --git a/R/httr2-package.R b/R/httr2-package.R
index a88b7dd..be60360 100644
--- a/R/httr2-package.R
+++ b/R/httr2-package.R
@@ -11,6 +11,7 @@ NULL
the <- new_environment()
the$throttle <- list()
+the$breaker <- new_environment()
the$cache_throttle <- list()
the$token_cache <- new_environment()
the$last_response <- NULL
diff --git a/R/is-online.R b/R/is-online.R
new file mode 100644
index 0000000..8c970b4
--- /dev/null
+++ b/R/is-online.R
@@ -0,0 +1,12 @@
+#' Is your computer currently online?
+#'
+#' This function uses some cheap heuristics to determine if your computer is
+#' currently online. It's a simple wrapper around [curl::has_internet()]
+#' exported from httr2 for convenience.
+#'
+#' @export
+#' @examples
+#' is_online()
+is_online <- function() {
+ curl::has_internet()
+}
diff --git a/R/iterate-responses.R b/R/iterate-responses.R
index 09a8224..e8a2723 100644
--- a/R/iterate-responses.R
+++ b/R/iterate-responses.R
@@ -16,6 +16,11 @@
#' @param resps A list of responses (possibly including errors).
#' @param resp_data A function that takes a response (`resp`) and
#' returns the data found inside that response as a vector or data frame.
+#'
+#' NB: If you're using [resp_body_raw()], you're likely to want to wrap its
+#' output in `list()` to avoid combining all the bodies into a single raw
+#' vector, e.g. `resps |> resps_data(\(resp) list(resp_body_raw(resp)))`.
+#'
#' @examples
#' reqs <- list(
#' request(example_url()) |> req_url_path("/ip"),
@@ -29,10 +34,14 @@
#' resps |> resps_successes()
#'
#' # collect all their data
-#' resps |> resps_successes() |> resps_data(\(resp) resp_body_json(resp))
+#' resps |>
+#' resps_successes() |>
+#' resps_data(\(resp) resp_body_json(resp))
#'
#' # find requests corresponding to failure responses
-#' resps |> resps_failures() |> resps_requests()
+#' resps |>
+#' resps_failures() |>
+#' resps_requests()
resps_successes <- function(resps) {
resps[resps_ok(resps)]
}
diff --git a/R/oauth-flow-auth-code.R b/R/oauth-flow-auth-code.R
index f089305..7abda74 100644
--- a/R/oauth-flow-auth-code.R
+++ b/R/oauth-flow-auth-code.R
@@ -7,12 +7,14 @@
#' This flow is the most commonly used OAuth flow where the user
#' opens a page in their browser, approves the access, and then returns to R.
#' When possible, it redirects the browser back to a temporary local webserver
-#' to capture the authorization code. When this is not possible (e.g. when
+#' to capture the authorization code. When this is not possible (e.g., when
#' running on a hosted platform like RStudio Server), provide a custom
#' `redirect_uri` and httr2 will prompt the user to enter the code manually.
#'
#' Learn more about the overall OAuth authentication flow in
-#' .
+#' , and more about the motivations
+#' behind this flow in
+#' .
#'
#' # Security considerations
#'
@@ -20,17 +22,18 @@
#' applications (which are equivalent to R packages). `r rfc(8252)` spells out
#' important considerations for native apps. Most importantly there's no way
#' for native apps to keep secrets from their users. This means that the
-#' server should either not require a `client_secret` (i.e. a public client
-#' not an confidential client) or ensure that possession of the `client_secret`
-#' doesn't bestow any meaningful rights.
+#' server should either not require a `client_secret` (i.e. it should be a
+#' public client and not a confidential client) or ensure that possession of
+#' the `client_secret` doesn't grant any significant privileges.
#'
-#' Only modern APIs from the bigger players (Azure, Google, etc) explicitly
-#' native apps. However, in most cases, even for older APIs, possessing the
-#' `client_secret` gives you no ability to do anything harmful, so our
-#' general principle is that it's fine to include it in an R package, as long
-#' as it's mildly obfuscated to protect it from credential scraping. There's
-#' no incentive to steal your client credentials if it takes less time to
-#' create a new client than find your client secret.
+#' Only modern APIs from major providers (like Azure and Google) explicitly
+#' support native apps. However, in most cases, even for older APIs, possessing
+#' the `client_secret` provides limited ability to perform harmful actions.
+#' Therefore, our general principle is that it's acceptable to include it in an
+#' R package, as long as it's mildly obfuscated to protect against credential
+#' scraping attacks (which aim to acquire large numbers of client secrets by
+#' scanning public sites like GitHub). The goal is to ensure that obtaining your
+#' client credentials is more work than just creating a new client.
#'
#' @export
#' @family OAuth flows
@@ -362,7 +365,7 @@ oauth_flow_auth_code_listen <- function(redirect_uri = "http://localhost:1410")
# https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.1
# Spaces are first replaced by +
parse_form_urlencoded <- function(query) {
- query <- query_parse(query)
+ query <- url_query_parse(query)
query[] <- gsub("+", " ", query, fixed = TRUE)
query
}
diff --git a/R/oauth-flow.R b/R/oauth-flow.R
index 068ae87..4fcdcd4 100644
--- a/R/oauth-flow.R
+++ b/R/oauth-flow.R
@@ -12,6 +12,8 @@ oauth_flow_parse <- function(resp, source, error_call = caller_env()) {
cli::cli_abort(
"Failed to parse response from {.arg {source}} OAuth url.",
parent = err,
+ resp = resp,
+ class = "httr2_oauth_parse",
call = error_call
)
}
@@ -40,6 +42,8 @@ oauth_flow_parse <- function(resp, source, error_call = caller_env()) {
"Failed to parse response from {.arg {source}} OAuth url.",
"*" = "Did not contain {.code access_token}, {.code device_code}, or {.code error} field."
),
+ resp = resp,
+ class = "httr2_oauth_parse",
call = error_call
)
}
diff --git a/R/progress-bars.R b/R/progress-bars.R
index 31c2a73..59d46df 100644
--- a/R/progress-bars.R
+++ b/R/progress-bars.R
@@ -30,9 +30,6 @@
#' By default the same as `format`.
#' * `name`: progress bar name. This is by default the empty string and it
#' is displayed at the beginning of the progress bar.
-#' * `show_after`: numeric scalar. Only show the progress bar after this
-#' number of seconds. It overrides the `cli.progress_show_after`
-#' global option.
#' * `type`: progress bar type. Currently supported types are:
#' * `iterator`: the default, a for loop or a mapping function,
#' * `tasks`: a (typically small) number of tasks,
diff --git a/R/req-auth-aws.R b/R/req-auth-aws.R
index 7d32e69..c6713bb 100644
--- a/R/req-auth-aws.R
+++ b/R/req-auth-aws.R
@@ -129,7 +129,7 @@ aws_v4_signature <- function(method,
CanonicalQueryString <- ""
} else {
sorted_query <- url$query[order(names(url$query))]
- CanonicalQueryString <- query_build(CanonicalQueryString)
+ CanonicalQueryString <- url_query_build(CanonicalQueryString)
}
headers$host <- url$hostname
@@ -186,7 +186,7 @@ aws_v4_signature <- function(method,
credential <- file.path(aws_access_key_id, CredentialScope)
Authorization <- paste0(
- Algorithm, ",",
+ Algorithm, " ",
"Credential=", credential, ",",
"SignedHeaders=", SignedHeaders, ",",
"Signature=", signature
diff --git a/R/req-auth.R b/R/req-auth.R
index 9811f0a..6459349 100644
--- a/R/req-auth.R
+++ b/R/req-auth.R
@@ -5,8 +5,8 @@
#'
#' @inheritParams req_perform
#' @param username User name.
-#' @param password Password. You avoid entering the password directly when
-#' calling this function as it will be captured by `.Rhistory`. Instead,
+#' @param password Password. You should avoid entering the password directly
+#' when calling this function as it will be captured by `.Rhistory`. Instead,
#' leave it unset and the default behaviour will prompt you for it
#' interactively.
#' @returns A modified HTTP [request].
diff --git a/R/req-body.R b/R/req-body.R
index f978673..6f63e67 100644
--- a/R/req-body.R
+++ b/R/req-body.R
@@ -137,7 +137,8 @@ req_body_json_modify <- function(req, ...) {
#' data in the body.
#'
#' * For `req_body_form()`, the values must be strings (or things easily
-#' coerced to strings);
+#' coerced to strings). Vectors are convertd to strings using the
+#' value of `.multi`.
#' * For `req_body_multipart()` the values must be strings or objects
#' produced by [curl::form_file()]/[curl::form_data()].
#' * For `req_body_json_modify()`, any simple data made from atomic vectors
@@ -226,7 +227,7 @@ req_body_get <- function(req) {
raw = req$body$data,
form = {
data <- unobfuscate(req$body$data)
- query_build(data)
+ url_query_build(data)
},
json = exec(jsonlite::toJSON, req$body$data, !!!req$body$params),
cli::cli_abort("Unsupported request body type {.str {req$body$type}}.")
diff --git a/R/req-headers.R b/R/req-headers.R
index e943587..3d099cc 100644
--- a/R/req-headers.R
+++ b/R/req-headers.R
@@ -1,7 +1,12 @@
#' Modify request headers
#'
+#' @description
#' `req_headers()` allows you to set the value of any header.
#'
+#' `req_headers_redacted()` is a variation that adds "redacted" headers, which
+#' httr2 avoids printing on the console. This is good practice for
+#' authentication headers to avoid accidentally leaking them in log files.
+#'
#' @param .req A [request].
#' @param ... <[`dynamic-dots`][rlang::dyn-dots]> Name-value pairs of headers
#' and their values.
@@ -46,13 +51,16 @@
#' # If you have headers in a list, use !!!
#' headers <- list(HeaderOne = "one", HeaderTwo = "two")
#' req |>
-#' req_headers(!!!headers, HeaderThree = "three") |>
-#' req_dry_run()
-#'
-#' # Use `.redact` to hide a header in the output
-#' req |>
-#' req_headers(Secret = "this-is-private", Public = "but-this-is-not", .redact = "Secret") |>
+#' req_headers(!!!headers, HeaderThree = "three") |>
#' req_dry_run()
+#'
+#' # Use `req_headers_redacted()`` to hide a header in the output
+#' req_secret <- req |>
+#' req_headers_redacted(Secret = "this-is-private") |>
+#' req_headers(Public = "but-this-is-not")
+#'
+#' req_secret
+#' req_secret |> req_dry_run()
req_headers <- function(.req, ..., .redact = NULL) {
check_request(.req)
@@ -68,3 +76,12 @@ req_headers <- function(.req, ..., .redact = NULL) {
.req
}
+
+#' @export
+#' @rdname req_headers
+req_headers_redacted <- function(.req, ...) {
+ check_request(.req)
+
+ dots <- list(...)
+ req_headers(.req, !!!dots, .redact = names(dots))
+}
diff --git a/R/req-mock.R b/R/req-mock.R
index 56d9be0..b13b286 100644
--- a/R/req-mock.R
+++ b/R/req-mock.R
@@ -40,7 +40,7 @@ with_mocked_responses <- function(mock, code) {
#' @rdname with_mocked_responses
#' @usage NULL
with_mock <- function(mock, code) {
- lifecycle::deprecate_warn("1.0.0", "with_mock()", "with_mocked_responses()")
+ lifecycle::deprecate_stop("1.1.0", "with_mock()", "with_mocked_responses()")
with_mocked_responses(mock, code)
}
@@ -55,7 +55,7 @@ local_mocked_responses <- function(mock, env = caller_env()) {
#' @rdname with_mocked_responses
#' @usage NULL
local_mock <- function(mock, env = caller_env()) {
- lifecycle::deprecate_warn("1.0.0", "local_mock()", "local_mocked_responses()")
+ lifecycle::deprecate_warn("1.1.0", "local_mock()", "local_mocked_responses()")
local_mocked_responses(mock, env)
}
diff --git a/R/req-options.R b/R/req-options.R
index 08939d2..4e8fd04 100644
--- a/R/req-options.R
+++ b/R/req-options.R
@@ -207,7 +207,7 @@ verbose_header <- function(prefix, x, redact = TRUE, to_redact = NULL) {
lines <- unlist(strsplit(x, "\r?\n", useBytes = TRUE))
for (line in lines) {
- if (grepl(":", line, fixed = TRUE)) {
+ if (grepl("^[-a-zA-z0-9]+:", line)) {
header <- headers_redact(as_headers(line), redact, to_redact = to_redact)
cli::cat_line(prefix, cli::style_bold(names(header)), ": ", header)
} else {
diff --git a/R/req-perform-connection.R b/R/req-perform-connection.R
index d8baf47..066aa68 100644
--- a/R/req-perform-connection.R
+++ b/R/req-perform-connection.R
@@ -1,9 +1,6 @@
-
#' Perform a request and return a streaming connection
#'
#' @description
-#' `r lifecycle::badge("experimental")`
-#'
#' Use `req_perform_connection()` to perform a request if you want to stream the
#' response body. A response returned by `req_perform_connection()` includes a
#' connection as the body. You can then use [resp_stream_raw()],
@@ -16,10 +13,21 @@
#' than providing callbacks that the data is pushed to. This is useful if you
#' want to do other work in between handling inputs from the stream.
#'
-#' @inheritParams req_perform_stream
+#' @inheritParams req_perform
#' @param blocking When retrieving data, should the connection block and wait
#' for the desired information or immediately return what it has (possibly
#' nothing)?
+#' @param verbosity How much information to print? This is a wrapper
+#' around [req_verbose()] that uses an integer to control verbosity:
+#'
+#' * `0`: no output
+#' * `1`: show headers
+#' * `2`: show headers and bodies as they're streamed
+#' * `3`: show headers, bodies, curl status messages, and stream buffer
+#' management
+#'
+#' Use [with_verbosity()] to control the verbosity of requests that
+#' you can't affect directly.
#' @export
#' @examples
#' req <- request(example_url()) |>
@@ -33,10 +41,19 @@
#'
#' # Always close the response when you're done
#' close(resp)
-req_perform_connection <- function(req, blocking = TRUE) {
+#'
+#' # You can loop until complete with resp_stream_is_complete()
+#' resp <- req_perform_connection(req)
+#' while (!resp_stream_is_complete(resp)) {
+#' print(length(resp_stream_raw(resp, kb = 12)))
+#' }
+#' close(resp)
+req_perform_connection <- function(req, blocking = TRUE, verbosity = NULL) {
check_request(req)
check_bool(blocking)
+ # verbosity checked in req_verbosity_connection
+ req <- req_verbosity_connection(req, verbosity %||% httr2_verbosity())
req <- auth_sign(req)
req_prep <- req_prepare(req)
handle <- req_handle(req_prep)
@@ -49,6 +66,7 @@ req_perform_connection <- function(req, blocking = TRUE) {
deadline <- Sys.time() + retry_max_seconds(req)
resp <- NULL
while (tries < max_tries && Sys.time() < deadline) {
+ retry_check_breaker(req, tries)
sys_sleep(delay, "for retry backoff")
if (!is.null(resp)) {
@@ -58,6 +76,7 @@ req_perform_connection <- function(req, blocking = TRUE) {
if (retry_is_transient(req, resp)) {
tries <- tries + 1
+
delay <- retry_after(req, resp, tries)
signal(class = "httr2_retry", tries = tries, delay = delay)
} else {
@@ -78,6 +97,30 @@ req_perform_connection <- function(req, blocking = TRUE) {
resp
}
+# Like req_verbosity() but we want to print the streaming body when it's
+# requested not when curl actually receives it
+req_verbosity_connection <- function(req, verbosity, error_call = caller_env()) {
+ if (!is_integerish(verbosity, n = 1) || verbosity < 0 || verbosity > 3) {
+ cli::cli_abort("{.arg verbosity} must 0, 1, 2, or 3.", call = error_call)
+ }
+
+ req <- switch(verbosity + 1,
+ req,
+ req_verbose(req),
+ req_verbose(req, body_req = TRUE),
+ req_verbose(req, body_req = TRUE, info = TRUE)
+ )
+ if (verbosity > 1) {
+ req <- req_policies(
+ req,
+ show_streaming_body = verbosity >= 2,
+ show_streaming_buffer = verbosity >= 3
+ )
+ }
+ req
+}
+
+
req_perform_connection1 <- function(req, handle, blocking = TRUE) {
stream <- curl::curl(req$url, handle = handle)
diff --git a/R/req-perform-stream.R b/R/req-perform-stream.R
index 0f9cd42..9c6aa6d 100644
--- a/R/req-perform-stream.R
+++ b/R/req-perform-stream.R
@@ -1,6 +1,12 @@
#' Perform a request and handle data as it streams back
#'
#' @description
+#' `r lifecycle::badge("superseded")`
+#'
+#' We now recommend [req_perform_connection()] since it has a considerably more
+#' flexible interface. Unless I hear compelling reasons otherwise, I'm likely
+#' to deprecate `req_perform_stream()` in a future release.
+#'
#' After preparing a request, call `req_perform_stream()` to perform the request
#' and handle the result with a streaming callback. This is useful for
#' streaming HTTP APIs where potentially the stream never ends.
diff --git a/R/req-perform.R b/R/req-perform.R
index 5eb8915..8da8896 100644
--- a/R/req-perform.R
+++ b/R/req-perform.R
@@ -107,6 +107,7 @@ req_perform <- function(
delay <- 0
while (tries < max_tries && Sys.time() < deadline) {
+ retry_check_breaker(req, tries, error_call = error_call)
sys_sleep(delay, "for retry backoff")
n <- n + 1
@@ -161,6 +162,7 @@ handle_resp <- function(req, resp, error_call = caller_env()) {
req_perform1 <- function(req, path = NULL, handle = NULL) {
the$last_request <- req
the$last_response <- NULL
+ signal(class = "httr2_perform")
if (!is.null(path)) {
res <- curl::curl_fetch_disk(req$url, path, handle)
@@ -229,6 +231,10 @@ last_request <- function() {
#' works by sending the real HTTP request to a local webserver, thanks to
#' the magic of [curl::curl_echo()].
#'
+#' ## Limitations
+#'
+#' * The `Host` header is not respected.
+#'
#' @inheritParams req_verbose
#' @param quiet If `TRUE` doesn't print anything.
#' @returns Invisibly, a list containing information about the request,
diff --git a/R/req-promise.R b/R/req-promise.R
index f157e13..0c0d216 100644
--- a/R/req-promise.R
+++ b/R/req-promise.R
@@ -69,9 +69,13 @@
#' }
req_perform_promise <- function(req,
path = NULL,
- pool = NULL) {
+ pool = NULL,
+ verbosity = NULL) {
check_installed(c("promises", "later"))
+
+ check_request(req)
check_string(path, allow_null = TRUE)
+ verbosity <- verbosity %||% httr2_verbosity()
if (missing(pool)) {
if (!identical(later::current_loop(), later::global_loop())) {
@@ -80,7 +84,14 @@ req_perform_promise <- function(req,
i = "Do you need {.code pool = curl::new_pool()}?"
))
}
+ } else {
+ if (!is.null(pool) && !inherits(pool, "curl_multi")) {
+ stop_input_type(pool, "a {curl} pool", allow_null = TRUE)
+ }
}
+ # verbosity checked by req_verbosity
+
+ req <- req_verbosity(req, verbosity)
promises::promise(
function(resolve, reject) {
diff --git a/R/req-retries.R b/R/req-retries.R
index 202391a..3049cd9 100644
--- a/R/req-retries.R
+++ b/R/req-retries.R
@@ -1,55 +1,62 @@
-#' Control when a request will retry, and how long it will wait between tries
+#' Automatically retry a request on failure
#'
#' @description
-#' `req_retry()` alters [req_perform()] so that it will automatically retry
-#' in the case of failure. To activate it, you must specify either the total
-#' number of requests to make with `max_tries` or the total amount of time
-#' to spend with `max_seconds`. Then `req_perform()` will retry if the error is
-#' "transient", i.e. it's an HTTP error that can be resolved by waiting. By
-#' default, 429 and 503 statuses are treated as transient, but if the API you
-#' are wrapping has other transient status codes (or conveys transient-ness
-#' with some other property of the response), you can override the default
-#' with `is_transient`.
+#' `req_retry()` allows [req_perform()] to automatically retry failing
+#' requests. It's particularly important for APIs with rate limiting, but can
+#' also be useful when dealing with flaky servers.
#'
-#' Additionally, if you set `retry_on_failure = TRUE`, the request will retry
-#' if either the HTTP request or HTTP response doesn't complete successfully
+#' By default, `req_perform()` will retry if the response is a 429
+#' ("too many requests", often used for rate limiting) or 503
+#' ("service unavailable"). If the API you are wrapping has other transient
+#' status codes (or conveys transience with some other property of the
+#' response), you can override the default with `is_transient`. And
+#' if you set `retry_on_failure = TRUE`, the request will retry
+#' if either the HTTP request or HTTP response doesn't complete successfully,
#' leading to an error from curl, the lower-level library that httr2 uses to
-#' perform HTTP request. This occurs, for example, if your wifi is down.
+#' perform HTTP requests. This occurs, for example, if your Wi-Fi is down.
+#'
+#' ## Delay
#'
#' It's a bad idea to immediately retry a request, so `req_perform()` will
#' wait a little before trying again:
#'
#' * If the response contains the `Retry-After` header, httr2 will wait the
#' amount of time it specifies. If the API you are wrapping conveys this
-#' information with a different header (or other property of the response)
-#' you can override the default behaviour with `retry_after`.
+#' information with a different header (or other property of the response),
+#' you can override the default behavior with `retry_after`.
#'
#' * Otherwise, httr2 will use "truncated exponential backoff with full
-#' jitter", i.e. it will wait a random amount of time between one second and
-#' `2 ^ tries` seconds, capped to at most 60 seconds. In other words, it
+#' jitter", i.e., it will wait a random amount of time between one second and
+#' `2 ^ tries` seconds, capped at a maximum of 60 seconds. In other words, it
#' waits `runif(1, 1, 2)` seconds after the first failure, `runif(1, 1, 4)`
#' after the second, `runif(1, 1, 8)` after the third, and so on. If you'd
#' prefer a different strategy, you can override the default with `backoff`.
#'
#' @inheritParams req_perform
-#' @param max_tries,max_seconds Cap the maximum number of attempts with
-#' `max_tries` or the total elapsed time from the first request with
-#' `max_seconds`. If neither option is supplied (the default), [req_perform()]
-#' will not retry.
+#' @param max_tries,max_seconds Cap the maximum number of attempts
+#' (`max_tries`), the total elapsed time from the first request
+#' (`max_seconds`), or both.
#'
-#' `max_tries` is the total number of attempts make, so this should always
-#' be greater than one.`
+#' `max_tries` is the total number of attempts made, so this should always
+#' be greater than one.
#' @param is_transient A predicate function that takes a single argument
#' (the response) and returns `TRUE` or `FALSE` specifying whether or not
#' the response represents a transient error.
#' @param retry_on_failure Treat low-level failures as if they are
-#' transient errors, and can be retried.
+#' transient errors that can be retried.
#' @param backoff A function that takes a single argument (the number of failed
#' attempts so far) and returns the number of seconds to wait.
#' @param after A function that takes a single argument (the response) and
-#' returns either a number of seconds to wait or `NULL`, which indicates
-#' that a precise wait time is not available that the `backoff` strategy
-#' should be used instead..
+#' returns either a number of seconds to wait or `NA`. `NA` indicates
+#' that a precise wait time is not available and that the `backoff` strategy
+#' should be used instead.
+#' @param failure_threshold,failure_timeout,failure_realm
+#' Set `failure_threshold` to activate "circuit breaking" where if a request
+#' continues to fail after `failure_threshold` times, cause the request to
+#' error until a timeout of `failure_timeout` seconds has elapsed. This
+#' timeout will persist across all requests with the same `failure_realm`
+#' (which defaults to the hostname of the request) and is intended to detect
+#' failing servers without needing to wait each time.
#' @returns A modified HTTP [request].
#' @export
#' @seealso [req_throttle()] if the API has a rate-limit but doesn't expose
@@ -61,7 +68,7 @@
#'
#' # use a constant 10s delay after every failure
#' request("http://example.com") |>
-#' req_retry(backoff = ~10)
+#' req_retry(backoff = \(resp) 10)
#'
#' # When rate-limited, GitHub's API returns a 403 with
#' # `X-RateLimit-Remaining: 0` and an Unix time stored in the
@@ -85,10 +92,22 @@ req_retry <- function(req,
retry_on_failure = FALSE,
is_transient = NULL,
backoff = NULL,
- after = NULL) {
+ after = NULL,
+ failure_threshold = Inf,
+ failure_timeout = 30,
+ failure_realm = NULL) {
+
check_request(req)
- check_number_whole(max_tries, min = 2, allow_null = TRUE)
+ check_number_whole(max_tries, min = 1, allow_null = TRUE)
check_number_whole(max_seconds, min = 0, allow_null = TRUE)
+ check_number_whole(failure_threshold, min = 1, allow_infinite = TRUE)
+ check_number_whole(failure_timeout, min = 1)
+
+ if (is.null(max_tries) && is.null(max_seconds)) {
+ max_tries <- 2
+ cli::cli_inform("Setting {.code max_tries = 2}.")
+ }
+
check_bool(retry_on_failure)
req_policies(req,
@@ -97,7 +116,10 @@ req_retry <- function(req,
retry_on_failure = retry_on_failure,
retry_is_transient = as_callback(is_transient, 1, "is_transient"),
retry_backoff = as_callback(backoff, 1, "backoff"),
- retry_after = as_callback(after, 1, "after")
+ retry_after = as_callback(after, 1, "after"),
+ retry_failure_threshold = failure_threshold,
+ retry_failure_timeout = failure_timeout,
+ retry_realm = failure_realm %||% url_parse(req$url)$hostname
)
}
@@ -110,6 +132,38 @@ retry_max_seconds <- function(req) {
req$policies$retry_max_wait %||% Inf
}
+retry_check_breaker <- function(req, i, error_call = caller_env()) {
+ realm <- req$policies$retry_realm
+ if (is.null(realm)) {
+ return(invisible())
+ }
+
+ now <- unix_time()
+ if (env_has(the$breaker, realm)) {
+ triggered <- the$breaker[[realm]]
+ } else if (i > req$policies$retry_failure_threshold) {
+ the$breaker[[realm]] <- triggered <- now
+ } else {
+ return(invisible())
+ }
+
+ remaining <- req$policies$retry_failure_timeout - (now - triggered)
+ if (remaining <= 0) {
+ env_unbind(the$breaker, realm)
+ } else {
+ cli::cli_abort(
+ c(
+ "Request failures have exceeded the threshold for realm {.str {realm}}.",
+ i = "The server behind {.str {realm}} is likely still overloaded or down.",
+ i = "Wait {remaining} seconds before retrying."
+
+ ),
+ call = error_call,
+ class = "httr2_breaker"
+ )
+ }
+}
+
retry_is_transient <- function(req, resp) {
if (is_error(resp)) {
return(req$policies$retry_on_failure %||% FALSE)
diff --git a/R/req-url.R b/R/req-url.R
index d168e42..d060ee8 100644
--- a/R/req-url.R
+++ b/R/req-url.R
@@ -1,13 +1,19 @@
#' Modify request URL
#'
#' @description
-#' * `req_url()` replaces the entire url
-#' * `req_url_query()` modifies the components of the query
-#' * `req_url_path()` modifies the path
-#' * `req_url_path_append()` adds to the path
+#' * `req_url()` replaces the entire URL.
+#' * `req_url_relative()` navigates to a relative URL.
+#' * `req_url_query()` modifies individual query components.
+#' * `req_url_path()` modifies just the path.
+#' * `req_url_path_append()` adds to the path.
#'
+#' @seealso
+#' * To modify a URL without creating a request, see [url_modify()] and
+#' friends.
+#' * To use a template like `GET /user/{user}`, see [req_template()].
#' @inheritParams req_perform
-#' @param url New URL; completely replaces existing.
+#' @param url A new URL; either an absolute URL for `req_url()` or a
+#' relative URL for `req_url_relative()`.
#' @param ... For `req_url_query()`: <[`dynamic-dots`][rlang::dyn-dots]>
#' Name-value pairs that define query parameters. Each value must be either
#' an atomic vector or `NULL` (which removes the corresponding parameters).
@@ -18,7 +24,14 @@
#' @returns A modified HTTP [request].
#' @export
#' @examples
+#' # Change complete url
#' req <- request("http://example.com")
+#' req |> req_url("http://google.com")
+#'
+#' # Use a relative url
+#' req <- request("http://example.com/a/b/c")
+#' req |> req_url_relative("..")
+#' req |> req_url_relative("/d/e/f")
#'
#' # Change url components
#' req |>
@@ -27,9 +40,11 @@
#' req_url_path_append("search.html") |>
#' req_url_query(q = "the cool ice")
#'
-#' # Change complete url
-#' req |>
-#' req_url("http://google.com")
+#' # Modify individual query parameters
+#' req <- request("http://example.com?a=1&b=2")
+#' req |> req_url_query(a = 10)
+#' req |> req_url_query(a = NULL)
+#' req |> req_url_query(c = 3)
#'
#' # Use .multi to control what happens with vector parameters:
#' req |> req_url_query(id = 100:105, .multi = "comma")
@@ -49,26 +64,21 @@ req_url <- function(req, url) {
#' @export
#' @rdname req_url
-#' @param .multi Controls what happens when an element of `...` is a vector
-#' containing multiple values:
-#'
-#' * `"error"`, the default, throws an error.
-#' * `"comma"`, separates values with a `,`, e.g. `?x=1,2`.
-#' * `"pipe"`, separates values with a `|`, e.g. `?x=1|2`.
-#' * `"explode"`, turns each element into its own parameter, e.g. `?x=1&x=2`.
-#'
-#' If none of these functions work, you can alternatively supply a function
-#' that takes a character vector and returns a string.
+req_url_relative <- function(req, url) {
+ check_request(req)
+ req_url(req, url_modify_relative(req$url, url))
+}
+
+#' @export
+#' @rdname req_url
+#' @inheritParams url_modify_query
req_url_query <- function(.req,
...,
- .multi = c("error", "comma", "pipe", "explode")) {
+ .multi = c("error", "comma", "pipe", "explode"),
+ .space = c("percent", "form")) {
check_request(.req)
-
- dots <- multi_dots(..., .multi = .multi)
-
- url <- url_parse(.req$url)
- url$query <- modify_list(url$query, !!!dots)
- req_url(.req, url_build(url))
+ url <- url_modify_query(.req$url, ..., .multi = .multi, .space = .space)
+ req_url(.req, url)
}
#' @export
diff --git a/R/resp-headers.R b/R/resp-headers.R
index 0560fc3..d76f18b 100644
--- a/R/resp-headers.R
+++ b/R/resp-headers.R
@@ -173,7 +173,9 @@ resp_link_url <- function(resp, rel) {
return()
}
- links <- parse_link(resp_header(resp, "Link"))
+ headers <- resp_headers(resp)
+ link_headers <- headers[names(headers) == "Link"]
+ links <- unlist(lapply(link_headers, parse_link), recursive = FALSE)
sel <- map_lgl(links, ~ .$rel == rel)
if (sum(sel) != 1L) {
return()
diff --git a/R/resp-request.R b/R/resp-request.R
new file mode 100644
index 0000000..d9e346f
--- /dev/null
+++ b/R/resp-request.R
@@ -0,0 +1,16 @@
+#' Find the request responsible for a response
+#'
+#' To make debugging easier, httr2 includes the request that was used to
+#' generate every response. You can use this function to access it.
+#'
+#' @inheritParams resp_header
+#' @export
+#' @examples
+#' req <- request(example_url())
+#' resp <- req_perform(req)
+#' resp_request(resp)
+resp_request <- function(resp) {
+ check_response(resp)
+
+ resp$request
+}
diff --git a/R/resp-stream-aws.R b/R/resp-stream-aws.R
index 7e7f59c..40c9954 100644
--- a/R/resp-stream-aws.R
+++ b/R/resp-stream-aws.R
@@ -9,11 +9,21 @@ resp_stream_aws <- function(resp, max_size = Inf) {
include_trailer = FALSE
)
- if (!is.null(event_bytes)) {
- parse_aws_event(event_bytes)
- } else {
- return(NULL)
+ if (is.null(event_bytes)) {
+ return()
+ }
+
+ event <- parse_aws_event(event_bytes)
+ if (resp_stream_show_body(resp)) {
+ # Emit header
+ for (key in names(event$headers)) {
+ log_stream(cli::style_bold(key), ": ", event$headers[[key]])
+ }
+ # Emit body
+ log_stream(jsonlite::toJSON(event$body, auto_unbox = TRUE, pretty = TRUE))
+ cli::cat_line()
}
+ event
}
find_aws_event_boundary <- function(buffer) {
@@ -57,15 +67,15 @@ parse_aws_event <- function(bytes) {
# headers
headers <- list()
- while(i <= 12 + header_length) {
+ while (i <= 12 + header_length) {
name_length <- as.integer(read_bytes(1))
name <- rawToChar(read_bytes(name_length))
type <- as.integer(read_bytes(1))
delayedAssign("length", parse_int(read_bytes(2)))
value <- switch(type_enum(type),
- 'TRUE' = TRUE,
- 'FALSE' = FALSE,
+ "TRUE" = TRUE,
+ "FALSE" = FALSE,
BYTE = parse_int(read_bytes(1)),
SHORT = parse_int(read_bytes(2)),
INTEGER = parse_int(read_bytes(4)),
@@ -95,7 +105,7 @@ parse_aws_event <- function(bytes) {
# Helpers ----------------------------------------------------------------
parse_int <- function(x) {
- sum(as.integer(x) * 256 ^ rev(seq_along(x) - 1))
+ sum(as.integer(x) * 256^rev(seq_along(x) - 1))
}
parse_int64 <- function(x) {
diff --git a/R/resp-stream.R b/R/resp-stream.R
index 20f632c..7b2c08c 100644
--- a/R/resp-stream.R
+++ b/R/resp-stream.R
@@ -1,8 +1,6 @@
#' Read a streaming body a chunk at a time
#'
#' @description
-#' `r lifecycle::badge("experimental")`
-#'
#' * `resp_stream_raw()` retrieves bytes (`raw` vectors).
#' * `resp_stream_lines()` retrieves lines of text (`character` vectors).
#' * `resp_stream_sse()` retrieves a single [server-sent
@@ -10,6 +8,9 @@
#' * `resp_stream_aws()` retrieves a single event from an AWS stream
#' (i.e. mime type `application/vnd.amazon.eventstream``).
#'
+#' Use `resp_stream_is_complete()` to determine if there is further data
+#' waiting on the stream.
+#'
#' @returns
#' * `resp_stream_raw()`: a raw vector.
#' * `resp_stream_lines()`: a character vector.
@@ -25,11 +26,33 @@
#' @param resp,con A streaming [response] created by [req_perform_connection()].
#' @param kb How many kilobytes (1024 bytes) of data to read.
#' @order 1
+#' @examples
+#' req <- request(example_url()) |>
+#' req_template("GET /stream/:n", n = 5)
+#'
+#' con <- req |> req_perform_connection()
+#' while (!resp_stream_is_complete(con)) {
+#' lines <- con |> resp_stream_lines(2)
+#' cat(length(lines), " lines received\n", sep = "")
+#' }
+#' close(con)
+#'
+#' # You can also see what's happening by setting verbosity
+#' con <- req |> req_perform_connection(verbosity = 2)
+#' while (!resp_stream_is_complete(con)) {
+#' lines <- con |> resp_stream_lines(2)
+#' }
+#' close(con)
resp_stream_raw <- function(resp, kb = 32) {
check_streaming_response(resp)
conn <- resp$body
- readBin(conn, raw(), kb * 1024)
+ out <- readBin(conn, raw(), kb * 1024)
+ if (resp_stream_show_body(resp)) {
+ log_stream("Streamed ", length(out), " bytes")
+ cli::cat_line()
+ }
+ out
}
#' @export
@@ -56,12 +79,18 @@ resp_stream_lines <- function(resp, lines = 1, max_size = Inf, warn = TRUE) {
line <- resp_stream_oneline(resp, max_size, warn, encoding)
if (length(line) == 0) {
# No more data, either because EOF or req_perform_connection(blocking=FALSE).
- # Either way, return what we have
- return(lines_read)
+ # Either way we're done
+ break
}
lines_read <- c(lines_read, line)
lines <- lines - 1
}
+
+ if (resp_stream_show_body(resp)) {
+ log_stream(lines_read)
+ cli::cat_line()
+ }
+
lines_read
}
@@ -73,11 +102,25 @@ resp_stream_lines <- function(resp, lines = 1, max_size = Inf, warn = TRUE) {
#' @order 1
resp_stream_sse <- function(resp, max_size = Inf) {
event_bytes <- resp_boundary_pushback(resp, max_size, find_event_boundary, include_trailer = FALSE)
- if (!is.null(event_bytes)) {
- parse_event(event_bytes)
- } else {
- return(NULL)
+ if (is.null(event_bytes)) {
+ return()
}
+
+ event <- parse_event(event_bytes)
+ if (resp_stream_show_body(resp)) {
+ for (key in names(event)) {
+ log_stream(cli::style_bold(key), ": ", pretty_json(event[[key]]))
+ }
+ cli::cat_line()
+ }
+ event
+}
+
+#' @export
+#' @rdname resp_stream_raw
+resp_stream_is_complete <- function(resp) {
+ check_response(resp)
+ length(resp$cache$push_back) == 0 && !isIncomplete(resp$body)
}
#' @export
@@ -178,16 +221,16 @@ find_event_boundary <- function(buffer) {
boundary_end <- which(
(left1 == 0x0A & buffer == 0x0A) | # \n\n
- (left1 == 0x0D & buffer == 0x0D) | # \r\r
- (left3 == 0x0D & left2 == 0x0A & left1 == 0x0D & buffer == 0x0A) # \r\n\r\n
+ (left1 == 0x0D & buffer == 0x0D) | # \r\r
+ (left3 == 0x0D & left2 == 0x0A & left1 == 0x0D & buffer == 0x0A) # \r\n\r\n
)
if (length(boundary_end) == 0) {
- return(NULL) # No event boundary found
+ return(NULL) # No event boundary found
}
- boundary_end <- boundary_end[1] # Take the first occurrence
- split_at <- boundary_end + 1 # Split at one after the boundary
+ boundary_end <- boundary_end[1] # Take the first occurrence
+ split_at <- boundary_end + 1 # Split at one after the boundary
split_at
}
@@ -208,7 +251,7 @@ split_buffer <- function(buffer, split_at) {
# the vector
# @param include_trailer If TRUE, at the end of the response, if there are
# bytes after the last boundary, then return those bytes; if FALSE, then those
-# bytes are silently discarded.
+# bytes are discarded with a warning.
resp_boundary_pushback <- function(resp, max_size, boundary_func, include_trailer) {
check_streaming_response(resp)
check_number_whole(max_size, min = 1, allow_infinite = TRUE)
@@ -219,8 +262,12 @@ resp_boundary_pushback <- function(resp, max_size, boundary_func, include_traile
buffer <- resp$cache$push_back %||% raw()
resp$cache$push_back <- raw()
- print_buffer <- function(buf, label) {
- # cat(label, ":", paste(sprintf("%02X", as.integer(buf)), collapse = " "), "\n", file = stderr())
+ if (resp_stream_show_buffer(resp)) {
+ print_buffer <- function(buf, label) {
+ cli::cat_line(" * ", label, ": ", paste(as.character(buf), collapse = " "))
+ }
+ } else {
+ print_buffer <- function(buf, label) {}
}
# Read chunks until we find an event or reach the end of input
@@ -252,24 +299,27 @@ resp_boundary_pushback <- function(resp, max_size, boundary_func, include_traile
# one extra byte so we know to error.
n = min(chunk_size, max_size - length(buffer) + 1)
)
-
print_buffer(chunk, "Received chunk")
- # If we've reached the end of input, store the buffer and return NULL
if (length(chunk) == 0) {
if (!isIncomplete(resp$body)) {
# We've truly reached the end of the connection; no more data is coming
- if (include_trailer && length(buffer) > 0) {
- return(buffer)
- } else {
+ if (length(buffer) == 0) {
return(NULL)
+ } else {
+ if (include_trailer) {
+ return(buffer)
+ } else {
+ cli::cli_warn("Premature end of input; ignoring final partial chunk")
+ return(NULL)
+ }
}
+ } else {
+ # More data might come later; store the buffer and return NULL
+ print_buffer(buffer, "Storing incomplete buffer")
+ resp$cache$push_back <- buffer
+ return(NULL)
}
-
- # More data might come later
- print_buffer(buffer, "Storing incomplete buffer")
- resp$cache$push_back <- buffer
- return(NULL)
}
# More data was received; combine it with existing buffer and continue the
@@ -313,7 +363,6 @@ parse_event <- function(event_data) {
check_streaming_response <- function(resp,
arg = caller_arg(resp),
call = caller_env()) {
-
check_response(resp, arg = arg, call = call)
if (resp_body_type(resp) != "stream") {
@@ -344,3 +393,10 @@ isValid <- function(con) {
error = function(cnd) FALSE
)
}
+
+resp_stream_show_body <- function(resp) {
+ resp$request$policies$show_streaming_body %||% FALSE
+}
+resp_stream_show_buffer <- function(resp) {
+ resp$request$policies$show_streaming_buffer %||% FALSE
+}
diff --git a/R/sequential.R b/R/sequential.R
index cda5389..db80bd6 100644
--- a/R/sequential.R
+++ b/R/sequential.R
@@ -6,16 +6,19 @@
#'
#' @param reqs A list of [request]s.
#' @param paths An optional character vector of paths, if you want to download
-#' the request bodies to disk. If supplied, must be the same length as `reqs`.
+#' the response bodies to disk. If supplied, must be the same length as
+#' `reqs`.
#' @param on_error What should happen if one of the requests fails?
#'
#' * `stop`, the default: stop iterating with an error.
#' * `return`: stop iterating, returning all the successful responses
#' received so far, as well as an error object for the failed request.
#' * `continue`: continue iterating, recording errors in the result.
-#' @param progress Display a progress bar? Use `TRUE` to turn on a basic
-#' progress bar, use a string to give it a name, or see [progress_bars] to
-#' customise it in other ways.
+#' @param progress Display a progress bar for the status of all requests? Use
+#' `TRUE` to turn on a basic progress bar, use a string to give it a name,
+#' or see [progress_bars] to customize it in other ways. Not compatible with
+#' [req_progress()], as httr2 can only display a single progress bar at a
+#' time.
#' @return
#' A list, the same length as `reqs`, containing [response]s and possibly
#' error objects, if `on_error` is `"return"` or `"continue"` and one of the
diff --git a/R/test.R b/R/test.R
index 9f973db..7622338 100644
--- a/R/test.R
+++ b/R/test.R
@@ -50,7 +50,7 @@ example_url <- function() {
env_cache(the, "test_app",
webfakes::new_app_process(
app,
- opts = webfakes::server_opts(num_threads = 2)
+ opts = webfakes::server_opts(num_threads = 6, enable_keep_alive = TRUE)
)
)
the$test_app$url()
diff --git a/R/url.R b/R/url.R
index 24680ab..bf2f8ac 100644
--- a/R/url.R
+++ b/R/url.R
@@ -1,75 +1,203 @@
-#' Parse and build URLs
+#' Parse a URL into its component pieces
#'
-#' `url_parse()` parses a URL into its component pieces; `url_build()` does
-#' the reverse, converting a list of pieces into a string URL. See `r rfc(3986)`
-#' for the details of the parsing algorithm.
+#' `url_parse()` parses a URL into its component parts, powered by
+#' [curl::curl_parse_url()]. The parsing algorithm follows the specifications
+#' detailed in `r rfc(3986)`.
#'
-#' @param url For `url_parse()` a string to parse into a URL;
-#' for `url_build()` a URL to turn back into a string.
-#' @returns
-#' * `url_build()` returns a string.
-#' * `url_parse()` returns a URL: a S3 list with class `httr2_url`
-#' and elements `scheme`, `hostname`, `port`, `path`, `fragment`, `query`,
-#' `username`, `password`.
+#' @param url A string containing the URL to parse.
+#' @param base_url Use this as a parent, if `url` is a relative URL.
+#' @returns An S3 object of class `httr2_url` with the following components:
+#' `scheme`, `hostname`, `username`, `password`, `port`, `path`, `query`, and
+#' `fragment`.
#' @export
+#' @family URL manipulation
#' @examples
#' url_parse("http://google.com/")
#' url_parse("http://google.com:80/")
#' url_parse("http://google.com:80/?a=1&b=2")
#' url_parse("http://username@google.com:80/path;test?a=1&b=2#40")
#'
-#' url <- url_parse("http://google.com/")
-#' url$port <- 80
-#' url$hostname <- "example.com"
-#' url$query <- list(a = 1, b = 2, c = 3)
-#' url_build(url)
-url_parse <- function(url) {
+#' # You can parse a relative URL if you also provide a base url
+#' url_parse("foo", "http://google.com/bar/")
+#' url_parse("..", "http://google.com/bar/")
+url_parse <- function(url, base_url = NULL) {
check_string(url)
+ check_string(base_url, allow_null = TRUE)
- # https://datatracker.ietf.org/doc/html/rfc3986#appendix-B
- pieces <- parse_match(url, "^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?")
+ curl <- curl::curl_parse_url(url, baseurl = base_url, decode = FALSE)
- scheme <- pieces[[2]]
- authority <- pieces[[4]]
- path <- pieces[[5]]
- query <- pieces[[7]]
- if (!is.null(query)) {
- query <- query_parse(query)
+ parsed <- list(
+ scheme = curl$scheme,
+ hostname = curl$host,
+ username = curl$user,
+ password = curl$password,
+ port = curl$port,
+ path = curl$path,
+ query = if (length(curl$params)) as.list(curl$params),
+ fragment = curl$fragment
+ )
+ class(parsed) <- "httr2_url"
+ parsed
+}
+
+#' Modify a URL
+#'
+#' @description
+#' Use `url_modify()` to modify any component of the URL,
+#' `url_modify_relative()` to modify with a relative URL,
+#' or `url_modify_query()` to modify individual query parameters.
+#'
+#' For `url_modify()`, components that aren't specified in the
+#' function call will be left as is; components set to `NULL` will be removed,
+#' and all other values will be updated. Note that removing `scheme` or
+#' `hostname` will create a relative URL.
+#'
+#' @param url,.url A string or [parsed URL][url_parse()].
+#' @param scheme The scheme, typically either `http` or `https`.
+#' @param hostname The hostname, e.g., `www.google.com` or `posit.co`.
+#' @param username,password Username and password to embed in the URL.
+#' Not generally recommended but needed for some legacy applications.
+#' @param port An integer port number.
+#' @param path The path, e.g., `/search`. Paths must start with `/`, so this
+#' will be automatically added if omitted.
+#' @param query Either a query string or a named list of query components.
+#' @param fragment The fragment, e.g., `#section-1`.
+#' @return An object of the same type as `url`.
+#' @export
+#' @family URL manipulation
+#' @examples
+#' url_modify("http://hadley.nz", path = "about")
+#' url_modify("http://hadley.nz", scheme = "https")
+#' url_modify("http://hadley.nz/abc", path = "/cde")
+#' url_modify("http://hadley.nz/abc", path = "")
+#' url_modify("http://hadley.nz?a=1", query = "b=2")
+#' url_modify("http://hadley.nz?a=1", query = list(c = 3))
+#'
+#' url_modify_query("http://hadley.nz?a=1&b=2", c = 3)
+#' url_modify_query("http://hadley.nz?a=1&b=2", b = NULL)
+#' url_modify_query("http://hadley.nz?a=1&b=2", a = 100)
+#'
+#' url_modify_relative("http://hadley.nz/a/b/c.html", "/d.html")
+#' url_modify_relative("http://hadley.nz/a/b/c.html", "d.html")
+#' url_modify_relative("http://hadley.nz/a/b/c.html", "../d.html")
+url_modify <- function(url,
+ scheme = as_is,
+ hostname = as_is,
+ username = as_is,
+ password = as_is,
+ port = as_is,
+ path = as_is,
+ query = as_is,
+ fragment = as_is) {
+ if (!is_string(url) && !is_url(url)) {
+ stop_input_type(url, "a string or parsed URL")
+ }
+ string_url <- is_string(url)
+ if (string_url) {
+ url <- url_parse(url)
}
- fragment <- pieces[[9]]
- # https://datatracker.ietf.org/doc/html/rfc3986#section-3.2
- pieces <- parse_match(authority %||% "", "^(([^@]+)@)?([^:]+)?(:([^#]+))?")
+ if (!leave_as_is(scheme)) check_string(scheme, allow_null = TRUE)
+ if (!leave_as_is(hostname)) check_string(hostname, allow_null = TRUE)
+ if (!leave_as_is(username)) check_string(username, allow_null = TRUE)
+ if (!leave_as_is(password)) check_string(password, allow_null = TRUE)
+ if (!leave_as_is(port)) check_number_whole(port, min = 1, allow_null = TRUE)
+ if (!leave_as_is(path)) check_string(path, allow_null = TRUE)
+ if (!leave_as_is(fragment)) check_string(fragment, allow_null = TRUE)
- userinfo <- pieces[[2]]
- if (!is.null(userinfo)) {
- userinfo <- parse_in_half(userinfo, ":")
- if (userinfo$right == "") {
- userinfo$right <- NULL
+ if (is_string(query)) {
+ query <- url_query_parse(query)
+ } else if (is_named_list(query)) {
+ for (nm in names(query)) {
+ check_query_param(query[[nm]], paste0("query$", nm))
}
+ } else if (!is.null(query) && !leave_as_is(query)) {
+ stop_input_type(query, "a character vector, named list, or NULL")
}
- hostname <- pieces[[3]]
- port <- pieces[[5]]
-
- structure(
- list(
- scheme = scheme,
- hostname = hostname,
- username = userinfo$left,
- password = userinfo$right,
- port = port,
- path = path,
- query = query,
- fragment = fragment
- ),
- class = "httr2_url"
+
+ new <- list(
+ scheme = scheme,
+ hostname = hostname,
+ username = username,
+ password = password,
+ port = port,
+ path = path,
+ query = query,
+ fragment = fragment
)
+ new <- new[!map_lgl(new, leave_as_is)]
+ url[names(new)] <- new
+
+ if (string_url) {
+ url_build(url)
+ } else {
+ url
+ }
+}
+
+as_is <- quote(as_is)
+leave_as_is <- function(x) identical(x, as_is)
+
+#' @export
+#' @rdname url_modify
+#' @param relative_url A relative URL to append to the base URL.
+url_modify_relative <- function(url, relative_url) {
+ string_url <- is_string(url)
+ if (!string_url) {
+ url <- url_build(url)
+ }
+
+ new_url <- url_parse(relative_url, base_url = url)
+
+ if (string_url) {
+ url_build(new_url)
+ } else {
+ new_url
+ }
}
-url_modify <- function(url, ..., error_call = caller_env()) {
- url <- url_parse(url)
- url <- modify_list(url, ..., error_call = error_call)
- url_build(url)
+#' @export
+#' @rdname url_modify
+#' @param ... <[`dynamic-dots`][rlang::dyn-dots]>
+#' Name-value pairs that define query parameters. Each value must be either
+#' an atomic vector or `NULL` (which removes the corresponding parameters).
+#' If you want to opt out of escaping, wrap strings in `I()`.
+#' @param .multi Controls what happens when a value is a vector:
+#'
+#' * `"error"`, the default, throws an error.
+#' * `"comma"`, separates values with a `,`, e.g. `?x=1,2`.
+#' * `"pipe"`, separates values with a `|`, e.g. `?x=1|2`.
+#' * `"explode"`, turns each element into its own parameter, e.g. `?x=1&x=2`
+#'
+#' If none of these options work for your needs, you can instead supply a
+#' function that takes a character vector of argument values and returns a
+#' a single string.
+#' @param .space How should spaces in query params be escaped? The default,
+#' "percent", uses standard percent encoding (i.e. `%20`), but you can opt-in
+#' to "form" encoding, which uses `+` instead.
+url_modify_query <- function(
+ .url,
+ ...,
+ .multi = c("error", "comma", "pipe", "explode"),
+ .space = c("percent", "form")) {
+ if (!is_string(.url) && !is_url(.url)) {
+ stop_input_type(.url, "a string or parsed URL")
+ }
+ string_url <- is_string(.url)
+ if (string_url) {
+ .url <- url_parse(.url)
+ }
+
+ new_query <- multi_dots(..., .multi = .multi, .space = .space)
+ if (length(new_query) > 0) {
+ .url$query <- modify_list(.url$query, !!!new_query)
+ }
+
+ if (string_url) {
+ url_build(.url)
+ } else {
+ .url
+ }
}
is_url <- function(x) inherits(x, "httr2_url")
@@ -109,11 +237,21 @@ print.httr2_url <- function(x, ...) {
invisible(x)
}
+#' Build a string from a URL object
+#'
+#' This is the inverse of [url_parse()], taking a parsed URL object and
+#' turning it back into a string.
+#'
+#' @param url An URL object created by [url_parse].
+#' @family URL manipulation
#' @export
-#' @rdname url_parse
url_build <- function(url) {
+ if (!is_url(url)) {
+ stop_input_type(url, "a parsed URL")
+ }
+
if (!is.null(url$query)) {
- query <- query_build(url$query)
+ query <- url_query_build(url$query)
} else {
query <- NULL
}
@@ -137,7 +275,7 @@ url_build <- function(url) {
authority <- NULL
}
- if (!is.null(url$path) && !startsWith(url$path, "/")) {
+ if (is.null(url$path) || !startsWith(url$path, "/")) {
url$path <- paste0("/", url$path)
}
@@ -151,9 +289,23 @@ url_build <- function(url) {
)
}
-query_parse <- function(x) {
- x <- gsub("^\\?", "", x) # strip leading ?, if present
- params <- parse_name_equals_value(parse_delim(x, "&"))
+#' Parse query parameters and/or build a string
+#'
+#' `url_query_parse()` parses a query string into a named list;
+#' `url_query_build()` builds a query string from a named list.
+#'
+#' @param query A string, when parsing; a named list when building.
+#' @export
+#' @examples
+#' str(url_query_parse("a=1&b=2"))
+#'
+#' url_query_build(list(x = 1, y = "z"))
+#' url_query_build(list(x = 1, y = 1:2), .multi = "explode")
+url_query_parse <- function(query) {
+ check_string(query)
+
+ query <- gsub("^\\?", "", query) # strip leading ?, if present
+ params <- parse_name_equals_value(parse_delim(query, "&"))
if (length(params) == 0) {
return(NULL)
@@ -164,12 +316,20 @@ query_parse <- function(x) {
out
}
-query_build <- function(x, error_call = caller_env()) {
- elements_build(x, "Query", "&", error_call = error_call)
+#' @export
+#' @rdname url_query_parse
+#' @inheritParams url_modify_query
+url_query_build <- function(query, .multi = c("error", "comma", "pipe", "explode")) {
+ if (!is_named_list(query)) {
+ stop_input_type(query, "a named list")
+ }
+
+ query <- multi_dots(!!!query, .multi = .multi, error_arg = "query")
+ elements_build(query, "Query", "&")
}
elements_build <- function(x, name, collapse, error_call = caller_env()) {
- if (!is_list(x) || (!is_named(x) && length(x) > 0)) {
+ if (!is_named_list(x)) {
cli::cli_abort("{name} must be a named list.", call = error_call)
}
@@ -187,6 +347,7 @@ elements_build <- function(x, name, collapse, error_call = caller_env()) {
format_query_param <- function(x,
name,
multi = FALSE,
+ form = FALSE,
error_call = caller_env()) {
check_query_param(x, name, multi = multi, error_call = error_call)
@@ -194,7 +355,11 @@ format_query_param <- function(x,
unclass(x)
} else {
x <- format(x, scientific = FALSE, trim = TRUE, justify = "none")
- curl::curl_escape(x)
+ x <- curl::curl_escape(x)
+ if (form) {
+ x <- gsub("%20", "+", x, fixed = TRUE)
+ }
+ x
}
}
check_query_param <- function(x, name, multi = FALSE, error_call = caller_env()) {
diff --git a/R/utils-multi.R b/R/utils-multi.R
index aa16e35..3fd851f 100644
--- a/R/utils-multi.R
+++ b/R/utils-multi.R
@@ -1,5 +1,6 @@
multi_dots <- function(...,
.multi = c("error", "comma", "pipe", "explode"),
+ .space = c("percent", "form"),
error_arg = "...",
error_call = caller_env()) {
if (is.function(.multi)) {
@@ -7,6 +8,8 @@ multi_dots <- function(...,
} else {
.multi <- arg_match(.multi, error_arg = ".multi", error_call = error_call)
}
+ .space <- arg_match(.space, call = error_call)
+ form <- .space == "form"
dots <- list2(...)
if (length(dots) == 0) {
@@ -31,20 +34,20 @@ multi_dots <- function(...,
n <- lengths(dots)
if (any(n > 1)) {
if (is.function(.multi)) {
- dots[n > 1] <- imap(dots[n > 1], format_query_param, multi = TRUE)
+ dots[n > 1] <- imap(dots[n > 1], format_query_param, multi = TRUE, form = form)
dots[n > 1] <- lapply(dots[n > 1], .multi)
dots[n > 1] <- lapply(dots[n > 1], I)
} else if (.multi == "comma") {
- dots[n > 1] <- imap(dots[n > 1], format_query_param, multi = TRUE)
+ dots[n > 1] <- imap(dots[n > 1], format_query_param, multi = TRUE, form = form)
dots[n > 1] <- lapply(dots[n > 1], paste0, collapse = ",")
dots[n > 1] <- lapply(dots[n > 1], I)
} else if (.multi == "pipe") {
- dots[n > 1] <- imap(dots[n > 1], format_query_param, multi = TRUE)
+ dots[n > 1] <- imap(dots[n > 1], format_query_param, multi = TRUE, form = form)
dots[n > 1] <- lapply(dots[n > 1], paste0, collapse = "|")
dots[n > 1] <- lapply(dots[n > 1], I)
} else if (.multi == "explode") {
dots <- explode(dots)
- dots[n > 1] <- imap(dots[n > 1], format_query_param, multi = TRUE)
+ dots[n > 1] <- imap(dots[n > 1], format_query_param, multi = TRUE, form = form)
dots[n > 1] <- lapply(dots[n > 1], I)
} else if (.multi == "error") {
cli::cli_abort(
@@ -58,7 +61,12 @@ multi_dots <- function(...,
}
# Format other params
- dots[n == 1] <- imap(dots[n == 1], format_query_param, error_call = error_call)
+ dots[n == 1] <- imap(
+ dots[n == 1],
+ format_query_param,
+ form = form,
+ error_call = error_call
+ )
dots[n == 1] <- lapply(dots[n == 1], I)
dots
diff --git a/R/utils.R b/R/utils.R
index 91e2516..bce1bed 100644
--- a/R/utils.R
+++ b/R/utils.R
@@ -8,7 +8,7 @@ bullets_with_header <- function(header, x) {
as_simple <- function(x) {
if (is.atomic(x) && length(x) == 1) {
if (is.character(x)) {
- paste0("'", x, "'")
+ paste0('"', x, '"')
} else {
format(x)
}
@@ -18,7 +18,9 @@ bullets_with_header <- function(header, x) {
}
vals <- map_chr(x, as_simple)
- cli::cli_li(paste0("{.field ", names(x), "}: ", vals))
+ for (i in seq_along(x)) {
+ cli::cli_li("{.field {names(x)[[i]]}}: {vals[[i]]}")
+ }
}
modify_list <- function(.x, ..., error_call = caller_env()) {
@@ -32,6 +34,8 @@ modify_list <- function(.x, ..., error_call = caller_env()) {
)
}
+
+
out <- .x[!names(.x) %in% names(dots)]
out <- c(out, compact(dots))
@@ -324,3 +328,24 @@ slice <- function(vector, start = 1, end = length(vector) + 1) {
vector[start:(end - 1)]
}
}
+
+is_named_list <- function(x) {
+ is_list(x) && (is_named(x) || length(x) == 0)
+}
+
+pretty_json <- function(x) {
+ parsed <- tryCatch(
+ jsonlite::parse_json(x),
+ error = function(e) NULL
+ )
+ if (is.null(parsed)) {
+ x
+ } else {
+ jsonlite::toJSON(parsed, auto_unbox = TRUE, pretty = TRUE)
+ }
+}
+
+log_stream <- function(..., prefix = "<< ") {
+ out <- gsub("\n", paste0("\n", prefix), paste0(prefix, ..., collapse = ""))
+ cli::cat_line(out)
+}
diff --git a/README.md b/README.md
index cb2d1d4..cecbeb5 100644
--- a/README.md
+++ b/README.md
@@ -10,11 +10,12 @@
coverage](https://codecov.io/gh/r-lib/httr2/branch/main/graph/badge.svg)](https://app.codecov.io/gh/r-lib/httr2?branch=main)
-httr2 (pronounced hitter2) is a ground-up rewrite of
-[httr](https://httr.r-lib.org) that provides a pipeable API with an
-explicit request object that solves more problems felt by packages that
-wrap APIs (e.g. built-in rate-limiting, retries, OAuth, secure secrets,
-and more).
+httr2 (pronounced “hitter2”) is a comprehensive HTTP client that
+provides a modern, pipeable API for working with web APIs. It builds on
+top of [{curl}](https://jeroen.r-universe.dev/curl) to provide features
+like explicit request objects, built-in rate limiting & retry tooling,
+comprehensive OAuth support, and secure handling of secrets and
+credentials.
## Installation
@@ -46,7 +47,7 @@ req |> req_headers("Accept" = "application/json")
#>
#> GET https://r-project.org
#> Headers:
-#> • Accept: 'application/json'
+#> • Accept: "application/json"
#> Body: empty
# Add a body, turning it into a POST
@@ -69,6 +70,9 @@ req |> req_retry(max_tries = 5)
#> Policies:
#> • retry_max_tries: 5
#> • retry_on_failure: FALSE
+#> • retry_failure_threshold: Inf
+#> • retry_failure_timeout: 30
+#> • retry_realm: "r-project.org"
# Change the HTTP method
req |> req_method("PATCH")
@@ -83,9 +87,9 @@ And see exactly what httr2 will send to the server with `req_dry_run()`:
req |> req_dry_run()
#> GET / HTTP/1.1
#> Host: r-project.org
-#> User-Agent: httr2/1.0.3.9000 r-curl/5.2.2 libcurl/8.6.0
+#> User-Agent: httr2/1.0.7.9000 r-curl/6.1.0 libcurl/8.7.1
#> Accept: */*
-#> Accept-Encoding: deflate, gzip
+#> Accept-Encoding: gzip
```
Use `req_perform()` to perform the request, retrieving a **response**:
@@ -97,7 +101,7 @@ resp
#> GET https://www.r-project.org/
#> Status: 200 OK
#> Content-Type: text/html
-#> Body: In memory (6951 bytes)
+#> Body: In memory (6774 bytes)
```
The `resp_` functions help you extract various useful components of the
@@ -147,10 +151,9 @@ resp |> resp_body_html()
## Acknowledgements
-httr2 wouldn’t be possible without
-[curl](https://jeroen.cran.dev/curl/),
-[openssl](https://github.com/jeroen/openssl/),
-[jsonlite](https://jeroen.cran.dev/jsonlite/), and
+httr2 wouldn’t be possible without [curl](https://cran.dev/curl/),
+[openssl](https://cran.dev/openssl/),
+[jsonlite](https://cran.dev/jsonlite/), and
[jose](https://github.com/r-lib/jose/), which are all maintained by
[Jeroen Ooms](https://github.com/jeroen). A big thanks also go to [Jenny
Bryan](https://jennybryan.org) and [Craig
diff --git a/inst/doc/httr2.html b/inst/doc/httr2.html
index f13d4fb..b6cff07 100644
--- a/inst/doc/httr2.html
+++ b/inst/doc/httr2.html
@@ -358,7 +358,7 @@ Create a request
req <- request(example_url())
req
#> <httr2_request>
-#> GET http://127.0.0.1:50487/
+#> GET http://127.0.0.1:56838/
#> Body: empty
Here, instead of an external website, we use a test server that’s
built-in to httr2 itself. That ensures that this vignette will work
@@ -367,8 +367,8 @@
Create a request
dry run:
req |> req_dry_run()
#> GET / HTTP/1.1
-#> Host: 127.0.0.1:50487
-#> User-Agent: httr2/1.0.7 r-curl/6.0.1 libcurl/8.7.1
+#> Host: 127.0.0.1:56838
+#> User-Agent: httr2/1.1.0 r-curl/6.1.0 libcurl/8.7.1
#> Accept: */*
#> Accept-Encoding: gzip
The first line of the request contains three important pieces of
@@ -382,7 +382,7 @@
Create a request
The path, which is the URL stripped of details
that the server already knows, i.e. the protocol (http
or
https
), the host (localhost
), and the port
-(50487
).
+(56838
).
The version of the HTTP protocol. This is unimportant for our
purposes because it’s handled at a lower level.
@@ -398,8 +398,8 @@ Create a request
) |>
req_dry_run()
#> GET / HTTP/1.1
-#> Host: 127.0.0.1:50487
-#> User-Agent: httr2/1.0.7 r-curl/6.0.1 libcurl/8.7.1
+#> Host: 127.0.0.1:56838
+#> User-Agent: httr2/1.1.0 r-curl/6.1.0 libcurl/8.7.1
#> Accept-Encoding: gzip
#> Name: Hadley
#> Shoe-Size: 11
@@ -416,8 +416,8 @@ Create a request
req_body_json(list(x = 1, y = "a")) |>
req_dry_run()
#> POST / HTTP/1.1
-#> Host: 127.0.0.1:50487
-#> User-Agent: httr2/1.0.7 r-curl/6.0.1 libcurl/8.7.1
+#> Host: 127.0.0.1:56838
+#> User-Agent: httr2/1.1.0 r-curl/6.1.0 libcurl/8.7.1
#> Accept: */*
#> Accept-Encoding: gzip
#> Content-Type: application/json
@@ -442,8 +442,8 @@ Create a request
req_body_form(x = "1", y = "a") |>
req_dry_run()
#> POST / HTTP/1.1
-#> Host: 127.0.0.1:50487
-#> User-Agent: httr2/1.0.7 r-curl/6.0.1 libcurl/8.7.1
+#> Host: 127.0.0.1:56838
+#> User-Agent: httr2/1.1.0 r-curl/6.1.0 libcurl/8.7.1
#> Accept: */*
#> Accept-Encoding: gzip
#> Content-Type: application/x-www-form-urlencoded
@@ -457,22 +457,22 @@ Create a request
req_body_multipart(x = "1", y = "a") |>
req_dry_run()
#> POST / HTTP/1.1
-#> Host: 127.0.0.1:50487
-#> User-Agent: httr2/1.0.7 r-curl/6.0.1 libcurl/8.7.1
+#> Host: 127.0.0.1:56838
+#> User-Agent: httr2/1.1.0 r-curl/6.1.0 libcurl/8.7.1
#> Accept: */*
#> Accept-Encoding: gzip
#> Content-Length: 246
-#> Content-Type: multipart/form-data; boundary=------------------------k9Lr0uMj0d6QwSHcamJX8n
+#> Content-Type: multipart/form-data; boundary=------------------------qUCL3dFUsWmQATVBqYbWZQ
#>
-#> --------------------------k9Lr0uMj0d6QwSHcamJX8n
+#> --------------------------qUCL3dFUsWmQATVBqYbWZQ
#> Content-Disposition: form-data; name="x"
#>
#> 1
-#> --------------------------k9Lr0uMj0d6QwSHcamJX8n
+#> --------------------------qUCL3dFUsWmQATVBqYbWZQ
#> Content-Disposition: form-data; name="y"
#>
#> a
-#> --------------------------k9Lr0uMj0d6QwSHcamJX8n--
+#> --------------------------qUCL3dFUsWmQATVBqYbWZQ--
If you need to send data encoded in a different form, you can use
req_body_raw()
to add the data to the body and set the
Content-Type
header.
@@ -485,7 +485,7 @@ Perform a request and fetch the response
resp <- req |> req_perform()
resp
#> <httr2_response>
-#> GET http://127.0.0.1:50487/json
+#> GET http://127.0.0.1:56838/json
#> Status: 200 OK
#> Content-Type: application/json
#> Body: In memory (407 bytes)
@@ -493,36 +493,35 @@ Perform a request and fetch the response
resp_raw()
:
resp |> resp_raw()
#> HTTP/1.1 200 OK
-#> Connection: close
-#> Date: Tue, 26 Nov 2024 13:45:27 GMT
-#> Content-Type: application/json
-#> Content-Length: 407
-#> ETag: "de760e6d"
-#>
-#> {
-#> "firstName": "John",
-#> "lastName": "Smith",
-#> "isAlive": true,
-#> "age": 27,
-#> "address": {
-#> "streetAddress": "21 2nd Street",
-#> "city": "New York",
-#> "state": "NY",
-#> "postalCode": "10021-3100"
-#> },
-#> "phoneNumbers": [
-#> {
-#> "type": "home",
-#> "number": "212 555-1234"
-#> },
-#> {
-#> "type": "office",
-#> "number": "646 555-4567"
-#> }
-#> ],
-#> "children": [],
-#> "spouse": null
-#> }
+#> Date: Sat, 18 Jan 2025 14:13:15 GMT
+#> Content-Type: application/json
+#> Content-Length: 407
+#> ETag: "de760e6d"
+#>
+#> {
+#> "firstName": "John",
+#> "lastName": "Smith",
+#> "isAlive": true,
+#> "age": 27,
+#> "address": {
+#> "streetAddress": "21 2nd Street",
+#> "city": "New York",
+#> "state": "NY",
+#> "postalCode": "10021-3100"
+#> },
+#> "phoneNumbers": [
+#> {
+#> "type": "home",
+#> "number": "212 555-1234"
+#> },
+#> {
+#> "type": "office",
+#> "number": "646 555-4567"
+#> }
+#> ],
+#> "children": [],
+#> "spouse": null
+#> }
An HTTP response has a very similar structure to an HTTP request. The
first line gives the version of HTTP used, and a status code that’s
optionally followed by a short description. Then we have the headers,
@@ -541,13 +540,12 @@
Perform a request and fetch the response
specific header with resp_header()
:
resp |> resp_headers()
#> <httr2_headers>
-#> Connection: close
-#> Date: Tue, 26 Nov 2024 13:45:27 GMT
-#> Content-Type: application/json
-#> Content-Length: 407
-#> ETag: "de760e6d"
-resp |> resp_header("Content-Length")
-#> [1] "407"
+#> Date: Sat, 18 Jan 2025 14:13:15 GMT
+#> Content-Type: application/json
+#> Content-Length: 407
+#> ETag: "de760e6d"
+resp |> resp_header("Content-Length")
+#> [1] "407"
Headers are case insensitive:
resp |> resp_header("ConTEnT-LeNgTH")
#> [1] "407"
diff --git a/man/is_online.Rd b/man/is_online.Rd
new file mode 100644
index 0000000..8fba512
--- /dev/null
+++ b/man/is_online.Rd
@@ -0,0 +1,16 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/is-online.R
+\name{is_online}
+\alias{is_online}
+\title{Is your computer currently online?}
+\usage{
+is_online()
+}
+\description{
+This function uses some cheap heuristics to determine if your computer is
+currently online. It's a simple wrapper around \code{\link[curl:nslookup]{curl::has_internet()}}
+exported from httr2 for convenience.
+}
+\examples{
+is_online()
+}
diff --git a/man/multi_req_perform.Rd b/man/multi_req_perform.Rd
index a178c02..f47ac90 100644
--- a/man/multi_req_perform.Rd
+++ b/man/multi_req_perform.Rd
@@ -10,7 +10,8 @@ multi_req_perform(reqs, paths = NULL, pool = NULL, cancel_on_error = FALSE)
\item{reqs}{A list of \link{request}s.}
\item{paths}{An optional character vector of paths, if you want to download
-the request bodies to disk. If supplied, must be the same length as \code{reqs}.}
+the response bodies to disk. If supplied, must be the same length as
+\code{reqs}.}
\item{pool}{Optionally, a curl pool made by \code{\link[curl:multi]{curl::new_pool()}}. Supply
this if you want to override the defaults for total concurrent connections
diff --git a/man/progress_bars.Rd b/man/progress_bars.Rd
index 3acb1d1..83c52c5 100644
--- a/man/progress_bars.Rd
+++ b/man/progress_bars.Rd
@@ -33,9 +33,6 @@ the same as \code{format}.
By default the same as \code{format}.
\item \code{name}: progress bar name. This is by default the empty string and it
is displayed at the beginning of the progress bar.
-\item \code{show_after}: numeric scalar. Only show the progress bar after this
-number of seconds. It overrides the \code{cli.progress_show_after}
-global option.
\item \code{type}: progress bar type. Currently supported types are:
\itemize{
\item \code{iterator}: the default, a for loop or a mapping function,
diff --git a/man/req_auth_basic.Rd b/man/req_auth_basic.Rd
index 1627404..7f30492 100644
--- a/man/req_auth_basic.Rd
+++ b/man/req_auth_basic.Rd
@@ -11,8 +11,8 @@ req_auth_basic(req, username, password = NULL)
\item{username}{User name.}
-\item{password}{Password. You avoid entering the password directly when
-calling this function as it will be captured by \code{.Rhistory}. Instead,
+\item{password}{Password. You should avoid entering the password directly
+when calling this function as it will be captured by \code{.Rhistory}. Instead,
leave it unset and the default behaviour will prompt you for it
interactively.}
}
diff --git a/man/req_body.Rd b/man/req_body.Rd
index cf41628..67224a8 100644
--- a/man/req_body.Rd
+++ b/man/req_body.Rd
@@ -53,7 +53,8 @@ or an empty list (\code{"list"}).}
data in the body.
\itemize{
\item For \code{req_body_form()}, the values must be strings (or things easily
-coerced to strings);
+coerced to strings). Vectors are convertd to strings using the
+value of \code{.multi}.
\item For \code{req_body_multipart()} the values must be strings or objects
produced by \code{\link[curl:multipart]{curl::form_file()}}/\code{\link[curl:multipart]{curl::form_data()}}.
\item For \code{req_body_json_modify()}, any simple data made from atomic vectors
@@ -63,17 +64,17 @@ and lists.
\code{req_body_json()} uses this argument differently; it takes additional
arguments passed on to \code{\link[jsonlite:fromJSON]{jsonlite::toJSON()}}.}
-\item{.multi}{Controls what happens when an element of \code{...} is a vector
-containing multiple values:
+\item{.multi}{Controls what happens when a value is a vector:
\itemize{
\item \code{"error"}, the default, throws an error.
\item \code{"comma"}, separates values with a \verb{,}, e.g. \verb{?x=1,2}.
\item \code{"pipe"}, separates values with a \code{|}, e.g. \code{?x=1|2}.
-\item \code{"explode"}, turns each element into its own parameter, e.g. \code{?x=1&x=2}.
+\item \code{"explode"}, turns each element into its own parameter, e.g. \code{?x=1&x=2}
}
-If none of these functions work, you can alternatively supply a function
-that takes a character vector and returns a string.}
+If none of these options work for your needs, you can instead supply a
+function that takes a character vector of argument values and returns a
+a single string.}
}
\value{
A modified HTTP \link{request}.
diff --git a/man/req_dry_run.Rd b/man/req_dry_run.Rd
index 62ce151..50ce30b 100644
--- a/man/req_dry_run.Rd
+++ b/man/req_dry_run.Rd
@@ -25,6 +25,13 @@ actually sending anything. It requires the httpuv package because it
works by sending the real HTTP request to a local webserver, thanks to
the magic of \code{\link[curl:curl_echo]{curl::curl_echo()}}.
}
+\details{
+\subsection{Limitations}{
+\itemize{
+\item The \code{Host} header is not respected.
+}
+}
+}
\examples{
# httr2 adds default User-Agent, Accept, and Accept-Encoding headers
request("http://example.com") |> req_dry_run()
diff --git a/man/req_headers.Rd b/man/req_headers.Rd
index c16d693..c78d5cd 100644
--- a/man/req_headers.Rd
+++ b/man/req_headers.Rd
@@ -2,9 +2,12 @@
% Please edit documentation in R/req-headers.R
\name{req_headers}
\alias{req_headers}
+\alias{req_headers_redacted}
\title{Modify request headers}
\usage{
req_headers(.req, ..., .redact = NULL)
+
+req_headers_redacted(.req, ...)
}
\arguments{
\item{.req}{A \link{request}.}
@@ -25,6 +28,10 @@ A modified HTTP \link{request}.
}
\description{
\code{req_headers()} allows you to set the value of any header.
+
+\code{req_headers_redacted()} is a variation that adds "redacted" headers, which
+httr2 avoids printing on the console. This is good practice for
+authentication headers to avoid accidentally leaking them in log files.
}
\examples{
req <- request("http://example.com")
@@ -59,11 +66,14 @@ req |>
# If you have headers in a list, use !!!
headers <- list(HeaderOne = "one", HeaderTwo = "two")
req |>
- req_headers(!!!headers, HeaderThree = "three") |>
- req_dry_run()
-
-# Use `.redact` to hide a header in the output
-req |>
- req_headers(Secret = "this-is-private", Public = "but-this-is-not", .redact = "Secret") |>
+ req_headers(!!!headers, HeaderThree = "three") |>
req_dry_run()
+
+# Use `req_headers_redacted()`` to hide a header in the output
+req_secret <- req |>
+ req_headers_redacted(Secret = "this-is-private") |>
+ req_headers(Public = "but-this-is-not")
+
+req_secret
+req_secret |> req_dry_run()
}
diff --git a/man/req_oauth_auth_code.Rd b/man/req_oauth_auth_code.Rd
index f00fec1..6ceae16 100644
--- a/man/req_oauth_auth_code.Rd
+++ b/man/req_oauth_auth_code.Rd
@@ -101,29 +101,32 @@ by \href{https://datatracker.ietf.org/doc/html/rfc6749#section-4.1}{Section 4.1
This flow is the most commonly used OAuth flow where the user
opens a page in their browser, approves the access, and then returns to R.
When possible, it redirects the browser back to a temporary local webserver
-to capture the authorization code. When this is not possible (e.g. when
+to capture the authorization code. When this is not possible (e.g., when
running on a hosted platform like RStudio Server), provide a custom
\code{redirect_uri} and httr2 will prompt the user to enter the code manually.
Learn more about the overall OAuth authentication flow in
-\url{https://httr2.r-lib.org/articles/oauth.html}.
+\url{https://httr2.r-lib.org/articles/oauth.html}, and more about the motivations
+behind this flow in
+\url{https://stack-auth.com/blog/oauth-from-first-principles}.
}
\section{Security considerations}{
The authorization code flow is used for both web applications and native
applications (which are equivalent to R packages). \href{https://datatracker.ietf.org/doc/html/rfc8252}{RFC 8252} spells out
important considerations for native apps. Most importantly there's no way
for native apps to keep secrets from their users. This means that the
-server should either not require a \code{client_secret} (i.e. a public client
-not an confidential client) or ensure that possession of the \code{client_secret}
-doesn't bestow any meaningful rights.
-
-Only modern APIs from the bigger players (Azure, Google, etc) explicitly
-native apps. However, in most cases, even for older APIs, possessing the
-\code{client_secret} gives you no ability to do anything harmful, so our
-general principle is that it's fine to include it in an R package, as long
-as it's mildly obfuscated to protect it from credential scraping. There's
-no incentive to steal your client credentials if it takes less time to
-create a new client than find your client secret.
+server should either not require a \code{client_secret} (i.e. it should be a
+public client and not a confidential client) or ensure that possession of
+the \code{client_secret} doesn't grant any significant privileges.
+
+Only modern APIs from major providers (like Azure and Google) explicitly
+support native apps. However, in most cases, even for older APIs, possessing
+the \code{client_secret} provides limited ability to perform harmful actions.
+Therefore, our general principle is that it's acceptable to include it in an
+R package, as long as it's mildly obfuscated to protect against credential
+scraping attacks (which aim to acquire large numbers of client secrets by
+scanning public sites like GitHub). The goal is to ensure that obtaining your
+client credentials is more work than just creating a new client.
}
\examples{
diff --git a/man/req_oauth_password.Rd b/man/req_oauth_password.Rd
index 0fe91d1..713f623 100644
--- a/man/req_oauth_password.Rd
+++ b/man/req_oauth_password.Rd
@@ -31,8 +31,8 @@ oauth_flow_password(
\item{username}{User name.}
-\item{password}{Password. You avoid entering the password directly when
-calling this function as it will be captured by \code{.Rhistory}. Instead,
+\item{password}{Password. You should avoid entering the password directly
+when calling this function as it will be captured by \code{.Rhistory}. Instead,
leave it unset and the default behaviour will prompt you for it
interactively.}
diff --git a/man/req_perform_connection.Rd b/man/req_perform_connection.Rd
index a6126f3..cc9961b 100644
--- a/man/req_perform_connection.Rd
+++ b/man/req_perform_connection.Rd
@@ -4,7 +4,7 @@
\alias{req_perform_connection}
\title{Perform a request and return a streaming connection}
\usage{
-req_perform_connection(req, blocking = TRUE)
+req_perform_connection(req, blocking = TRUE, verbosity = NULL)
}
\arguments{
\item{req}{A httr2 \link{request} object.}
@@ -12,10 +12,21 @@ req_perform_connection(req, blocking = TRUE)
\item{blocking}{When retrieving data, should the connection block and wait
for the desired information or immediately return what it has (possibly
nothing)?}
+
+\item{verbosity}{How much information to print? This is a wrapper
+around \code{\link[=req_verbose]{req_verbose()}} that uses an integer to control verbosity:
+\itemize{
+\item \code{0}: no output
+\item \code{1}: show headers
+\item \code{2}: show headers and bodies as they're streamed
+\item \code{3}: show headers, bodies, curl status messages, and stream buffer
+management
}
-\description{
-\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}}
+Use \code{\link[=with_verbosity]{with_verbosity()}} to control the verbosity of requests that
+you can't affect directly.}
+}
+\description{
Use \code{req_perform_connection()} to perform a request if you want to stream the
response body. A response returned by \code{req_perform_connection()} includes a
connection as the body. You can then use \code{\link[=resp_stream_raw]{resp_stream_raw()}},
@@ -40,4 +51,11 @@ length(resp_stream_raw(resp, kb = 16))
# Always close the response when you're done
close(resp)
+
+# You can loop until complete with resp_stream_is_complete()
+resp <- req_perform_connection(req)
+while (!resp_stream_is_complete(resp)) {
+ print(length(resp_stream_raw(resp, kb = 12)))
+}
+close(resp)
}
diff --git a/man/req_perform_iterative.Rd b/man/req_perform_iterative.Rd
index 8c8ce2f..4b86e53 100644
--- a/man/req_perform_iterative.Rd
+++ b/man/req_perform_iterative.Rd
@@ -35,9 +35,11 @@ perform all requests until \code{next_req()} returns \code{NULL}.}
far, as well as an error object for the failed request.
}}
-\item{progress}{Display a progress bar? Use \code{TRUE} to turn on a basic
-progress bar, use a string to give it a name, or see \link{progress_bars} to
-customise it in other ways.}
+\item{progress}{Display a progress bar for the status of all requests? Use
+\code{TRUE} to turn on a basic progress bar, use a string to give it a name,
+or see \link{progress_bars} to customize it in other ways. Not compatible with
+\code{\link[=req_progress]{req_progress()}}, as httr2 can only display a single progress bar at a
+time.}
}
\value{
A list, at most length \code{max_reqs}, containing \link{response}s and possibly one
diff --git a/man/req_perform_parallel.Rd b/man/req_perform_parallel.Rd
index e6dfb5e..f9c9439 100644
--- a/man/req_perform_parallel.Rd
+++ b/man/req_perform_parallel.Rd
@@ -16,7 +16,8 @@ req_perform_parallel(
\item{reqs}{A list of \link{request}s.}
\item{paths}{An optional character vector of paths, if you want to download
-the request bodies to disk. If supplied, must be the same length as \code{reqs}.}
+the response bodies to disk. If supplied, must be the same length as
+\code{reqs}.}
\item{pool}{Optionally, a curl pool made by \code{\link[curl:multi]{curl::new_pool()}}. Supply
this if you want to override the defaults for total concurrent connections
@@ -30,9 +31,11 @@ received so far, as well as an error object for the failed request.
\item \code{continue}: continue iterating, recording errors in the result.
}}
-\item{progress}{Display a progress bar? Use \code{TRUE} to turn on a basic
-progress bar, use a string to give it a name, or see \link{progress_bars} to
-customise it in other ways.}
+\item{progress}{Display a progress bar for the status of all requests? Use
+\code{TRUE} to turn on a basic progress bar, use a string to give it a name,
+or see \link{progress_bars} to customize it in other ways. Not compatible with
+\code{\link[=req_progress]{req_progress()}}, as httr2 can only display a single progress bar at a
+time.}
}
\value{
A list, the same length as \code{reqs}, containing \link{response}s and possibly
diff --git a/man/req_perform_promise.Rd b/man/req_perform_promise.Rd
index 0573a6d..8f0ca12 100644
--- a/man/req_perform_promise.Rd
+++ b/man/req_perform_promise.Rd
@@ -4,7 +4,7 @@
\alias{req_perform_promise}
\title{Perform request asynchronously using the promises package}
\usage{
-req_perform_promise(req, path = NULL, pool = NULL)
+req_perform_promise(req, path = NULL, pool = NULL, verbosity = NULL)
}
\arguments{
\item{req}{A httr2 \link{request} object.}
@@ -15,6 +15,18 @@ for large responses since it avoids storing the response in memory.}
\item{pool}{Optionally, a curl pool made by \code{\link[curl:multi]{curl::new_pool()}}. Supply
this if you want to override the defaults for total concurrent connections
(100) or concurrent connections per host (6).}
+
+\item{verbosity}{How much information to print? This is a wrapper
+around \code{\link[=req_verbose]{req_verbose()}} that uses an integer to control verbosity:
+\itemize{
+\item \code{0}: no output
+\item \code{1}: show headers
+\item \code{2}: show headers and bodies
+\item \code{3}: show headers, bodies, and curl status messages.
+}
+
+Use \code{\link[=with_verbosity]{with_verbosity()}} to control the verbosity of requests that
+you can't affect directly.}
}
\value{
a \code{\link[promises:promise]{promises::promise()}} object which resolves to a \link{response} if
diff --git a/man/req_perform_sequential.Rd b/man/req_perform_sequential.Rd
index c5d1334..1089db6 100644
--- a/man/req_perform_sequential.Rd
+++ b/man/req_perform_sequential.Rd
@@ -15,7 +15,8 @@ req_perform_sequential(
\item{reqs}{A list of \link{request}s.}
\item{paths}{An optional character vector of paths, if you want to download
-the request bodies to disk. If supplied, must be the same length as \code{reqs}.}
+the response bodies to disk. If supplied, must be the same length as
+\code{reqs}.}
\item{on_error}{What should happen if one of the requests fails?
\itemize{
@@ -25,9 +26,11 @@ received so far, as well as an error object for the failed request.
\item \code{continue}: continue iterating, recording errors in the result.
}}
-\item{progress}{Display a progress bar? Use \code{TRUE} to turn on a basic
-progress bar, use a string to give it a name, or see \link{progress_bars} to
-customise it in other ways.}
+\item{progress}{Display a progress bar for the status of all requests? Use
+\code{TRUE} to turn on a basic progress bar, use a string to give it a name,
+or see \link{progress_bars} to customize it in other ways. Not compatible with
+\code{\link[=req_progress]{req_progress()}}, as httr2 can only display a single progress bar at a
+time.}
}
\value{
A list, the same length as \code{reqs}, containing \link{response}s and possibly
diff --git a/man/req_perform_stream.Rd b/man/req_perform_stream.Rd
index 29ee829..f00274c 100644
--- a/man/req_perform_stream.Rd
+++ b/man/req_perform_stream.Rd
@@ -35,6 +35,12 @@ successful (since the \code{callback} function will have handled it). The body
will contain the HTTP response body if the request was unsuccessful.
}
\description{
+\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#superseded}{\figure{lifecycle-superseded.svg}{options: alt='[Superseded]'}}}{\strong{[Superseded]}}
+
+We now recommend \code{\link[=req_perform_connection]{req_perform_connection()}} since it has a considerably more
+flexible interface. Unless I hear compelling reasons otherwise, I'm likely
+to deprecate \code{req_perform_stream()} in a future release.
+
After preparing a request, call \code{req_perform_stream()} to perform the request
and handle the result with a streaming callback. This is useful for
streaming HTTP APIs where potentially the stream never ends.
diff --git a/man/req_retry.Rd b/man/req_retry.Rd
index 9c3d504..c04a47d 100644
--- a/man/req_retry.Rd
+++ b/man/req_retry.Rd
@@ -2,7 +2,7 @@
% Please edit documentation in R/req-retries.R
\name{req_retry}
\alias{req_retry}
-\title{Control when a request will retry, and how long it will wait between tries}
+\title{Automatically retry a request on failure}
\usage{
req_retry(
req,
@@ -11,22 +11,24 @@ req_retry(
retry_on_failure = FALSE,
is_transient = NULL,
backoff = NULL,
- after = NULL
+ after = NULL,
+ failure_threshold = Inf,
+ failure_timeout = 30,
+ failure_realm = NULL
)
}
\arguments{
\item{req}{A httr2 \link{request} object.}
-\item{max_tries, max_seconds}{Cap the maximum number of attempts with
-\code{max_tries} or the total elapsed time from the first request with
-\code{max_seconds}. If neither option is supplied (the default), \code{\link[=req_perform]{req_perform()}}
-will not retry.
+\item{max_tries, max_seconds}{Cap the maximum number of attempts
+(\code{max_tries}), the total elapsed time from the first request
+(\code{max_seconds}), or both.
-\code{max_tries} is the total number of attempts make, so this should always
-be greater than one.`}
+\code{max_tries} is the total number of attempts made, so this should always
+be greater than one.}
\item{retry_on_failure}{Treat low-level failures as if they are
-transient errors, and can be retried.}
+transient errors that can be retried.}
\item{is_transient}{A predicate function that takes a single argument
(the response) and returns \code{TRUE} or \code{FALSE} specifying whether or not
@@ -36,44 +38,52 @@ the response represents a transient error.}
attempts so far) and returns the number of seconds to wait.}
\item{after}{A function that takes a single argument (the response) and
-returns either a number of seconds to wait or \code{NULL}, which indicates
-that a precise wait time is not available that the \code{backoff} strategy
-should be used instead..}
+returns either a number of seconds to wait or \code{NA}. \code{NA} indicates
+that a precise wait time is not available and that the \code{backoff} strategy
+should be used instead.}
+
+\item{failure_threshold, failure_timeout, failure_realm}{Set \code{failure_threshold} to activate "circuit breaking" where if a request
+continues to fail after \code{failure_threshold} times, cause the request to
+error until a timeout of \code{failure_timeout} seconds has elapsed. This
+timeout will persist across all requests with the same \code{failure_realm}
+(which defaults to the hostname of the request) and is intended to detect
+failing servers without needing to wait each time.}
}
\value{
A modified HTTP \link{request}.
}
\description{
-\code{req_retry()} alters \code{\link[=req_perform]{req_perform()}} so that it will automatically retry
-in the case of failure. To activate it, you must specify either the total
-number of requests to make with \code{max_tries} or the total amount of time
-to spend with \code{max_seconds}. Then \code{req_perform()} will retry if the error is
-"transient", i.e. it's an HTTP error that can be resolved by waiting. By
-default, 429 and 503 statuses are treated as transient, but if the API you
-are wrapping has other transient status codes (or conveys transient-ness
-with some other property of the response), you can override the default
-with \code{is_transient}.
+\code{req_retry()} allows \code{\link[=req_perform]{req_perform()}} to automatically retry failing
+requests. It's particularly important for APIs with rate limiting, but can
+also be useful when dealing with flaky servers.
-Additionally, if you set \code{retry_on_failure = TRUE}, the request will retry
-if either the HTTP request or HTTP response doesn't complete successfully
+By default, \code{req_perform()} will retry if the response is a 429
+("too many requests", often used for rate limiting) or 503
+("service unavailable"). If the API you are wrapping has other transient
+status codes (or conveys transience with some other property of the
+response), you can override the default with \code{is_transient}. And
+if you set \code{retry_on_failure = TRUE}, the request will retry
+if either the HTTP request or HTTP response doesn't complete successfully,
leading to an error from curl, the lower-level library that httr2 uses to
-perform HTTP request. This occurs, for example, if your wifi is down.
+perform HTTP requests. This occurs, for example, if your Wi-Fi is down.
+\subsection{Delay}{
It's a bad idea to immediately retry a request, so \code{req_perform()} will
wait a little before trying again:
\itemize{
\item If the response contains the \code{Retry-After} header, httr2 will wait the
amount of time it specifies. If the API you are wrapping conveys this
-information with a different header (or other property of the response)
-you can override the default behaviour with \code{retry_after}.
+information with a different header (or other property of the response),
+you can override the default behavior with \code{retry_after}.
\item Otherwise, httr2 will use "truncated exponential backoff with full
-jitter", i.e. it will wait a random amount of time between one second and
-\code{2 ^ tries} seconds, capped to at most 60 seconds. In other words, it
+jitter", i.e., it will wait a random amount of time between one second and
+\code{2 ^ tries} seconds, capped at a maximum of 60 seconds. In other words, it
waits \code{runif(1, 1, 2)} seconds after the first failure, \code{runif(1, 1, 4)}
after the second, \code{runif(1, 1, 8)} after the third, and so on. If you'd
prefer a different strategy, you can override the default with \code{backoff}.
}
}
+}
\examples{
# google APIs assume that a 500 is also a transient error
request("http://google.com") |>
@@ -81,7 +91,7 @@ request("http://google.com") |>
# use a constant 10s delay after every failure
request("http://example.com") |>
- req_retry(backoff = ~10)
+ req_retry(backoff = \(resp) 10)
# When rate-limited, GitHub's API returns a 403 with
# `X-RateLimit-Remaining: 0` and an Unix time stored in the
diff --git a/man/req_url.Rd b/man/req_url.Rd
index 87bdc28..1e9ebb6 100644
--- a/man/req_url.Rd
+++ b/man/req_url.Rd
@@ -2,6 +2,7 @@
% Please edit documentation in R/req-url.R
\name{req_url}
\alias{req_url}
+\alias{req_url_relative}
\alias{req_url_query}
\alias{req_url_path}
\alias{req_url_path_append}
@@ -9,7 +10,14 @@
\usage{
req_url(req, url)
-req_url_query(.req, ..., .multi = c("error", "comma", "pipe", "explode"))
+req_url_relative(req, url)
+
+req_url_query(
+ .req,
+ ...,
+ .multi = c("error", "comma", "pipe", "explode"),
+ .space = c("percent", "form")
+)
req_url_path(req, ...)
@@ -18,7 +26,8 @@ req_url_path_append(req, ...)
\arguments{
\item{req, .req}{A httr2 \link{request} object.}
-\item{url}{New URL; completely replaces existing.}
+\item{url}{A new URL; either an absolute URL for \code{req_url()} or a
+relative URL for \code{req_url_relative()}.}
\item{...}{For \code{req_url_query()}: <\code{\link[rlang:dyn-dots]{dynamic-dots}}>
Name-value pairs that define query parameters. Each value must be either
@@ -28,31 +37,43 @@ If you want to opt out of escaping, wrap strings in \code{I()}.
For \code{req_url_path()} and \code{req_url_path_append()}: A sequence of path
components that will be combined with \code{/}.}
-\item{.multi}{Controls what happens when an element of \code{...} is a vector
-containing multiple values:
+\item{.multi}{Controls what happens when a value is a vector:
\itemize{
\item \code{"error"}, the default, throws an error.
\item \code{"comma"}, separates values with a \verb{,}, e.g. \verb{?x=1,2}.
\item \code{"pipe"}, separates values with a \code{|}, e.g. \code{?x=1|2}.
-\item \code{"explode"}, turns each element into its own parameter, e.g. \code{?x=1&x=2}.
+\item \code{"explode"}, turns each element into its own parameter, e.g. \code{?x=1&x=2}
}
-If none of these functions work, you can alternatively supply a function
-that takes a character vector and returns a string.}
+If none of these options work for your needs, you can instead supply a
+function that takes a character vector of argument values and returns a
+a single string.}
+
+\item{.space}{How should spaces in query params be escaped? The default,
+"percent", uses standard percent encoding (i.e. \verb{\%20}), but you can opt-in
+to "form" encoding, which uses \code{+} instead.}
}
\value{
A modified HTTP \link{request}.
}
\description{
\itemize{
-\item \code{req_url()} replaces the entire url
-\item \code{req_url_query()} modifies the components of the query
-\item \code{req_url_path()} modifies the path
-\item \code{req_url_path_append()} adds to the path
+\item \code{req_url()} replaces the entire URL.
+\item \code{req_url_relative()} navigates to a relative URL.
+\item \code{req_url_query()} modifies individual query components.
+\item \code{req_url_path()} modifies just the path.
+\item \code{req_url_path_append()} adds to the path.
}
}
\examples{
+# Change complete url
req <- request("http://example.com")
+req |> req_url("http://google.com")
+
+# Use a relative url
+req <- request("http://example.com/a/b/c")
+req |> req_url_relative("..")
+req |> req_url_relative("/d/e/f")
# Change url components
req |>
@@ -61,9 +82,11 @@ req |>
req_url_path_append("search.html") |>
req_url_query(q = "the cool ice")
-# Change complete url
-req |>
- req_url("http://google.com")
+# Modify individual query parameters
+req <- request("http://example.com?a=1&b=2")
+req |> req_url_query(a = 10)
+req |> req_url_query(a = NULL)
+req |> req_url_query(c = 3)
# Use .multi to control what happens with vector parameters:
req |> req_url_query(id = 100:105, .multi = "comma")
@@ -74,3 +97,10 @@ params <- list(a = "1", b = "2")
req |>
req_url_query(!!!params, c = "3")
}
+\seealso{
+\itemize{
+\item To modify a URL without creating a request, see \code{\link[=url_modify]{url_modify()}} and
+friends.
+\item To use a template like \code{GET /user/{user}}, see \code{\link[=req_template]{req_template()}}.
+}
+}
diff --git a/man/resp_request.Rd b/man/resp_request.Rd
new file mode 100644
index 0000000..ff9960b
--- /dev/null
+++ b/man/resp_request.Rd
@@ -0,0 +1,20 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/resp-request.R
+\name{resp_request}
+\alias{resp_request}
+\title{Find the request responsible for a response}
+\usage{
+resp_request(resp)
+}
+\arguments{
+\item{resp}{A httr2 \link{response} object, created by \code{\link[=req_perform]{req_perform()}}.}
+}
+\description{
+To make debugging easier, httr2 includes the request that was used to
+generate every response. You can use this function to access it.
+}
+\examples{
+req <- request(example_url())
+resp <- req_perform(req)
+resp_request(resp)
+}
diff --git a/man/resp_stream_raw.Rd b/man/resp_stream_raw.Rd
index e386a88..c6a43b6 100644
--- a/man/resp_stream_raw.Rd
+++ b/man/resp_stream_raw.Rd
@@ -6,6 +6,7 @@
\alias{resp_stream_sse}
\alias{resp_stream_aws}
\alias{close.httr2_response}
+\alias{resp_stream_is_complete}
\title{Read a streaming body a chunk at a time}
\usage{
resp_stream_raw(resp, kb = 32)
@@ -17,6 +18,8 @@ resp_stream_sse(resp, max_size = Inf)
resp_stream_aws(resp, max_size = Inf)
\method{close}{httr2_response}(con, ...)
+
+resp_stream_is_complete(resp)
}
\arguments{
\item{resp, con}{A streaming \link{response} created by \code{\link[=req_perform_connection]{req_perform_connection()}}.}
@@ -48,7 +51,6 @@ the end of the stream has been reached or, if in nonblocking mode, that
no event is currently available.
}
\description{
-\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}}
\itemize{
\item \code{resp_stream_raw()} retrieves bytes (\code{raw} vectors).
\item \code{resp_stream_lines()} retrieves lines of text (\code{character} vectors).
@@ -56,4 +58,25 @@ no event is currently available.
\item \code{resp_stream_aws()} retrieves a single event from an AWS stream
(i.e. mime type `application/vnd.amazon.eventstream``).
}
+
+Use \code{resp_stream_is_complete()} to determine if there is further data
+waiting on the stream.
+}
+\examples{
+req <- request(example_url()) |>
+ req_template("GET /stream/:n", n = 5)
+
+con <- req |> req_perform_connection()
+while (!resp_stream_is_complete(con)) {
+ lines <- con |> resp_stream_lines(2)
+ cat(length(lines), " lines received\n", sep = "")
+}
+close(con)
+
+# You can also see what's happening by setting verbosity
+con <- req |> req_perform_connection(verbosity = 2)
+while (!resp_stream_is_complete(con)) {
+ lines <- con |> resp_stream_lines(2)
+}
+close(con)
}
diff --git a/man/resps_successes.Rd b/man/resps_successes.Rd
index 5744b0f..aca4539 100644
--- a/man/resps_successes.Rd
+++ b/man/resps_successes.Rd
@@ -19,7 +19,11 @@ resps_data(resps, resp_data)
\item{resps}{A list of responses (possibly including errors).}
\item{resp_data}{A function that takes a response (\code{resp}) and
-returns the data found inside that response as a vector or data frame.}
+returns the data found inside that response as a vector or data frame.
+
+NB: If you're using \code{\link[=resp_body_raw]{resp_body_raw()}}, you're likely to want to wrap its
+output in \code{list()} to avoid combining all the bodies into a single raw
+vector, e.g. \verb{resps |> resps_data(\\(resp) list(resp_body_raw(resp)))}.}
}
\description{
These function provide a basic toolkit for operating with lists of
@@ -47,8 +51,12 @@ resps <- req_perform_parallel(reqs, on_error = "continue")
resps |> resps_successes()
# collect all their data
-resps |> resps_successes() |> resps_data(\(resp) resp_body_json(resp))
+resps |>
+ resps_successes() |>
+ resps_data(\(resp) resp_body_json(resp))
# find requests corresponding to failure responses
-resps |> resps_failures() |> resps_requests()
+resps |>
+ resps_failures() |>
+ resps_requests()
}
diff --git a/man/url_build.Rd b/man/url_build.Rd
new file mode 100644
index 0000000..c209950
--- /dev/null
+++ b/man/url_build.Rd
@@ -0,0 +1,21 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/url.R
+\name{url_build}
+\alias{url_build}
+\title{Build a string from a URL object}
+\usage{
+url_build(url)
+}
+\arguments{
+\item{url}{An URL object created by \link{url_parse}.}
+}
+\description{
+This is the inverse of \code{\link[=url_parse]{url_parse()}}, taking a parsed URL object and
+turning it back into a string.
+}
+\seealso{
+Other URL manipulation:
+\code{\link{url_modify}()},
+\code{\link{url_parse}()}
+}
+\concept{URL manipulation}
diff --git a/man/url_modify.Rd b/man/url_modify.Rd
new file mode 100644
index 0000000..1eaedfd
--- /dev/null
+++ b/man/url_modify.Rd
@@ -0,0 +1,106 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/url.R
+\name{url_modify}
+\alias{url_modify}
+\alias{url_modify_relative}
+\alias{url_modify_query}
+\title{Modify a URL}
+\usage{
+url_modify(
+ url,
+ scheme = as_is,
+ hostname = as_is,
+ username = as_is,
+ password = as_is,
+ port = as_is,
+ path = as_is,
+ query = as_is,
+ fragment = as_is
+)
+
+url_modify_relative(url, relative_url)
+
+url_modify_query(
+ .url,
+ ...,
+ .multi = c("error", "comma", "pipe", "explode"),
+ .space = c("percent", "form")
+)
+}
+\arguments{
+\item{url, .url}{A string or \link[=url_parse]{parsed URL}.}
+
+\item{scheme}{The scheme, typically either \code{http} or \code{https}.}
+
+\item{hostname}{The hostname, e.g., \code{www.google.com} or \code{posit.co}.}
+
+\item{username, password}{Username and password to embed in the URL.
+Not generally recommended but needed for some legacy applications.}
+
+\item{port}{An integer port number.}
+
+\item{path}{The path, e.g., \verb{/search}. Paths must start with \code{/}, so this
+will be automatically added if omitted.}
+
+\item{query}{Either a query string or a named list of query components.}
+
+\item{fragment}{The fragment, e.g., \verb{#section-1}.}
+
+\item{relative_url}{A relative URL to append to the base URL.}
+
+\item{...}{<\code{\link[rlang:dyn-dots]{dynamic-dots}}>
+Name-value pairs that define query parameters. Each value must be either
+an atomic vector or \code{NULL} (which removes the corresponding parameters).
+If you want to opt out of escaping, wrap strings in \code{I()}.}
+
+\item{.multi}{Controls what happens when a value is a vector:
+\itemize{
+\item \code{"error"}, the default, throws an error.
+\item \code{"comma"}, separates values with a \verb{,}, e.g. \verb{?x=1,2}.
+\item \code{"pipe"}, separates values with a \code{|}, e.g. \code{?x=1|2}.
+\item \code{"explode"}, turns each element into its own parameter, e.g. \code{?x=1&x=2}
+}
+
+If none of these options work for your needs, you can instead supply a
+function that takes a character vector of argument values and returns a
+a single string.}
+
+\item{.space}{How should spaces in query params be escaped? The default,
+"percent", uses standard percent encoding (i.e. \verb{\%20}), but you can opt-in
+to "form" encoding, which uses \code{+} instead.}
+}
+\value{
+An object of the same type as \code{url}.
+}
+\description{
+Use \code{url_modify()} to modify any component of the URL,
+\code{url_modify_relative()} to modify with a relative URL,
+or \code{url_modify_query()} to modify individual query parameters.
+
+For \code{url_modify()}, components that aren't specified in the
+function call will be left as is; components set to \code{NULL} will be removed,
+and all other values will be updated. Note that removing \code{scheme} or
+\code{hostname} will create a relative URL.
+}
+\examples{
+url_modify("http://hadley.nz", path = "about")
+url_modify("http://hadley.nz", scheme = "https")
+url_modify("http://hadley.nz/abc", path = "/cde")
+url_modify("http://hadley.nz/abc", path = "")
+url_modify("http://hadley.nz?a=1", query = "b=2")
+url_modify("http://hadley.nz?a=1", query = list(c = 3))
+
+url_modify_query("http://hadley.nz?a=1&b=2", c = 3)
+url_modify_query("http://hadley.nz?a=1&b=2", b = NULL)
+url_modify_query("http://hadley.nz?a=1&b=2", a = 100)
+
+url_modify_relative("http://hadley.nz/a/b/c.html", "/d.html")
+url_modify_relative("http://hadley.nz/a/b/c.html", "d.html")
+url_modify_relative("http://hadley.nz/a/b/c.html", "../d.html")
+}
+\seealso{
+Other URL manipulation:
+\code{\link{url_build}()},
+\code{\link{url_parse}()}
+}
+\concept{URL manipulation}
diff --git a/man/url_parse.Rd b/man/url_parse.Rd
index 15a8d0a..9a881c0 100644
--- a/man/url_parse.Rd
+++ b/man/url_parse.Rd
@@ -2,29 +2,24 @@
% Please edit documentation in R/url.R
\name{url_parse}
\alias{url_parse}
-\alias{url_build}
-\title{Parse and build URLs}
+\title{Parse a URL into its component pieces}
\usage{
-url_parse(url)
-
-url_build(url)
+url_parse(url, base_url = NULL)
}
\arguments{
-\item{url}{For \code{url_parse()} a string to parse into a URL;
-for \code{url_build()} a URL to turn back into a string.}
+\item{url}{A string containing the URL to parse.}
+
+\item{base_url}{Use this as a parent, if \code{url} is a relative URL.}
}
\value{
-\itemize{
-\item \code{url_build()} returns a string.
-\item \code{url_parse()} returns a URL: a S3 list with class \code{httr2_url}
-and elements \code{scheme}, \code{hostname}, \code{port}, \code{path}, \code{fragment}, \code{query},
-\code{username}, \code{password}.
-}
+An S3 object of class \code{httr2_url} with the following components:
+\code{scheme}, \code{hostname}, \code{username}, \code{password}, \code{port}, \code{path}, \code{query}, and
+\code{fragment}.
}
\description{
-\code{url_parse()} parses a URL into its component pieces; \code{url_build()} does
-the reverse, converting a list of pieces into a string URL. See \href{https://datatracker.ietf.org/doc/html/rfc3986}{RFC 3986}
-for the details of the parsing algorithm.
+\code{url_parse()} parses a URL into its component parts, powered by
+\code{\link[curl:curl_parse_url]{curl::curl_parse_url()}}. The parsing algorithm follows the specifications
+detailed in \href{https://datatracker.ietf.org/doc/html/rfc3986}{RFC 3986}.
}
\examples{
url_parse("http://google.com/")
@@ -32,9 +27,13 @@ url_parse("http://google.com:80/")
url_parse("http://google.com:80/?a=1&b=2")
url_parse("http://username@google.com:80/path;test?a=1&b=2#40")
-url <- url_parse("http://google.com/")
-url$port <- 80
-url$hostname <- "example.com"
-url$query <- list(a = 1, b = 2, c = 3)
-url_build(url)
+# You can parse a relative URL if you also provide a base url
+url_parse("foo", "http://google.com/bar/")
+url_parse("..", "http://google.com/bar/")
+}
+\seealso{
+Other URL manipulation:
+\code{\link{url_build}()},
+\code{\link{url_modify}()}
}
+\concept{URL manipulation}
diff --git a/man/url_query_parse.Rd b/man/url_query_parse.Rd
new file mode 100644
index 0000000..14e248e
--- /dev/null
+++ b/man/url_query_parse.Rd
@@ -0,0 +1,36 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/url.R
+\name{url_query_parse}
+\alias{url_query_parse}
+\alias{url_query_build}
+\title{Parse query parameters and/or build a string}
+\usage{
+url_query_parse(query)
+
+url_query_build(query, .multi = c("error", "comma", "pipe", "explode"))
+}
+\arguments{
+\item{query}{A string, when parsing; a named list when building.}
+
+\item{.multi}{Controls what happens when a value is a vector:
+\itemize{
+\item \code{"error"}, the default, throws an error.
+\item \code{"comma"}, separates values with a \verb{,}, e.g. \verb{?x=1,2}.
+\item \code{"pipe"}, separates values with a \code{|}, e.g. \code{?x=1|2}.
+\item \code{"explode"}, turns each element into its own parameter, e.g. \code{?x=1&x=2}
+}
+
+If none of these options work for your needs, you can instead supply a
+function that takes a character vector of argument values and returns a
+a single string.}
+}
+\description{
+\code{url_query_parse()} parses a query string into a named list;
+\code{url_query_build()} builds a query string from a named list.
+}
+\examples{
+str(url_query_parse("a=1&b=2"))
+
+url_query_build(list(x = 1, y = "z"))
+url_query_build(list(x = 1, y = 1:2), .multi = "explode")
+}
diff --git a/tests/testthat/_snaps/curl.md b/tests/testthat/_snaps/curl.md
index 3d58afc..fd8f649 100644
--- a/tests/testthat/_snaps/curl.md
+++ b/tests/testthat/_snaps/curl.md
@@ -40,18 +40,18 @@
Code
curl_translate("curl http://x.com")
Output
- request("http://x.com") |>
+ request("http://x.com/") |>
req_perform()
Code
curl_translate("curl http://x.com -X DELETE")
Output
- request("http://x.com") |>
+ request("http://x.com/") |>
req_method("DELETE") |>
req_perform()
Code
curl_translate("curl http://x.com -H A:1")
Output
- request("http://x.com") |>
+ request("http://x.com/") |>
req_headers(
A = "1",
) |>
@@ -59,7 +59,7 @@
Code
curl_translate("curl http://x.com -H 'A B:1'")
Output
- request("http://x.com") |>
+ request("http://x.com/") |>
req_headers(
`A B` = "1",
) |>
@@ -67,13 +67,13 @@
Code
curl_translate("curl http://x.com -u u:p")
Output
- request("http://x.com") |>
+ request("http://x.com/") |>
req_auth_basic("u", "p") |>
req_perform()
Code
curl_translate("curl http://x.com --verbose")
Output
- request("http://x.com") |>
+ request("http://x.com/") |>
req_perform(verbosity = 1)
# can translate query
@@ -81,7 +81,7 @@
Code
curl_translate("curl http://x.com?string=abcde&b=2")
Output
- request("http://x.com") |>
+ request("http://x.com/") |>
req_url_query(
string = "abcde",
b = "2",
@@ -93,17 +93,61 @@
Code
curl_translate("curl http://example.com --data abcdef")
Output
- request("http://example.com") |>
+ request("http://example.com/") |>
req_body_raw("abcdef", "application/x-www-form-urlencoded") |>
req_perform()
Code
curl_translate(
"curl http://example.com --data abcdef -H Content-Type:text/plain")
Output
- request("http://example.com") |>
+ request("http://example.com/") |>
req_body_raw("abcdef", "text/plain") |>
req_perform()
+# can translate ocokies
+
+ Code
+ curl_translate("curl 'http://test' -H 'Cookie: x=1; y=2;z=3'")
+ Output
+ request("http://test/") |>
+ req_cookies_set(
+ x = "1",
+ y = "2",
+ z = "3",
+ ) |>
+ req_perform()
+
+# can translate json
+
+ Code
+ curl_translate(
+ "curl http://example.com --data-raw '{\"a\": 1, \"b\": \"text\"}' -H Content-Type:application/json")
+ Output
+ request("http://example.com/") |>
+ req_body_json(
+ data = list(a = 1L, b = "text"),
+ ) |>
+ req_perform()
+ Code
+ curl_translate("curl http://example.com --json '{\"a\": 1, \"b\": \"text\"}'")
+ Output
+ request("http://example.com/") |>
+ req_body_json(
+ data = list(a = 1L, b = "text"),
+ ) |>
+ req_perform()
+
+# content type stays in header if no data
+
+ Code
+ curl_translate("curl http://example.com -H Content-Type:text/plain")
+ Output
+ request("http://example.com/") |>
+ req_headers(
+ `Content-Type` = "text/plain",
+ ) |>
+ req_perform()
+
# can read from clipboard
Code
@@ -111,7 +155,7 @@
Message
v Copying to clipboard:
Output
- request("http://example.com") |>
+ request("http://example.com/") |>
req_headers(
A = "1",
B = "2",
@@ -120,17 +164,22 @@
Code
clipr::read_clip()
Output
- [1] "request(\"http://example.com\") |> " " req_headers("
- [3] " A = \"1\"," " B = \"2\","
- [5] " ) |> " " req_perform()"
+ [1] "request(\"http://example.com/\") |> "
+ [2] " req_headers("
+ [3] " A = \"1\","
+ [4] " B = \"2\","
+ [5] " ) |> "
+ [6] " req_perform()"
# encode_string2() produces simple strings
Code
curl_translate(cmd)
Output
- request("http://example.com") |>
+ request("http://example.com/") |>
req_method("PATCH") |>
- req_body_raw('{"data":{"x":1,"y":"a","nested":{"z":[1,2,3]}}}', "application/json") |>
+ req_body_json(
+ data = list(data = list(x = 1L, y = "a", nested = list(z = list(1L, 2L, 3L)))),
+ ) |>
req_perform()
diff --git a/tests/testthat/_snaps/req-mock.md b/tests/testthat/_snaps/req-mock.md
index 6d0d477..3b77c31 100644
--- a/tests/testthat/_snaps/req-mock.md
+++ b/tests/testthat/_snaps/req-mock.md
@@ -4,13 +4,13 @@
local_mock(~ response(404))
Condition
Warning:
- `local_mock()` was deprecated in httr2 1.0.0.
+ `local_mock()` was deprecated in httr2 1.1.0.
i Please use `local_mocked_responses()` instead.
Code
. <- with_mock(NULL, ~ response(404))
Condition
- Warning:
- `with_mock()` was deprecated in httr2 1.0.0.
+ Error:
+ ! `with_mock()` was deprecated in httr2 1.1.0 and is now defunct.
i Please use `with_mocked_responses()` instead.
# validates inputs
diff --git a/tests/testthat/_snaps/req-promise.md b/tests/testthat/_snaps/req-promise.md
index d42917e..efcdd75 100644
--- a/tests/testthat/_snaps/req-promise.md
+++ b/tests/testthat/_snaps/req-promise.md
@@ -1,3 +1,44 @@
+# checks its inputs
+
+ Code
+ req_perform_promise(1)
+ Condition
+ Error in `req_perform_promise()`:
+ ! `req` must be an HTTP request object, not the number 1.
+ Code
+ req_perform_promise(req, path = 1)
+ Condition
+ Error in `req_perform_promise()`:
+ ! `path` must be a single string or `NULL`, not the number 1.
+ Code
+ req_perform_promise(req, pool = "INVALID")
+ Condition
+ Error in `req_perform_promise()`:
+ ! `pool` must be a {curl} pool or `NULL`, not the string "INVALID".
+ Code
+ req_perform_promise(req, verbosity = "INVALID")
+ Condition
+ Error in `req_perform_promise()`:
+ ! `verbosity` must 0, 1, 2, or 3.
+
+# correctly prepares request
+
+ Code
+ . <- extract_promise(req_perform_promise(req, verbosity = 1))
+ Output
+ -> GET /get HTTP/1.1
+ -> Host:
+ -> User-Agent:
+ -> Accept: */*
+ -> Accept-Encoding:
+ ->
+ <- HTTP/1.1 200 OK
+ <- Date:
+ <- Content-Type: application/json
+ <- Content-Length:
+ <- ETag:
+ <-
+
# req_perform_promise uses the default loop
Code
diff --git a/tests/testthat/_snaps/req-retries.md b/tests/testthat/_snaps/req-retries.md
index a44fc28..a1d8ea1 100644
--- a/tests/testthat/_snaps/req-retries.md
+++ b/tests/testthat/_snaps/req-retries.md
@@ -1,3 +1,10 @@
+# has useful default (with message)
+
+ Code
+ req <- req_retry(req)
+ Message
+ Setting `max_tries = 2`.
+
# useful message if `after` wrong
Code
@@ -9,17 +16,17 @@
# validates its inputs
Code
- req_retry(req, max_tries = 1)
+ req_retry(req, max_tries = 0)
Condition
Error in `req_retry()`:
- ! `max_tries` must be a whole number larger than or equal to 2 or `NULL`, not the number 1.
+ ! `max_tries` must be a whole number larger than or equal to 1 or `NULL`, not the number 0.
Code
- req_retry(req, max_seconds = "x")
+ req_retry(req, max_tries = 2, max_seconds = "x")
Condition
Error in `req_retry()`:
! `max_seconds` must be a whole number or `NULL`, not the string "x".
Code
- req_retry(req, retry_on_failure = "x")
+ req_retry(req, max_tries = 2, retry_on_failure = "x")
Condition
Error in `req_retry()`:
! `retry_on_failure` must be `TRUE` or `FALSE`, not the string "x".
diff --git a/tests/testthat/_snaps/req-url.md b/tests/testthat/_snaps/req-url.md
index aeb5062..b67f376 100644
--- a/tests/testthat/_snaps/req-url.md
+++ b/tests/testthat/_snaps/req-url.md
@@ -1,9 +1,17 @@
+# can control space handling
+
+ Code
+ req_url_query(req, a = " ", .space = "bar")
+ Condition
+ Error in `multi_dots()`:
+ ! `.space` must be one of "percent" or "form", not "bar".
+
# can handle multi query params
Code
req_url_query_multi("error")
Condition
- Error in `req_url_query()`:
+ Error in `url_modify_query()`:
! All vector elements of `...` must be length 1.
i Use `.multi` to choose a strategy for handling vectors.
@@ -12,22 +20,22 @@
Code
req %>% req_url_query(1)
Condition
- Error in `req_url_query()`:
+ Error in `url_modify_query()`:
! All components of `...` must be named.
Code
req %>% req_url_query(a = I(1))
Condition
- Error in `req_url_query()`:
+ Error in `url_modify_query()`:
! Escaped query value `a` must be a single string, not the number 1.
Code
req %>% req_url_query(a = 1:2)
Condition
- Error in `req_url_query()`:
+ Error in `url_modify_query()`:
! All vector elements of `...` must be length 1.
i Use `.multi` to choose a strategy for handling vectors.
Code
req %>% req_url_query(a = mean)
Condition
- Error in `req_url_query()`:
+ Error in `url_modify_query()`:
! All elements of `...` must be either an atomic vector or NULL.
diff --git a/tests/testthat/_snaps/req.md b/tests/testthat/_snaps/req.md
index 08bad20..e69c648 100644
--- a/tests/testthat/_snaps/req.md
+++ b/tests/testthat/_snaps/req.md
@@ -20,6 +20,18 @@
POST https://example.com
Body: multipart encoded data
+# printing headers works with {}
+
+ Code
+ req_headers(request("http://test"), x = "{z}", `{z}` = "x")
+ Message
+
+ GET http://test
+ Headers:
+ * x: "{z}"
+ * {z}: "x"
+ Body: empty
+
# individually prints repeated headers
Code
@@ -28,9 +40,9 @@
GET https://example.com
Headers:
- * A: '1'
- * A: '2'
- * A: '3'
+ * A: "1"
+ * A: "2"
+ * A: "3"
Body: empty
# check_request() gives useful error
diff --git a/tests/testthat/_snaps/resp-stream.md b/tests/testthat/_snaps/resp-stream.md
index 9b3cdf2..11dfe94 100644
--- a/tests/testthat/_snaps/resp-stream.md
+++ b/tests/testthat/_snaps/resp-stream.md
@@ -1,3 +1,11 @@
+# can determine if incomplete data is complete
+
+ Code
+ expect_equal(resp_stream_sse(con), NULL)
+ Condition
+ Warning:
+ Premature end of input; ignoring final partial chunk
+
# can't read from a closed connection
Code
@@ -6,3 +14,43 @@
Error in `resp_stream_raw()`:
! `resp` has already been closed.
+# verbosity = 2 streams request bodies
+
+ Code
+ stream_all(req, resp_stream_lines, 1)
+ Output
+ << line 1
+
+ << line 2
+
+ Code
+ stream_all(req, resp_stream_raw, 5 / 1024)
+ Output
+ << Streamed 5 bytes
+
+ << Streamed 5 bytes
+
+ << Streamed 4 bytes
+
+
+# verbosity = 3 shows buffer info
+
+ Code
+ while (!resp_stream_is_complete(con)) {
+ resp_stream_lines(con, 1)
+ }
+ Output
+ * Buffer to parse:
+ * Received chunk: 6c 69 6e 65 20 31 0a 6c 69 6e 65 20 32 0a
+ * Combined buffer: 6c 69 6e 65 20 31 0a 6c 69 6e 65 20 32 0a
+ * Buffer to parse: 6c 69 6e 65 20 31 0a 6c 69 6e 65 20 32 0a
+ * Matched data: 6c 69 6e 65 20 31 0a
+ * Remaining buffer: 6c 69 6e 65 20 32 0a
+ << line 1
+
+ * Buffer to parse: 6c 69 6e 65 20 32 0a
+ * Matched data: 6c 69 6e 65 20 32 0a
+ * Remaining buffer:
+ << line 2
+
+
diff --git a/tests/testthat/_snaps/url.md b/tests/testthat/_snaps/url.md
index a80e0ec..79b0658 100644
--- a/tests/testthat/_snaps/url.md
+++ b/tests/testthat/_snaps/url.md
@@ -24,18 +24,107 @@
Error in `url_build()`:
! Cannot set url `password` without `username`.
+# url_build validates its input
+
+ Code
+ url_build("abc")
+ Condition
+ Error in `url_build()`:
+ ! `url` must be a parsed URL, not the string "abc".
+
+# url_modify checks its inputs
+
+ Code
+ url_modify(1)
+ Condition
+ Error in `url_modify()`:
+ ! `url` must be a string or parsed URL, not the number 1.
+ Code
+ url_modify(url, scheme = 1)
+ Condition
+ Error in `url_modify()`:
+ ! `scheme` must be a single string or `NULL`, not the number 1.
+ Code
+ url_modify(url, hostname = 1)
+ Condition
+ Error in `url_modify()`:
+ ! `hostname` must be a single string or `NULL`, not the number 1.
+ Code
+ url_modify(url, port = "x")
+ Condition
+ Error in `url_modify()`:
+ ! `port` must be a whole number or `NULL`, not the string "x".
+ Code
+ url_modify(url, username = 1)
+ Condition
+ Error in `url_modify()`:
+ ! `username` must be a single string or `NULL`, not the number 1.
+ Code
+ url_modify(url, password = 1)
+ Condition
+ Error in `url_modify()`:
+ ! `password` must be a single string or `NULL`, not the number 1.
+ Code
+ url_modify(url, path = 1)
+ Condition
+ Error in `url_modify()`:
+ ! `path` must be a single string or `NULL`, not the number 1.
+ Code
+ url_modify(url, fragment = 1)
+ Condition
+ Error in `url_modify()`:
+ ! `fragment` must be a single string or `NULL`, not the number 1.
+
+# checks various query formats
+
+ Code
+ url_modify(url, query = 1)
+ Condition
+ Error in `url_modify()`:
+ ! `query` must be a character vector, named list, or NULL, not the number 1.
+ Code
+ url_modify(url, query = list(1))
+ Condition
+ Error in `url_modify()`:
+ ! `query` must be a character vector, named list, or NULL, not a list.
+ Code
+ url_modify(url, query = list(x = 1:2))
+ Condition
+ Error in `url_modify()`:
+ ! Query value `query$x` must be a length-1 atomic vector, not an integer vector.
+
# validates inputs
Code
- query_build(1:3)
+ url_modify_query(1)
Condition
- Error:
- ! Query must be a named list.
+ Error in `url_modify_query()`:
+ ! `.url` must be a string or parsed URL, not the number 1.
Code
- query_build(list(x = 1:2, y = 1:3))
+ url_modify_query(url, 1)
Condition
- Error:
- ! Query value `x` must be a length-1 atomic vector, not an integer vector.
+ Error in `url_modify_query()`:
+ ! All components of `...` must be named.
+ Code
+ url_modify_query(url, x = 1:2)
+ Condition
+ Error in `url_modify_query()`:
+ ! All vector elements of `...` must be length 1.
+ i Use `.multi` to choose a strategy for handling vectors.
+
+---
+
+ Code
+ url_query_build(1:3)
+ Condition
+ Error in `url_query_build()`:
+ ! `query` must be a named list, not an integer vector.
+ Code
+ url_query_build(list(x = 1:2, y = 1:3))
+ Condition
+ Error in `url_query_build()`:
+ ! All vector elements of `query` must be length 1.
+ i Use `.multi` to choose a strategy for handling vectors.
# can't opt out of escaping non strings
diff --git a/tests/testthat/helper-promise.R b/tests/testthat/helper-promise.R
new file mode 100644
index 0000000..f4beb8a
--- /dev/null
+++ b/tests/testthat/helper-promise.R
@@ -0,0 +1,26 @@
+# promises package test helper
+extract_promise <- function(promise, timeout = 30) {
+ promise_value <- NULL
+ error <- NULL
+ promises::then(
+ promise,
+ onFulfilled = function(value) promise_value <<- value,
+ onRejected = function(reason) {
+ error <<- reason
+ }
+ )
+
+ start <- Sys.time()
+ while (!later::loop_empty()) {
+ if (difftime(Sys.time(), start, units = "secs") > timeout) {
+ stop("Waited too long")
+ }
+ later::run_now()
+ Sys.sleep(0.01)
+ }
+
+ if (!is.null(error)) {
+ cnd_signal(error)
+ } else
+ promise_value
+}
diff --git a/tests/testthat/helper.R b/tests/testthat/helper.R
index 0eef785..128daaf 100644
--- a/tests/testthat/helper.R
+++ b/tests/testthat/helper.R
@@ -1,3 +1,3 @@
testthat::set_state_inspector(function() {
- getAllConnections()
+ list(connections = getAllConnections())
})
diff --git a/tests/testthat/test-curl.R b/tests/testthat/test-curl.R
index 225983a..a9cff6a 100644
--- a/tests/testthat/test-curl.R
+++ b/tests/testthat/test-curl.R
@@ -51,7 +51,11 @@ test_that("can handle line breaks", {
test_that("headers are parsed", {
expect_equal(
curl_normalize("curl http://x.com -H 'A: 1'")$headers,
- as_headers("A: 1")
+ new_headers(list(A = "1"))
+ )
+ expect_equal(
+ curl_normalize("curl http://x.com -H 'B:'")$headers,
+ new_headers(list(B = ""))
)
})
@@ -138,6 +142,31 @@ test_that("can translate data", {
})
})
+test_that("can translate ocokies", {
+ skip_if(getRversion() < "4.1")
+
+ expect_snapshot({
+ curl_translate("curl 'http://test' -H 'Cookie: x=1; y=2;z=3'")
+ })
+})
+
+test_that("can translate json", {
+ skip_if(getRversion() < "4.1")
+
+ expect_snapshot({
+ curl_translate(r"--{curl http://example.com --data-raw '{"a": 1, "b": "text"}' -H Content-Type:application/json}--")
+ curl_translate(r"--{curl http://example.com --json '{"a": 1, "b": "text"}'}--")
+ })
+})
+
+test_that("content type stays in header if no data", {
+ skip_if(getRversion() < "4.1")
+
+ expect_snapshot(
+ curl_translate("curl http://example.com -H Content-Type:text/plain")
+ )
+})
+
test_that("can evaluate simple calls", {
request_test() # hack to start server
diff --git a/tests/testthat/test-multi-req.R b/tests/testthat/test-multi-req.R
index 9b637ec..1650e02 100644
--- a/tests/testthat/test-multi-req.R
+++ b/tests/testthat/test-multi-req.R
@@ -9,13 +9,9 @@ test_that("correctly prepares request", {
})
test_that("requests happen in parallel", {
- # GHA MacOS builder seems to be very slow
- skip_if(
- isTRUE(as.logical(Sys.getenv("CI", "false"))) &&
- Sys.info()[["sysname"]] == "Darwin"
- )
-
+ # test works best if webfakes has ample threads and keepalive
reqs <- list2(
+ request_test("/delay/:secs", secs = 0),
request_test("/delay/:secs", secs = 0.25),
request_test("/delay/:secs", secs = 0.25),
request_test("/delay/:secs", secs = 0.25),
diff --git a/tests/testthat/test-oauth-flow-auth-code.R b/tests/testthat/test-oauth-flow-auth-code.R
index 72cd8e6..9d0da0b 100644
--- a/tests/testthat/test-oauth-flow-auth-code.R
+++ b/tests/testthat/test-oauth-flow-auth-code.R
@@ -75,12 +75,12 @@ test_that("old args are deprecated", {
expect_snapshot(
redirect <- normalize_redirect_uri("http://localhost", port = 1234)
)
- expect_equal(redirect$uri, "http://localhost:1234")
+ expect_equal(redirect$uri, "http://localhost:1234/")
expect_snapshot(
redirect <- normalize_redirect_uri("http://x.com", host_name = "y.com")
)
- expect_equal(redirect$uri, "http://y.com")
+ expect_equal(redirect$uri, "http://y.com/")
expect_snapshot(
redirect <- normalize_redirect_uri("http://x.com", host_ip = "y.com")
diff --git a/tests/testthat/test-oauth-flow.R b/tests/testthat/test-oauth-flow.R
index 0bce31e..a3222f5 100644
--- a/tests/testthat/test-oauth-flow.R
+++ b/tests/testthat/test-oauth-flow.R
@@ -21,6 +21,24 @@ test_that("userful errors if response isn't parseable", {
})
})
+test_that("can inspect the original response if response isn't parseable", {
+ resp1 <- response(headers = list(`content-type` = "text/plain"))
+ resp2 <- response_json(body = list())
+
+ tryCatch(
+ oauth_flow_parse(resp1, "test"),
+ httr2_oauth_parse = function(cnd) {
+ expect_equal(cnd$resp, resp1)
+ }
+ )
+ tryCatch(
+ oauth_flow_parse(resp2, "test"),
+ httr2_oauth_parse = function(cnd) {
+ expect_equal(cnd$resp, resp2)
+ }
+ )
+})
+
test_that("returns body if known good structure", {
resp <- response_json(body = list(access_token = "10"))
expect_equal(oauth_flow_parse(resp, "test"), list(access_token = "10"))
diff --git a/tests/testthat/test-req-auth-aws.R b/tests/testthat/test-req-auth-aws.R
index e024f12..b0e7542 100644
--- a/tests/testthat/test-req-auth-aws.R
+++ b/tests/testthat/test-req-auth-aws.R
@@ -34,17 +34,15 @@ test_that("signing agrees with glacier example", {
aws_access_key_id = "AKIAIOSFODNN7EXAMPLE",
aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
)
- signature_pieces <- strsplit(paste0("Authorization=", signature$Authorization), ",")[[1]]
- known <- list(
- Authorization = "AWS4-HMAC-SHA256",
- Credential = "AKIAIOSFODNN7EXAMPLE/20120525/us-east-1/glacier/aws4_request",
- SignedHeaders = "host;x-amz-date;x-amz-glacier-version",
- Signature = "3ce5b2f2fffac9262b4da9256f8d086b4aaf42eba5f111c21681a65a127b7c2a"
+ expected <- paste0(
+ "AWS4-HMAC-SHA256 ",
+ "Credential=AKIAIOSFODNN7EXAMPLE/20120525/us-east-1/glacier/aws4_request,",
+ "SignedHeaders=host;x-amz-date;x-amz-glacier-version,",
+ "Signature=3ce5b2f2fffac9262b4da9256f8d086b4aaf42eba5f111c21681a65a127b7c2a"
)
- known_signature <- paste0(names(known), "=", known)
- expect_equal(signature_pieces, known_signature)
+ expect_equal(signature$Authorization, expected)
})
test_that("validates its inputs", {
diff --git a/tests/testthat/test-req-headers.R b/tests/testthat/test-req-headers.R
index 036a679..1b7f388 100644
--- a/tests/testthat/test-req-headers.R
+++ b/tests/testthat/test-req-headers.R
@@ -30,6 +30,8 @@ test_that("can control which headers to redact", {
expect_redact(req_headers(req, a = 1L, b = 2L, .redact = c("a", "b")), c("a", "b"))
expect_redact(req_headers(req, a = 1L, b = 2L, .redact = "a"), "a")
+ expect_redact(req_headers_redacted(req, a = 1L, b = 2L), c("a", "b"))
+
expect_snapshot(error = TRUE, {
req_headers(req, a = 1L, b = 2L, .redact = 1L)
})
diff --git a/tests/testthat/test-req-mock.R b/tests/testthat/test-req-mock.R
index 457f788..453caec 100644
--- a/tests/testthat/test-req-mock.R
+++ b/tests/testthat/test-req-mock.R
@@ -16,7 +16,7 @@ test_that("can generate errors with mocking", {
})
test_that("local_mock and with_mock are deprecated", {
- expect_snapshot({
+ expect_snapshot(error = TRUE, {
local_mock(~ response(404))
. <- with_mock(NULL, ~ response(404))
})
diff --git a/tests/testthat/test-req-perform.R b/tests/testthat/test-req-perform.R
index 0dbafcc..11a750a 100644
--- a/tests/testthat/test-req-perform.R
+++ b/tests/testthat/test-req-perform.R
@@ -55,11 +55,11 @@ test_that("persistent HTTP errors only get single attempt", {
})
test_that("don't retry curl errors by default", {
- req <- request("") %>% req_retry(max_tries = 2)
+ req <- request("") %>% req_retry(max_tries = 2, failure_realm = "x")
expect_error(req_perform(req), class = "httr2_failure")
# But can opt-in to it
- req <- request("") %>% req_retry(max_tries = 2, retry_on_failure = TRUE)
+ req <- request("") %>% req_retry(max_tries = 2, retry_on_failure = TRUE, failure_realm = "x")
cnd <- catch_cnd(req_perform(req), "httr2_retry")
expect_equal(cnd$tries, 1)
})
@@ -225,3 +225,8 @@ test_that("authorization headers are redacted", {
req_dry_run()
})
})
+
+test_that("doen't add space to urls (#567)", {
+ req <- request("https://example.com/test:1:2")
+ expect_output(req_dry_run(req), "test:1:2")
+})
diff --git a/tests/testthat/test-req-promise.R b/tests/testthat/test-req-promise.R
index 841d9c9..a869329 100644
--- a/tests/testthat/test-req-promise.R
+++ b/tests/testthat/test-req-promise.R
@@ -1,29 +1,13 @@
-# promises package test helper
-extract_promise <- function(promise, timeout = 30) {
- promise_value <- NULL
- error <- NULL
- promises::then(
- promise,
- onFulfilled = function(value) promise_value <<- value,
- onRejected = function(reason) {
- error <<- reason
- }
- )
-
- start <- Sys.time()
- while (!later::loop_empty()) {
- if (difftime(Sys.time(), start, units = "secs") > timeout) {
- stop("Waited too long")
- }
- later::run_now()
- Sys.sleep(0.01)
- }
-
- if (!is.null(error)) {
- cnd_signal(error)
- } else
- promise_value
-}
+test_that("checks its inputs", {
+ req <- request_test("/status/:status", status = 200)
+
+ expect_snapshot(error = TRUE, {
+ req_perform_promise(1)
+ req_perform_promise(req, path = 1)
+ req_perform_promise(req, pool = "INVALID")
+ req_perform_promise(req, verbosity = "INVALID")
+ })
+})
test_that("returns a promise that resolves", {
p1 <- req_perform_promise(request_test("/delay/:secs", secs = 0.25))
@@ -42,6 +26,16 @@ test_that("correctly prepares request", {
expect_no_error(extract_promise(prom))
})
+test_that("correctly prepares request", {
+ req <- request_test("/get")
+ expect_snapshot(
+ . <- extract_promise(req_perform_promise(req, verbosity = 1)),
+ transform = function(x) {
+ gsub("(Date|Host|User-Agent|ETag|Content-Length|Accept-Encoding): .*", "\\1: ", x)
+ }
+ )
+})
+
test_that("can promise to download files", {
req <- request_test("/json")
path <- withr::local_tempfile()
@@ -81,12 +75,6 @@ test_that("both curl and HTTP errors in promises are rejected", {
),
class = "httr2_failure"
)
- expect_error(
- extract_promise(
- req_perform_promise(request_test("/status/:status", status = 200), pool = "INVALID")
- ),
- 'inherits\\(pool, "curl_multi"\\) is not TRUE'
- )
})
test_that("req_perform_promise doesn't leave behind poller", {
diff --git a/tests/testthat/test-req-retries.R b/tests/testthat/test-req-retries.R
index c59b3dc..d2e7d5d 100644
--- a/tests/testthat/test-req-retries.R
+++ b/tests/testthat/test-req-retries.R
@@ -1,3 +1,10 @@
+test_that("has useful default (with message)", {
+ req <- request_test()
+ expect_snapshot(req <- req_retry(req))
+ expect_equal(retry_max_tries(req), 2)
+ expect_equal(retry_max_seconds(req), Inf)
+})
+
test_that("can set define maximum retries", {
req <- request_test()
expect_equal(retry_max_tries(req), 1)
@@ -70,9 +77,9 @@ test_that("validates its inputs", {
req <- new_request("http://example.com")
expect_snapshot(error = TRUE, {
- req_retry(req, max_tries = 1)
- req_retry(req, max_seconds = "x")
- req_retry(req, retry_on_failure = "x")
+ req_retry(req, max_tries = 0)
+ req_retry(req, max_tries = 2, max_seconds = "x")
+ req_retry(req, max_tries = 2, retry_on_failure = "x")
})
})
@@ -85,3 +92,33 @@ test_that("is_number_or_na implemented correctly", {
expect_equal(is_number_or_na(numeric()), FALSE)
expect_equal(is_number_or_na("x"), FALSE)
})
+
+
+# circuit breaker --------------------------------------------------------
+
+test_that("triggered after specified requests", {
+ req <- request_test("/status/:status", status = 429) %>%
+ req_retry(
+ after = function(resp) 0,
+ max_tries = 10,
+ failure_threshold = 1
+ )
+
+ # First attempt performs, retries, then errors
+ req_perform(req) %>%
+ expect_condition(class = "httr2_perform") %>%
+ expect_condition(class = "httr2_retry") %>%
+ expect_error(class = "httr2_breaker")
+
+ # Second attempt errors without performing
+ req_perform(req) %>%
+ expect_no_condition(class = "httr2_perform") %>%
+ expect_error(class = "httr2_breaker")
+
+ # Attempt on same realm errors without trying at all
+ req2 <- request_test("/status/:status", status = 200) %>%
+ req_retry()
+ req_perform(req) %>%
+ expect_no_condition(class = "httr2_perform") %>%
+ expect_error(class = "httr2_breaker")
+})
diff --git a/tests/testthat/test-req-url.R b/tests/testthat/test-req-url.R
index aa55bcd..759c33a 100644
--- a/tests/testthat/test-req-url.R
+++ b/tests/testthat/test-req-url.R
@@ -52,6 +52,17 @@ test_that("can set query params", {
expect_equal(req_url_query(req, !!!list(a = 1, a = 2))$url, "http://example.com/?a=1&a=2")
})
+test_that("can control space handling", {
+ req <- request("http://example.com/")
+ expect_equal(req_url_query(req, a = " ")$url, "http://example.com/?a=%20")
+ expect_equal(req_url_query(req, a = " ", .space = "form")$url, "http://example.com/?a=+")
+
+ expect_snapshot(
+ req_url_query(req, a = " ", .space = "bar"),
+ error = TRUE
+ )
+})
+
test_that("can handle multi query params", {
req <- request("http://example.com/")
@@ -96,6 +107,12 @@ test_that("can opt-out of query escaping", {
expect_equal(req_url_query(req, a = I(","))$url, "http://example.com/?a=,")
})
+test_that("can construct relative urls", {
+ req <- request("http://example.com/a/b/c.html")
+ expect_equal(req_url_relative(req, ".")$url, "http://example.com/a/b/")
+ expect_equal(req_url_relative(req, "..")$url, "http://example.com/a/")
+ expect_equal(req_url_relative(req, "/d/e/f")$url, "http://example.com/d/e/f")
+})
# explode -----------------------------------------------------------------
test_that("explode handles expected inputs", {
diff --git a/tests/testthat/test-req.R b/tests/testthat/test-req.R
index a445930..600d6b1 100644
--- a/tests/testthat/test-req.R
+++ b/tests/testthat/test-req.R
@@ -7,6 +7,10 @@ test_that("req has basic print method", {
})
})
+test_that("printing headers works with {}", {
+ expect_snapshot(req_headers(request("http://test"), x = "{z}", `{z}` = "x"))
+})
+
test_that("individually prints repeated headers", {
expect_snapshot(request("https://example.com") %>% req_headers(A = 1:3))
})
@@ -18,7 +22,7 @@ test_that("print method obfuscates Authorization header unless requested", {
expect_false(any(grepl("SECRET", output, fixed = TRUE)))
output <- testthat::capture_messages(print(req, redact_headers = FALSE))
- expect_true(any(grepl("Authorization: 'Basic", output, fixed = TRUE)))
+ expect_true(any(grepl("Authorization: \"Basic", output, fixed = TRUE)))
expect_false(any(grepl("REDACTED", output, fixed = TRUE)))
})
diff --git a/tests/testthat/test-resp-headers.R b/tests/testthat/test-resp-headers.R
index 52c9c42..cfc3070 100644
--- a/tests/testthat/test-resp-headers.R
+++ b/tests/testthat/test-resp-headers.R
@@ -58,3 +58,12 @@ test_that("can extract specified link url", {
expect_equal(resp_link_url(resp, "first"), NULL)
expect_equal(resp_link_url(response(), "first"), NULL)
})
+
+test_that("can extract from multiple link headers", {
+ resp <- response(headers = c(
+ 'Link: ; rel="next"',
+ 'Link: ; rel="last"'
+ ))
+ expect_equal(resp_link_url(resp, "next"), "https://example.com/1")
+ expect_equal(resp_link_url(resp, "last"), "https://example.com/2")
+})
diff --git a/tests/testthat/test-resp-request.R b/tests/testthat/test-resp-request.R
new file mode 100644
index 0000000..e1f2644
--- /dev/null
+++ b/tests/testthat/test-resp-request.R
@@ -0,0 +1,5 @@
+test_that("can extract request", {
+ req <- request_test()
+ resp <- req_perform(req)
+ expect_equal(resp_request(resp), req)
+})
diff --git a/tests/testthat/test-resp-stream.R b/tests/testthat/test-resp-stream.R
index 718b824..a476de3 100644
--- a/tests/testthat/test-resp-stream.R
+++ b/tests/testthat/test-resp-stream.R
@@ -1,4 +1,3 @@
-
test_that("can stream bytes from a connection", {
resp <- request_test("/stream-bytes/2048") %>% req_perform_connection()
withr::defer(close(resp))
@@ -16,6 +15,39 @@ test_that("can stream bytes from a connection", {
expect_length(out, 0)
})
+test_that("can determine if a stream is complete (blocking)", {
+ resp <- request_test("/stream-bytes/2048") %>% req_perform_connection()
+ withr::defer(close(resp))
+
+ expect_false(resp_stream_is_complete(resp))
+ expect_length(resp_stream_raw(resp, kb = 2), 2048)
+ expect_length(resp_stream_raw(resp, kb = 1), 0)
+ expect_true(resp_stream_is_complete(resp))
+})
+
+test_that("can determine if a stream is complete (non-blocking)", {
+ resp <- request_test("/stream-bytes/2048") %>% req_perform_connection(blocking = FALSE)
+ withr::defer(close(resp))
+
+ expect_false(resp_stream_is_complete(resp))
+ expect_length(resp_stream_raw(resp, kb = 2), 2048)
+ expect_length(resp_stream_raw(resp, kb = 1), 0)
+ expect_true(resp_stream_is_complete(resp))
+})
+
+test_that("can determine if incomplete data is complete", {
+ req <- local_app_request(function(req, res) {
+ res$send_chunk("data: 1\n\n")
+ res$send_chunk("data: ")
+ })
+
+ con <- req %>% req_perform_connection(blocking = TRUE)
+ expect_equal(resp_stream_sse(con, 10), list(type = "message", data = "1", id = character()))
+ expect_snapshot(expect_equal(resp_stream_sse(con), NULL))
+ expect_true(resp_stream_is_complete(con))
+ close(con)
+})
+
test_that("can't read from a closed connection", {
resp <- request_test("/stream-bytes/1024") %>% req_perform_connection()
close(resp)
@@ -42,7 +74,7 @@ test_that("can join lines across multiple reads", {
expect_equal(out, character())
expect_equal(resp1$cache$push_back, charToRaw("This is a "))
- while(length(out) == 0) {
+ while (length(out) == 0) {
Sys.sleep(0.1)
out <- resp_stream_lines(resp1)
}
@@ -147,7 +179,7 @@ test_that("streams the specified number of lines", {
test_that("can feed sse events one at a time", {
req <- local_app_request(function(req, res) {
- for(i in 1:3) {
+ for (i in 1:3) {
res$send_chunk(sprintf("data: %s\n\n", i))
}
})
@@ -185,7 +217,7 @@ test_that("can join sse events across multiple reads", {
expect_equal(out, NULL)
expect_equal(resp1$cache$push_back, charToRaw("data: 1\n"))
- while(is.null(out)) {
+ while (is.null(out)) {
Sys.sleep(0.1)
out <- resp_stream_sse(resp1)
}
@@ -213,7 +245,7 @@ test_that("sse always interprets data as UTF-8", {
withr::defer(close(resp1))
out <- NULL
- while(is.null(out)) {
+ while (is.null(out)) {
Sys.sleep(0.1)
out <- resp_stream_sse(resp1)
}
@@ -236,7 +268,7 @@ test_that("streaming size limits enforced", {
resp1 <- req_perform_connection(req, blocking = FALSE)
withr::defer(close(resp1))
expect_error(
- while(is.null(out)) {
+ while (is.null(out)) {
Sys.sleep(0.1)
out <- resp_stream_sse(resp1, max_size = 999)
}
@@ -255,6 +287,47 @@ test_that("streaming size limits enforced", {
)
})
+test_that("verbosity = 2 streams request bodies", {
+ req <- local_app_request(function(req, res) {
+ res$send_chunk("line 1\n")
+ res$send_chunk("line 2\n")
+ })
+
+ stream_all <- function(req, fun, ...) {
+ con <- req_perform_connection(req, blocking = TRUE, verbosity = 2)
+ on.exit(close(con))
+ while (!resp_stream_is_complete(con)) {
+ fun(con, ...)
+ }
+ }
+ expect_snapshot(
+ {
+ stream_all(req, resp_stream_lines, 1)
+ stream_all(req, resp_stream_raw, 5 / 1024)
+ },
+ transform = function(lines) lines[!grepl("^(<-|->)", lines)]
+ )
+})
+
+test_that("verbosity = 3 shows buffer info", {
+ req <- local_app_request(function(req, res) {
+ res$send_chunk("line 1\n")
+ res$send_chunk("line 2\n")
+ })
+
+ con <- req_perform_connection(req, blocking = TRUE, verbosity = 3)
+ on.exit(close(con))
+ expect_snapshot(
+ {
+ while (!resp_stream_is_complete(con)) {
+ resp_stream_lines(con, 1)
+ }
+ },
+ transform = function(lines) lines[!grepl("^(<-|->)", lines)]
+ )
+})
+
+
test_that("has a working find_event_boundary", {
boundary_test <- function(x, matched, remaining) {
buffer <- charToRaw(x)
@@ -266,7 +339,7 @@ test_that("has a working find_event_boundary", {
}
expect_identical(
result,
- list(matched=charToRaw(matched), remaining = charToRaw(remaining))
+ list(matched = charToRaw(matched), remaining = charToRaw(remaining))
)
}
diff --git a/tests/testthat/test-url.R b/tests/testthat/test-url.R
index 9dc3de0..153309d 100644
--- a/tests/testthat/test-url.R
+++ b/tests/testthat/test-url.R
@@ -1,22 +1,11 @@
test_that("can parse special cases", {
- url <- url_parse("//google.com")
- expect_equal(url$scheme, NULL)
- expect_equal(url$hostname, "google.com")
-
url <- url_parse("file:///tmp")
expect_equal(url$scheme, "file")
expect_equal(url$path, "/tmp")
-
- url <- url_parse("/")
- expect_equal(url$scheme, NULL)
- expect_equal(url$path, "/")
})
test_that("can round trip urls", {
urls <- list(
- "/",
- "//google.com",
- "file:///",
"http://google.com/",
"http://google.com/path",
"http://google.com/path?a=1&b=2",
@@ -31,47 +20,191 @@ test_that("can round trip urls", {
expect_equal(map(urls, ~ url_build(url_parse(.x))), urls)
})
+test_that("can parse relative urls", {
+ base <- "http://example.com/a/b/c/"
+ expect_equal(url_parse("d", base)$path, "/a/b/c/d")
+ expect_equal(url_parse("..", base)$path, "/a/b/")
+
+ expect_equal(url_parse("//archive.org", base)$scheme, "http")
+})
+
test_that("can print all url details", {
expect_snapshot(
url_parse("http://user:pass@example.com:80/path?a=1&b=2&c={1{2}3}#frag")
)
})
-test_that("ensures path always starts with /", {
- expect_equal(
- url_modify("https://example.com/abc", path = "def"),
- "https://example.com/def"
- )
-})
-
test_that("password also requires username", {
url <- url_parse("http://username:pwd@example.com")
url$username <- NULL
expect_snapshot(url_build(url), error = TRUE)
+})
+test_that("url_build validates its input", {
+ expect_snapshot(url_build("abc"), error = TRUE)
+})
+
+test_that("decodes query params but not paths", {
+ url <- url_parse("http://example.com/a%20b?q=a%20b")
+ expect_equal(url$path, "/a%20b")
+ expect_equal(url$query$q, "a b")
+})
+
+# modify url -------------------------------------------------------------
+
+test_that("url_modify checks its inputs", {
+ url <- "http://example.com"
+
+ expect_snapshot(error = TRUE, {
+ url_modify(1)
+ url_modify(url, scheme = 1)
+ url_modify(url, hostname = 1)
+ url_modify(url, port = "x")
+ url_modify(url, username = 1)
+ url_modify(url, password = 1)
+ url_modify(url, path = 1)
+ url_modify(url, fragment = 1)
+ })
+})
+
+test_that("no arguments is idempotent", {
+ string <- "http://example.com/"
+ url <- url_parse(string)
+
+ expect_equal(url_modify(string), string)
+ expect_equal(url_modify(url), url)
+})
+
+test_that("can round-trip escaped components", {
+ url <- "https://example.com/a%20b"
+ expect_equal(url_modify(url), url)
+
+ url <- "https://example.com/?q=a%20b"
+ expect_equal(url_modify(url), url)
+})
+
+test_that("can accept query as a string or list", {
+ url <- "http://test/"
+
+ expect_equal(url_modify(url, query = "a=1&b=2"), "http://test/?a=1&b=2")
+ expect_equal(url_modify(url, query = list(a = 1, b = 2)), "http://test/?a=1&b=2")
+
+ expect_equal(url_modify(url, query = ""), "http://test/")
+ expect_equal(url_modify(url, query = list()), "http://test/")
+})
+
+test_that("automatically escapes query components", {
+ expect_equal(
+ url_modify("https://example.com", query = list(q = "a b")),
+ "https://example.com/?q=a%20b"
+ )
+})
+
+test_that("checks various query formats", {
+ url <- "http://example.com"
+
+ expect_snapshot(error = TRUE, {
+ url_modify(url, query = 1)
+ url_modify(url, query = list(1))
+ url_modify(url, query = list(x = 1:2))
+ })
})
+test_that("path always starts with /", {
+ expect_equal(url_modify("https://x.com/abc", path = "def"), "https://x.com/def")
+ expect_equal(url_modify("https://x.com/abc", path = ""), "https://x.com/")
+ expect_equal(url_modify("https://x.com/abc", path = NULL), "https://x.com/")
+})
+
+# relative url ------------------------------------------------------------
+
+test_that("can set relative urls", {
+ base <- "http://example.com/a/b/c/"
+ expect_equal(url_modify_relative(base, "d"), "http://example.com/a/b/c/d")
+ expect_equal(url_modify_relative(base, ".."), "http://example.com/a/b/")
+ expect_equal(url_modify_relative(base, "//archive.org"), "http://archive.org/")
+})
+
+test_that("is idempotent", {
+ string <- "http://example.com/"
+ url <- url_parse(string)
+
+ expect_equal(url_modify_relative(string, "."), string)
+ expect_equal(url_modify_relative(url, "."), url)
+})
+
+# modify query -------------------------------------------------------------
+
+test_that("can add, modify, and delete query components", {
+ expect_equal(
+ url_modify_query("http://test/path", new = "value"),
+ "http://test/path?new=value"
+ )
+ expect_equal(
+ url_modify_query("http://test/path", new = "one", new = "two"),
+ "http://test/path?new=one&new=two"
+ )
+ expect_equal(
+ url_modify_query("http://test/path?a=old&b=old", a = "new"),
+ "http://test/path?b=old&a=new"
+ )
+ expect_equal(
+ url_modify_query("http://test/path?remove=me&keep=this", remove = NULL),
+ "http://test/path?keep=this"
+ )
+})
+
+test_that("can control space formatting", {
+ expect_equal(
+ url_modify_query("http://test/path", new = "a b"),
+ "http://test/path?new=a%20b"
+ )
+ expect_equal(
+ url_modify_query("http://test/path", new = "a b", .space = "form"),
+ "http://test/path?new=a+b"
+ )
+})
+
+test_that("is idempotent", {
+ string <- "http://example.com/"
+ url <- url_parse(string)
+
+ expect_equal(url_modify_query(string), string)
+ expect_equal(url_modify_query(url), url)
+})
+
+test_that("validates inputs", {
+ url <- "http://example.com/"
+
+ expect_snapshot(error = TRUE, {
+ url_modify_query(1)
+ url_modify_query(url, 1)
+ url_modify_query(url, x = 1:2)
+ })
+})
+
+
# query -------------------------------------------------------------------
test_that("missing query values become empty strings", {
- expect_equal(query_parse("?q="), list(q = ""))
- expect_equal(query_parse("?q"), list(q = ""))
- expect_equal(query_parse("?a&q"), list(a = "", q = ""))
+ expect_equal(url_query_parse("?q="), list(q = ""))
+ expect_equal(url_query_parse("?q"), list(q = ""))
+ expect_equal(url_query_parse("?a&q"), list(a = "", q = ""))
})
test_that("handles equals in values", {
- expect_equal(query_parse("?x==&y=="), list(x = "=", y = "="))
- })
+ expect_equal(url_query_parse("?x==&y=="), list(x = "=", y = "="))
+})
test_that("empty queries become NULL", {
- expect_equal(query_parse("?"), NULL)
- expect_equal(query_parse(""), NULL)
+ expect_equal(url_query_parse("?"), NULL)
+ expect_equal(url_query_parse(""), NULL)
})
test_that("validates inputs", {
expect_snapshot(error = TRUE, {
- query_build(1:3)
- query_build(list(x = 1:2, y = 1:3))
+ url_query_build(1:3)
+ url_query_build(list(x = 1:2, y = 1:3))
})
})