Skip to content

Commit 88e2cbb

Browse files
committed
feat(swift): add GITHUB_TOKEN support for authenticated API requests
Add get_cached_with_headers to HttpCache for passing extra headers (Authorization) to specific registries. SwiftRegistry reads GITHUB_TOKEN from environment, increasing rate limit from 60 to 5000 requests/hour.
1 parent 4d0ad80 commit 88e2cbb

File tree

4 files changed

+185
-4
lines changed

4 files changed

+185
-4
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/deps-core/src/cache.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,36 @@ impl HttpCache {
172172
self.fetch_and_store(url).await
173173
}
174174

175+
/// Fetches a URL with additional request headers, using the cache.
176+
///
177+
/// Works the same as `get_cached` but injects extra headers (e.g., Authorization)
178+
/// into every request. Useful for APIs that require authentication tokens.
179+
pub async fn get_cached_with_headers(
180+
&self,
181+
url: &str,
182+
extra_headers: &[(header::HeaderName, &str)],
183+
) -> Result<Bytes> {
184+
if self.entries.len() >= MAX_CACHE_ENTRIES {
185+
self.evict_entries();
186+
}
187+
188+
if let Some(cached) = self.entries.get(url).map(|r| r.clone()) {
189+
match self
190+
.conditional_request_with_headers(url, &cached, extra_headers)
191+
.await
192+
{
193+
Ok(Some(new_body)) => return Ok(new_body),
194+
Ok(None) => return Ok(cached.body),
195+
Err(e) => {
196+
tracing::warn!("conditional request failed, using cache: {e}");
197+
return Ok(cached.body);
198+
}
199+
}
200+
}
201+
202+
self.fetch_and_store_with_headers(url, extra_headers).await
203+
}
204+
175205
/// Performs conditional HTTP request using cached validation headers.
176206
///
177207
/// Sends `If-None-Match` (ETag) and/or `If-Modified-Since` headers
@@ -304,6 +334,124 @@ impl HttpCache {
304334
Ok(body)
305335
}
306336

337+
async fn conditional_request_with_headers(
338+
&self,
339+
url: &str,
340+
cached: &CachedResponse,
341+
extra_headers: &[(header::HeaderName, &str)],
342+
) -> Result<Option<Bytes>> {
343+
ensure_https(url)?;
344+
let mut request = self.client.get(url);
345+
346+
for (name, value) in extra_headers {
347+
request = request.header(name, *value);
348+
}
349+
if let Some(etag) = &cached.etag {
350+
request = request.header(header::IF_NONE_MATCH, etag);
351+
}
352+
if let Some(last_modified) = &cached.last_modified {
353+
request = request.header(header::IF_MODIFIED_SINCE, last_modified);
354+
}
355+
356+
let response = request.send().await.map_err(|e| DepsError::RegistryError {
357+
package: url.to_string(),
358+
source: e,
359+
})?;
360+
361+
if response.status() == StatusCode::NOT_MODIFIED {
362+
return Ok(None);
363+
}
364+
365+
if !response.status().is_success() {
366+
let status = response.status();
367+
return Err(DepsError::CacheError(format!("HTTP {status} for {url}")));
368+
}
369+
370+
let etag = response
371+
.headers()
372+
.get(header::ETAG)
373+
.and_then(|v| v.to_str().ok())
374+
.map(String::from);
375+
let last_modified = response
376+
.headers()
377+
.get(header::LAST_MODIFIED)
378+
.and_then(|v| v.to_str().ok())
379+
.map(String::from);
380+
let body = response
381+
.bytes()
382+
.await
383+
.map_err(|e| DepsError::RegistryError {
384+
package: url.to_string(),
385+
source: e,
386+
})?;
387+
388+
self.entries.insert(
389+
url.to_string(),
390+
CachedResponse {
391+
body: body.clone(),
392+
etag,
393+
last_modified,
394+
fetched_at: Instant::now(),
395+
},
396+
);
397+
398+
Ok(Some(body))
399+
}
400+
401+
async fn fetch_and_store_with_headers(
402+
&self,
403+
url: &str,
404+
extra_headers: &[(header::HeaderName, &str)],
405+
) -> Result<Bytes> {
406+
ensure_https(url)?;
407+
tracing::debug!("fetching fresh with headers: {url}");
408+
409+
let mut request = self.client.get(url);
410+
for (name, value) in extra_headers {
411+
request = request.header(name, *value);
412+
}
413+
414+
let response = request.send().await.map_err(|e| DepsError::RegistryError {
415+
package: url.to_string(),
416+
source: e,
417+
})?;
418+
419+
if !response.status().is_success() {
420+
let status = response.status();
421+
return Err(DepsError::CacheError(format!("HTTP {status} for {url}")));
422+
}
423+
424+
let etag = response
425+
.headers()
426+
.get(header::ETAG)
427+
.and_then(|v| v.to_str().ok())
428+
.map(String::from);
429+
let last_modified = response
430+
.headers()
431+
.get(header::LAST_MODIFIED)
432+
.and_then(|v| v.to_str().ok())
433+
.map(String::from);
434+
let body = response
435+
.bytes()
436+
.await
437+
.map_err(|e| DepsError::RegistryError {
438+
package: url.to_string(),
439+
source: e,
440+
})?;
441+
442+
self.entries.insert(
443+
url.to_string(),
444+
CachedResponse {
445+
body: body.clone(),
446+
etag,
447+
last_modified,
448+
fetched_at: Instant::now(),
449+
},
450+
);
451+
452+
Ok(body)
453+
}
454+
307455
/// Clears all cached entries.
308456
///
309457
/// This removes all cached responses, forcing the next request for

crates/deps-swift/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ workspace = true
1313

1414
[dependencies]
1515
deps-core = { workspace = true }
16+
reqwest = { workspace = true }
1617
regex = { workspace = true }
1718
semver = { workspace = true }
1819
serde = { workspace = true, features = ["derive"] }

crates/deps-swift/src/registry.rs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,37 @@ impl std::error::Error for InvalidOwnerRepo {}
4242
#[derive(Clone)]
4343
pub struct SwiftRegistry {
4444
cache: Arc<HttpCache>,
45+
auth_headers: Vec<(reqwest::header::HeaderName, String)>,
4546
}
4647

4748
impl SwiftRegistry {
4849
/// Creates a new Swift registry client with the given HTTP cache.
49-
pub const fn new(cache: Arc<HttpCache>) -> Self {
50-
Self { cache }
50+
///
51+
/// Reads `GITHUB_TOKEN` from environment for authenticated requests
52+
/// (5000 req/h vs 60 req/h unauthenticated).
53+
pub fn new(cache: Arc<HttpCache>) -> Self {
54+
let auth_headers = std::env::var("GITHUB_TOKEN")
55+
.ok()
56+
.map(|token| {
57+
tracing::info!("GITHUB_TOKEN detected, using authenticated GitHub API requests");
58+
vec![(
59+
reqwest::header::AUTHORIZATION,
60+
format!("Bearer {token}"),
61+
)]
62+
})
63+
.unwrap_or_default();
64+
65+
Self {
66+
cache,
67+
auth_headers,
68+
}
69+
}
70+
71+
fn headers(&self) -> Vec<(reqwest::header::HeaderName, &str)> {
72+
self.auth_headers
73+
.iter()
74+
.map(|(k, v): &(reqwest::header::HeaderName, String)| (k.clone(), v.as_str()))
75+
.collect()
5176
}
5277

5378
/// Fetches all semver-tagged versions for a package.
@@ -56,7 +81,10 @@ impl SwiftRegistry {
5681
pub async fn get_versions(&self, name: &str) -> Result<Vec<SwiftVersion>> {
5782
validate_owner_repo(name)?;
5883
let url = format!("{GITHUB_API}/repos/{name}/tags?per_page=100");
59-
let data = self.cache.get_cached(&url).await?;
84+
let data = self
85+
.cache
86+
.get_cached_with_headers(&url, &self.headers())
87+
.await?;
6088
parse_tags_response(&data)
6189
}
6290

@@ -90,7 +118,10 @@ impl SwiftRegistry {
90118
"{GITHUB_API}/search/repositories?q={}+language:swift&per_page={limit}",
91119
urlencoding::encode(query)
92120
);
93-
let data = self.cache.get_cached(&url).await?;
121+
let data = self
122+
.cache
123+
.get_cached_with_headers(&url, &self.headers())
124+
.await?;
94125
parse_search_response(&data)
95126
}
96127
}

0 commit comments

Comments
 (0)