Skip to content

Commit 5bd355d

Browse files
authored
Merge pull request #134 from Amoifr/1.x
Authenticate the GitHub API call to avoid rate limiting
2 parents 68417a5 + 28a31d4 commit 5bd355d

2 files changed

Lines changed: 202 additions & 10 deletions

File tree

src/TailwindVersionFinder.php

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
namespace Symfonycasts\TailwindBundle;
1111

1212
use Symfony\Component\HttpClient\HttpClient;
13+
use Symfony\Component\HttpClient\ScopingHttpClient;
1314
use Symfony\Contracts\HttpClient\HttpClientInterface;
1415

1516
/**
@@ -25,7 +26,18 @@ final class TailwindVersionFinder
2526

2627
public function __construct(?HttpClientInterface $httpClient = null)
2728
{
28-
$this->httpClient = $httpClient ?? HttpClient::create();
29+
$httpClient ??= HttpClient::create();
30+
31+
// authenticate calls when a GitHub token is available to avoid the low
32+
// rate limit applied to anonymous requests (60 requests per hour,
33+
// shared by IP address)
34+
if (null !== $token = self::githubToken()) {
35+
$httpClient = ScopingHttpClient::forBaseUri($httpClient, 'https://api.github.com/', [
36+
'auth_bearer' => $token,
37+
]);
38+
}
39+
40+
$this->httpClient = $httpClient;
2941
}
3042

3143
/**
@@ -71,4 +83,69 @@ private function tags(int $page = 1): iterable
7183

7284
yield from $this->tags(++$page);
7385
}
86+
87+
/**
88+
* Looks for a GitHub token, first in the usual environment variables, then
89+
* in Composer's authentication config (the "github-oauth" token developers
90+
* commonly already have set up).
91+
*/
92+
private static function githubToken(): ?string
93+
{
94+
foreach (['GITHUB_TOKEN', 'GH_TOKEN'] as $name) {
95+
if (null !== $token = self::readEnv($name)) {
96+
return $token;
97+
}
98+
}
99+
100+
return self::composerGithubToken();
101+
}
102+
103+
private static function composerGithubToken(): ?string
104+
{
105+
$candidates = [];
106+
107+
if (null !== $composerAuth = self::readEnv('COMPOSER_AUTH')) {
108+
$candidates[] = $composerAuth;
109+
}
110+
111+
foreach (self::composerAuthFiles() as $file) {
112+
if (is_file($file) && false !== $contents = @file_get_contents($file)) {
113+
$candidates[] = $contents;
114+
}
115+
}
116+
117+
foreach ($candidates as $json) {
118+
$data = json_decode($json, true);
119+
$token = $data['github-oauth']['github.com'] ?? null;
120+
121+
if (\is_string($token) && '' !== $token) {
122+
return $token;
123+
}
124+
}
125+
126+
return null;
127+
}
128+
129+
/**
130+
* @return string[]
131+
*/
132+
private static function composerAuthFiles(): array
133+
{
134+
if (null !== $composerHome = self::readEnv('COMPOSER_HOME')) {
135+
return [$composerHome.'/auth.json'];
136+
}
137+
138+
if (null !== $home = self::readEnv('HOME')) {
139+
return [$home.'/.composer/auth.json', $home.'/.config/composer/auth.json'];
140+
}
141+
142+
return [];
143+
}
144+
145+
private static function readEnv(string $name): ?string
146+
{
147+
$value = $_SERVER[$name] ?? $_ENV[$name] ?? getenv($name);
148+
149+
return \is_string($value) && '' !== $value ? $value : null;
150+
}
74151
}

tests/TailwindVersionFinderTest.php

Lines changed: 124 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,53 @@
1010
namespace Symfonycasts\TailwindBundle\Tests;
1111

1212
use PHPUnit\Framework\TestCase;
13-
use Symfony\Component\HttpClient\HttpClient;
13+
use Symfony\Component\HttpClient\MockHttpClient;
14+
use Symfony\Component\HttpClient\Response\MockResponse;
1415
use Symfonycasts\TailwindBundle\TailwindVersionFinder;
1516

1617
class TailwindVersionFinderTest extends TestCase
1718
{
19+
private const TOKEN_ENV_VARS = ['GITHUB_TOKEN', 'GH_TOKEN', 'COMPOSER_AUTH', 'COMPOSER_HOME', 'HOME'];
20+
21+
/** @var array<string, array{0: mixed, 1: mixed, 2: string|false}> */
22+
private array $envBackup = [];
23+
24+
protected function setUp(): void
25+
{
26+
foreach (self::TOKEN_ENV_VARS as $name) {
27+
$this->envBackup[$name] = [$_SERVER[$name] ?? null, $_ENV[$name] ?? null, getenv($name)];
28+
}
29+
}
30+
31+
protected function tearDown(): void
32+
{
33+
foreach ($this->envBackup as $name => [$server, $env, $getenv]) {
34+
if (null !== $server) {
35+
$_SERVER[$name] = $server;
36+
} else {
37+
unset($_SERVER[$name]);
38+
}
39+
40+
if (null !== $env) {
41+
$_ENV[$name] = $env;
42+
} else {
43+
unset($_ENV[$name]);
44+
}
45+
46+
if (false !== $getenv) {
47+
putenv("$name=$getenv");
48+
} else {
49+
putenv($name);
50+
}
51+
}
52+
}
53+
1854
/**
1955
* @dataProvider versionProvider
2056
*/
2157
public function testGetLatestVersion(string $version, int $expectedMajor): void
2258
{
23-
$options = [];
24-
25-
if ($_SERVER['GITHUB_TOKEN'] ?? null) {
26-
$options['auth_bearer'] = $_SERVER['GITHUB_TOKEN'];
27-
}
28-
29-
$versionDetector = new TailwindVersionFinder(HttpClient::create($options));
30-
$latestVersion = $versionDetector->latestVersionFor($version);
59+
$latestVersion = (new TailwindVersionFinder())->latestVersionFor($version);
3160

3261
$this->assertStringStartsWith('v'.$expectedMajor.'.', $latestVersion);
3362
}
@@ -49,4 +78,90 @@ public function testThrowsOnUnparsableVersion(): void
4978

5079
(new TailwindVersionFinder())->latestVersionFor('nope');
5180
}
81+
82+
public function testNoAuthorizationHeaderWithoutToken(): void
83+
{
84+
$this->clearTokenEnv();
85+
86+
$this->assertNull($this->captureAuthorizationHeader());
87+
}
88+
89+
public function testUsesGitHubTokenEnvVar(): void
90+
{
91+
$this->clearTokenEnv();
92+
$_SERVER['GITHUB_TOKEN'] = 'secret-token';
93+
94+
$this->assertSame('Bearer secret-token', $this->captureAuthorizationHeader());
95+
}
96+
97+
public function testUsesGhTokenEnvVar(): void
98+
{
99+
$this->clearTokenEnv();
100+
$_SERVER['GH_TOKEN'] = 'gh-secret';
101+
102+
$this->assertSame('Bearer gh-secret', $this->captureAuthorizationHeader());
103+
}
104+
105+
public function testUsesComposerAuthEnvVar(): void
106+
{
107+
$this->clearTokenEnv();
108+
$_SERVER['COMPOSER_AUTH'] = json_encode(['github-oauth' => ['github.com' => 'composer-env-token']]);
109+
110+
$this->assertSame('Bearer composer-env-token', $this->captureAuthorizationHeader());
111+
}
112+
113+
public function testUsesComposerAuthJsonFile(): void
114+
{
115+
$this->clearTokenEnv();
116+
117+
$composerHome = sys_get_temp_dir().'/tailwind-token-test-'.uniqid();
118+
mkdir($composerHome, 0777, true);
119+
file_put_contents(
120+
$composerHome.'/auth.json',
121+
json_encode(['github-oauth' => ['github.com' => 'composer-file-token']])
122+
);
123+
$_SERVER['COMPOSER_HOME'] = $composerHome;
124+
125+
try {
126+
$this->assertSame('Bearer composer-file-token', $this->captureAuthorizationHeader());
127+
} finally {
128+
unlink($composerHome.'/auth.json');
129+
rmdir($composerHome);
130+
}
131+
}
132+
133+
/**
134+
* Removes every token source from the environment so a test starts from a
135+
* known, token-less state (CI sets GITHUB_TOKEN).
136+
*/
137+
private function clearTokenEnv(): void
138+
{
139+
foreach (self::TOKEN_ENV_VARS as $name) {
140+
unset($_SERVER[$name], $_ENV[$name]);
141+
putenv($name);
142+
}
143+
}
144+
145+
/**
146+
* Runs latestVersionFor() against a mocked GitHub API and returns the
147+
* Authorization header that was sent (or null if none).
148+
*/
149+
private function captureAuthorizationHeader(): ?string
150+
{
151+
$sentAuthorization = null;
152+
153+
$client = new MockHttpClient(static function (string $method, string $url, array $options) use (&$sentAuthorization): MockResponse {
154+
foreach ($options['headers'] ?? [] as $header) {
155+
if (str_starts_with($header, 'Authorization: ')) {
156+
$sentAuthorization = substr($header, \strlen('Authorization: '));
157+
}
158+
}
159+
160+
return new MockResponse(json_encode([['tag_name' => 'v4.0.0']]));
161+
});
162+
163+
(new TailwindVersionFinder($client))->latestVersionFor('4');
164+
165+
return $sentAuthorization;
166+
}
52167
}

0 commit comments

Comments
 (0)