├── src ├── Exceptions │ ├── AuthenticationException.php │ └── RedditApiException.php ├── Http │ ├── SleeperInterface.php │ ├── NoopSleeper.php │ ├── RateLimitInfo.php │ ├── AutoRefreshingClient.php │ └── RedditApiClient.php ├── Enum │ ├── VoteDirection.php │ ├── Sort.php │ └── TimeWindow.php ├── Data │ ├── User.php │ ├── Comment.php │ ├── Subreddit.php │ ├── Link.php │ └── Listing.php ├── Value │ ├── Username.php │ ├── SubredditName.php │ └── Fullname.php ├── Auth │ ├── Token.php │ ├── TokenStorageInterface.php │ ├── InMemoryTokenStorage.php │ ├── TokenRefresher.php │ ├── PdoSqliteTokenStorage.php │ └── Auth.php ├── Resources │ ├── Moderation.php │ ├── Me.php │ ├── Flair.php │ ├── Subreddit.php │ ├── PrivateMessages.php │ ├── Links.php │ ├── Search.php │ └── User.php └── Config │ └── Config.php ├── phpstan.neon ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── pull_request_template.md └── workflows │ └── ci.yml ├── SECURITY.md ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── phpunit.xml.dist ├── tests └── Unit │ ├── FlairResourceTest.php │ ├── ModerationResourceTest.php │ ├── RateLimitExposureTest.php │ ├── UserResourceTest.php │ ├── MeResourceTest.php │ ├── SubredditResourceTest.php │ ├── PrivateMessagesResourceTest.php │ ├── ClientInstantiationTest.php │ ├── AuthAppOnlyTest.php │ ├── RetryAndRateLimitTest.php │ ├── SearchResourceTest.php │ ├── InMemoryTokenStorageTest.php │ ├── PdoSqliteTokenStorageTest.php │ ├── LinksResourceTest.php │ ├── UserHistoryResourceTest.php │ ├── AuthAuthorizationCodeTest.php │ └── AutoRefreshingClientTest.php ├── LICENSE ├── examples ├── me.php └── app_only_search.php ├── composer.json ├── phpstan-baseline.neon └── README.md /src/Exceptions/AuthenticationException.php: -------------------------------------------------------------------------------- 1 | in(__DIR__ . '/src') 5 | ->in(__DIR__ . '/tests'); 6 | 7 | return (new PhpCsFixer\Config()) 8 | ->setRiskyAllowed(true) 9 | ->setRules([ 10 | '@PSR12' => true, 11 | 'array_syntax' => ['syntax' => 'short'], 12 | 'strict_param' => true, 13 | ]) 14 | ->setFinder($finder); 15 | 16 | -------------------------------------------------------------------------------- /src/Data/User.php: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests/Unit 10 | 11 | 12 | 13 | 14 | src 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Value/Username.php: -------------------------------------------------------------------------------- 1 | value; 19 | } 20 | 21 | public function __toString(): string 22 | { 23 | return $this->value; 24 | } 25 | } 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Value/SubredditName.php: -------------------------------------------------------------------------------- 1 | value; 19 | } 20 | 21 | public function __toString(): string 22 | { 23 | return $this->value; 24 | } 25 | } 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Auth/Token.php: -------------------------------------------------------------------------------- 1 | $scopes 11 | */ 12 | public function __construct( 13 | public readonly string $providerUserId, 14 | public readonly string $accessToken, 15 | public readonly ?string $refreshToken, 16 | public readonly int $expiresAtEpoch, 17 | public readonly array $scopes = [], 18 | public readonly ?string $ownerUserId = null, 19 | public readonly ?string $ownerTenantId = null, 20 | ) { 21 | } 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/Value/Fullname.php: -------------------------------------------------------------------------------- 1 | value; 19 | } 20 | 21 | public function __toString(): string 22 | { 23 | return $this->value; 24 | } 25 | } 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Auth/TokenStorageInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function allForOwner(?string $ownerUserId, ?string $ownerTenantId): array; 20 | 21 | public function deleteByOwnerAndProviderUserId(?string $ownerUserId, ?string $ownerTenantId, string $providerUserId): void; 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/Exceptions/RedditApiException.php: -------------------------------------------------------------------------------- 1 | statusCode; 23 | } 24 | 25 | public function getResponseBody(): ?string 26 | { 27 | return $this->responseBody; 28 | } 29 | } -------------------------------------------------------------------------------- /src/Resources/Moderation.php: -------------------------------------------------------------------------------- 1 | client->request('POST', '/api/approve', form: [ 18 | 'id' => $fullname, 19 | ]); 20 | } 21 | 22 | public function remove(string $fullname, bool $spam = false): void 23 | { 24 | $this->client->request('POST', '/api/remove', form: [ 25 | 'id' => $fullname, 26 | 'spam' => $spam ? 'true' : 'false', 27 | ]); 28 | } 29 | } 30 | 31 | 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | php: [ '8.1', '8.2', '8.3' ] 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: ${{ matrix.php }} 25 | coverage: none 26 | tools: composer 27 | 28 | - name: Install dependencies 29 | run: composer install --no-interaction --prefer-dist --no-progress 30 | 31 | - name: Run tests 32 | run: composer test 33 | 34 | - name: Run static analysis 35 | run: composer phpstan 36 | 37 | -------------------------------------------------------------------------------- /src/Resources/Me.php: -------------------------------------------------------------------------------- 1 | client->request('GET', '/api/v1/me'); 19 | $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); 20 | 21 | return new User( 22 | id: (string) ($data['id'] ?? ''), 23 | name: (string) ($data['name'] ?? ''), 24 | isEmployee: (bool) ($data['is_employee'] ?? false), 25 | isMod: (bool) ($data['is_mod'] ?? false), 26 | createdUtc: (float) ($data['created_utc'] ?? 0), 27 | ); 28 | } 29 | } -------------------------------------------------------------------------------- /src/Resources/Flair.php: -------------------------------------------------------------------------------- 1 | > 19 | */ 20 | public function get(string $subredditName): array 21 | { 22 | // Note: Flair endpoints vary; using /r/{subreddit}/api/flairselector.json as a placeholder 23 | $json = $this->client->request('GET', "/r/{$subredditName}/api/flairselector.json"); 24 | $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); 25 | // Return raw decoded for now (DTOs can be added later) 26 | return is_array($decoded) ? $decoded : []; 27 | } 28 | } 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/Unit/FlairResourceTest.php: -------------------------------------------------------------------------------- 1 | withToken('tkn'); 22 | 23 | $http->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode(['current' => [], 'choices' => []], JSON_THROW_ON_ERROR))); 24 | 25 | $resp = (new \Avansaber\RedditApi\Resources\Flair($client))->get('php'); 26 | $this->assertIsArray($resp); 27 | $this->assertArrayHasKey('choices', $resp); 28 | } 29 | } 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Resources/Subreddit.php: -------------------------------------------------------------------------------- 1 | client->request('GET', "/r/{$subredditName}/about.json"); 19 | $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); 20 | $d = $decoded['data'] ?? []; 21 | 22 | return new SubredditDTO( 23 | id: (string) ($d['id'] ?? ''), 24 | name: (string) ($d['display_name'] ?? ''), 25 | title: (string) ($d['title'] ?? ''), 26 | publicDescription: (string) ($d['public_description'] ?? ''), 27 | subscribers: (int) ($d['subscribers'] ?? 0), 28 | over18: (bool) ($d['over18'] ?? false), 29 | url: (string) ($d['url'] ?? ''), 30 | ); 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Avansaber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /examples/me.php: -------------------------------------------------------------------------------- 1 | withToken($accessToken); 25 | 26 | $me = $api->me()->get(); 27 | echo json_encode([ 28 | 'id' => $me->id, 29 | 'name' => $me->name, 30 | 'isEmployee' => $me->isEmployee, 31 | 'isMod' => $me->isMod, 32 | 'createdUtc' => $me->createdUtc, 33 | ], JSON_PRETTY_PRINT) . "\n"; 34 | 35 | -------------------------------------------------------------------------------- /src/Data/Listing.php: -------------------------------------------------------------------------------- 1 | $items 16 | */ 17 | public function __construct( 18 | public readonly array $items, 19 | public readonly ?string $after, 20 | public readonly ?string $before, 21 | ) { 22 | } 23 | 24 | /** 25 | * Simple generator to iterate pages using a provided page fetcher. 26 | * 27 | * @param callable(?string $after): Listing $fetchNext 28 | * @return \Generator 29 | */ 30 | public function iterate(callable $fetchNext): \Generator 31 | { 32 | $current = $this; 33 | while (true) { 34 | foreach ($current->items as $item) { 35 | yield $item; 36 | } 37 | if ($current->after === null) { 38 | break; 39 | } 40 | $current = $fetchNext($current->after); 41 | } 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /tests/Unit/ModerationResourceTest.php: -------------------------------------------------------------------------------- 1 | withToken('tkn'); 22 | 23 | // Approve response then Remove response 24 | $http->addResponse(new Response(200, ['Content-Type' => 'application/json'], '{}')); 25 | $http->addResponse(new Response(200, ['Content-Type' => 'application/json'], '{}')); 26 | 27 | $mod = new \Avansaber\RedditApi\Resources\Moderation($client); 28 | $mod->approve('t3_abc'); 29 | $mod->remove('t3_abc', true); 30 | $this->assertTrue(true); 31 | } 32 | } 33 | 34 | 35 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "avansaber/php-reddit-api", 3 | "description": "Modern, fluent, framework-agnostic Reddit API client for PHP (PSR-18/PSR-7).", 4 | "type": "library", 5 | "license": "MIT", 6 | "require": { 7 | "php": "^8.1", 8 | "psr/http-client": "^1.0", 9 | "psr/http-factory": "^1.0", 10 | "psr/log": "^1.1 || ^2.0 || ^3.0", 11 | "php-http/discovery": "^1.19", 12 | "php-http/guzzle7-adapter": "^1.0", 13 | "guzzlehttp/guzzle": "^7.8" 14 | }, 15 | "require-dev": { 16 | "phpunit/phpunit": "^10.5", 17 | "php-http/mock-client": "^1.6", 18 | "nyholm/psr7": "^1.8", 19 | "phpstan/phpstan": "^1.11", 20 | "friendsofphp/php-cs-fixer": "^3.46" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "Avansaber\\RedditApi\\": "src/" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "Avansaber\\RedditApi\\Tests\\": "tests/" 30 | } 31 | }, 32 | "scripts": { 33 | "test": "phpunit --colors=always", 34 | "test:coverage": "phpunit --coverage-clover=coverage.xml", 35 | "cs:fix": "php-cs-fixer fix --ansi", 36 | "phpstan": "phpstan analyse src --level=max" 37 | }, 38 | "minimum-stability": "stable", 39 | "prefer-stable": true 40 | , 41 | "config": { 42 | "platform": { 43 | "php": "8.1.0" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Unit/RateLimitExposureTest.php: -------------------------------------------------------------------------------- 1 | addResponse(new Response(200, [ 23 | 'x-ratelimit-remaining' => '55', 24 | 'x-ratelimit-used' => '45', 25 | 'x-ratelimit-reset' => '120', 26 | 'Content-Type' => 'application/json', 27 | ], '{}')); 28 | 29 | $client->request('GET', '/api/v1/me'); 30 | $info = $client->getLastRateLimitInfo(); 31 | $this->assertNotNull($info); 32 | $this->assertSame(55.0, $info->remaining); 33 | $this->assertSame(45.0, $info->used); 34 | $this->assertSame(120.0, $info->resetSeconds); 35 | } 36 | } 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/Config/Config.php: -------------------------------------------------------------------------------- 1 | userAgent = $userAgent; 26 | $this->baseUri = rtrim($baseUri, '/'); 27 | $this->timeoutSeconds = $timeoutSeconds; 28 | $this->maxRetries = $maxRetries; 29 | } 30 | 31 | public function getBaseUri(): string 32 | { 33 | return $this->baseUri; 34 | } 35 | 36 | public function getUserAgent(): string 37 | { 38 | return $this->userAgent; 39 | } 40 | 41 | public function getTimeoutSeconds(): float 42 | { 43 | return $this->timeoutSeconds; 44 | } 45 | 46 | public function getMaxRetries(): int 47 | { 48 | return $this->maxRetries; 49 | } 50 | } -------------------------------------------------------------------------------- /tests/Unit/UserResourceTest.php: -------------------------------------------------------------------------------- 1 | withToken('token'); 23 | 24 | $payload = [ 25 | 'data' => [ 26 | 'id' => 'u_abc', 27 | 'name' => 'spez', 28 | 'is_employee' => true, 29 | 'is_mod' => false, 30 | 'created_utc' => 1500000000.0, 31 | ], 32 | ]; 33 | $http->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode($payload, JSON_THROW_ON_ERROR))); 34 | 35 | $u = $client->user()->about('spez'); 36 | $this->assertSame('spez', $u->name); 37 | $this->assertTrue($u->isEmployee); 38 | $this->assertFalse($u->isMod); 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /tests/Unit/MeResourceTest.php: -------------------------------------------------------------------------------- 1 | withToken('access-token'); 24 | 25 | $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 26 | 'id' => 'abc', 27 | 'name' => 'johndoe', 28 | 'is_employee' => false, 29 | 'is_mod' => true, 30 | 'created_utc' => 1600000000.0, 31 | ], JSON_THROW_ON_ERROR))); 32 | 33 | $user = $client->me()->get(); 34 | $this->assertSame('abc', $user->id); 35 | $this->assertSame('johndoe', $user->name); 36 | $this->assertTrue($user->isMod); 37 | $this->assertFalse($user->isEmployee); 38 | $this->assertSame(1600000000.0, $user->createdUtc); 39 | } 40 | } -------------------------------------------------------------------------------- /examples/app_only_search.php: -------------------------------------------------------------------------------- 1 | appOnly($clientId, $clientSecret, ['read']); 31 | 32 | $api = new RedditApiClient($http, $requestFactory, $streamFactory, $config); 33 | $api->withToken($accessToken); 34 | 35 | $listing = $api->search()->get('php', ['limit' => 3, 'sort' => 'relevance']); 36 | 37 | foreach ($listing->items as $i => $link) { 38 | echo sprintf("%d. [%s] %s (%s)\n", $i + 1, $link->subreddit, $link->title, $link->permalink); 39 | } 40 | 41 | -------------------------------------------------------------------------------- /tests/Unit/SubredditResourceTest.php: -------------------------------------------------------------------------------- 1 | withToken('token'); 23 | 24 | $payload = [ 25 | 'data' => [ 26 | 'id' => 't5_2qh33', 27 | 'display_name' => 'php', 28 | 'title' => 'PHP', 29 | 'public_description' => 'All about PHP', 30 | 'subscribers' => 123, 31 | 'over18' => false, 32 | 'url' => '/r/php/', 33 | ], 34 | ]; 35 | $http->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode($payload, JSON_THROW_ON_ERROR))); 36 | 37 | $sr = $client->subreddit()->about('php'); 38 | $this->assertSame('php', $sr->name); 39 | $this->assertSame('PHP', $sr->title); 40 | $this->assertSame(123, $sr->subscribers); 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /tests/Unit/PrivateMessagesResourceTest.php: -------------------------------------------------------------------------------- 1 | withToken('tkn'); 22 | 23 | $payload = [ 24 | 'data' => [ 25 | 'after' => null, 26 | 'before' => null, 27 | 'children' => [ 28 | ['data' => ['id' => 'm1', 'name' => 't4_m1', 'author' => 'u2', 'subject' => 'Hello', 'body' => 'Hi there', 'created_utc' => 123.0]], 29 | ], 30 | ], 31 | ]; 32 | $http->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode($payload, JSON_THROW_ON_ERROR))); 33 | 34 | $listing = (new \Avansaber\RedditApi\Resources\PrivateMessages($client))->inbox(['limit' => 1]); 35 | $this->assertCount(1, $listing->items); 36 | $this->assertSame('m1', $listing->items[0]['id']); 37 | $this->assertSame('Hello', $listing->items[0]['subject']); 38 | } 39 | } 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/Auth/InMemoryTokenStorage.php: -------------------------------------------------------------------------------- 1 | */ 10 | private array $tokens = []; 11 | 12 | public function save(Token $token): void 13 | { 14 | $key = $this->key($token->ownerUserId, $token->ownerTenantId, $token->providerUserId); 15 | $this->tokens[$key] = $token; 16 | } 17 | 18 | public function findByOwnerAndProviderUserId(?string $ownerUserId, ?string $ownerTenantId, string $providerUserId): ?Token 19 | { 20 | $key = $this->key($ownerUserId, $ownerTenantId, $providerUserId); 21 | return $this->tokens[$key] ?? null; 22 | } 23 | 24 | public function allForOwner(?string $ownerUserId, ?string $ownerTenantId): array 25 | { 26 | $result = []; 27 | foreach ($this->tokens as $key => $token) { 28 | if ($token->ownerUserId === $ownerUserId && $token->ownerTenantId === $ownerTenantId) { 29 | $result[] = $token; 30 | } 31 | } 32 | return $result; 33 | } 34 | 35 | public function deleteByOwnerAndProviderUserId(?string $ownerUserId, ?string $ownerTenantId, string $providerUserId): void 36 | { 37 | $key = $this->key($ownerUserId, $ownerTenantId, $providerUserId); 38 | unset($this->tokens[$key]); 39 | } 40 | 41 | private function key(?string $ownerUserId, ?string $ownerTenantId, string $providerUserId): string 42 | { 43 | return ($ownerUserId ?? '-') . '|' . ($ownerTenantId ?? '-') . '|' . $providerUserId; 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /tests/Unit/ClientInstantiationTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(RedditApiClient::class, $client); 31 | $this->assertSame('avansaber-php-reddit-api/0.1; contact admin@example.com', $client->getConfig()->getUserAgent()); 32 | } 33 | 34 | public function test_with_token_returns_self(): void 35 | { 36 | $httpClient = new MockHttpClient(); 37 | $psr17Factory = new Psr17Factory(); 38 | $config = new Config(userAgent: 'ua/1.0; contact admin@example.com'); 39 | 40 | $client = new RedditApiClient( 41 | httpClient: $httpClient, 42 | requestFactory: $psr17Factory, 43 | streamFactory: $psr17Factory, 44 | config: $config, 45 | logger: null 46 | ); 47 | 48 | $result = $client->withToken('token'); 49 | $this->assertSame($client, $result); 50 | } 51 | } -------------------------------------------------------------------------------- /tests/Unit/AuthAppOnlyTest.php: -------------------------------------------------------------------------------- 1 | addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 26 | 'access_token' => 'abc123', 27 | 'token_type' => 'bearer', 28 | 'expires_in' => 3600, 29 | 'scope' => 'read identity', 30 | ], JSON_THROW_ON_ERROR))); 31 | 32 | $token = $auth->appOnly('client', 'secret', ['read', 'identity']); 33 | $this->assertSame('abc123', $token); 34 | } 35 | 36 | public function test_app_only_throws_on_error_status(): void 37 | { 38 | $httpClient = new MockHttpClient(); 39 | $psr17 = new Psr17Factory(); 40 | $config = new Config('ua/1.0; contact admin@example.com'); 41 | 42 | $auth = new Auth($httpClient, $psr17, $psr17, $config, null); 43 | 44 | $httpClient->addResponse(new Response(401, ['Content-Type' => 'application/json'], '{"error":"unauthorized"}')); 45 | 46 | $this->expectException(AuthenticationException::class); 47 | $auth->appOnly('bad', 'creds'); 48 | } 49 | } -------------------------------------------------------------------------------- /tests/Unit/RetryAndRateLimitTest.php: -------------------------------------------------------------------------------- 1 | addResponse(new Response(500, [], 'error')); 25 | $http->addResponse(new Response(200, ['Content-Type' => 'application/json'], '{}')); 26 | 27 | $body = $client->request('GET', '/api/v1/me'); 28 | $this->assertSame('{}', $body); 29 | } 30 | 31 | public function test_retries_on_429_respects_retry_after(): void 32 | { 33 | $http = new MockHttpClient(); 34 | $psr17 = new Psr17Factory(); 35 | $config = new Config('ua/1.0; contact admin@example.com', timeoutSeconds: 10.0, maxRetries: 1); 36 | $client = new RedditApiClient($http, $psr17, $psr17, $config, null, '', new NoopSleeper()); 37 | 38 | $http->addResponse(new Response(429, ['Retry-After' => '1'], 'rate limited')); 39 | $http->addResponse(new Response(200, ['Content-Type' => 'application/json'], '{}')); 40 | 41 | $body = $client->request('GET', '/api/v1/me'); 42 | $this->assertSame('{}', $body); 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /tests/Unit/SearchResourceTest.php: -------------------------------------------------------------------------------- 1 | withToken('token'); 24 | 25 | $payload = [ 26 | 'data' => [ 27 | 'after' => 't3_after', 28 | 'before' => null, 29 | 'children' => [ 30 | ['kind' => 't3', 'data' => [ 31 | 'id' => 'abc', 'name' => 't3_abc', 'title' => 'Hello', 'author' => 'me', 'subreddit' => 'php', 'permalink' => '/r/php/abc', 'url' => 'https://example.com', 'score' => 10, 32 | ]], 33 | ['kind' => 't1', 'data' => ['id' => 'comment']], 34 | ], 35 | ], 36 | ]; 37 | 38 | $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode($payload, JSON_THROW_ON_ERROR))); 39 | 40 | $listing = $client->search()->get('hello'); 41 | $this->assertSame('t3_after', $listing->after); 42 | $this->assertNull($listing->before); 43 | $this->assertCount(1, $listing->items); 44 | $this->assertSame('abc', $listing->items[0]->id); 45 | $this->assertSame('Hello', $listing->items[0]->title); 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /src/Http/AutoRefreshingClient.php: -------------------------------------------------------------------------------- 1 | client->withToken($this->token->accessToken); 24 | } 25 | 26 | /** 27 | * Performs a request, refreshes on 401 once, persists and retries. 28 | * 29 | * @param array $query 30 | * @param array $headers 31 | */ 32 | public function request(string $method, string $uri, array $query = [], array $headers = []): string 33 | { 34 | try { 35 | return $this->client->request($method, $uri, $query, $headers); 36 | } catch (RedditApiException $e) { 37 | if ($e->getStatusCode() === 401 && $this->token->refreshToken) { 38 | $this->logger?->warning('Access token expired, attempting refresh'); 39 | $this->token = $this->refresher->refresh($this->token); 40 | $this->client->withToken($this->token->accessToken); 41 | return $this->client->request($method, $uri, $query, $headers); 42 | } 43 | throw $e; 44 | } 45 | } 46 | 47 | public function me(): \Avansaber\RedditApi\Resources\Me 48 | { 49 | return $this->client->me(); 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /tests/Unit/InMemoryTokenStorageTest.php: -------------------------------------------------------------------------------- 1 | assertNull($storage->findByOwnerAndProviderUserId('u1', null, 'p123')); 18 | 19 | $t1 = new Token( 20 | providerUserId: 'p123', 21 | accessToken: 'at1', 22 | refreshToken: 'rt1', 23 | expiresAtEpoch: time() + 1000, 24 | scopes: ['read'], 25 | ownerUserId: 'u1', 26 | ownerTenantId: null, 27 | ); 28 | $storage->save($t1); 29 | 30 | $found = $storage->findByOwnerAndProviderUserId('u1', null, 'p123'); 31 | $this->assertNotNull($found); 32 | $this->assertSame('at1', $found->accessToken); 33 | 34 | $all = $storage->allForOwner('u1', null); 35 | $this->assertCount(1, $all); 36 | 37 | // Update 38 | $t2 = new Token( 39 | providerUserId: 'p123', 40 | accessToken: 'at2', 41 | refreshToken: 'rt2', 42 | expiresAtEpoch: time() + 2000, 43 | scopes: ['read', 'identity'], 44 | ownerUserId: 'u1', 45 | ownerTenantId: null, 46 | ); 47 | $storage->save($t2); 48 | $found2 = $storage->findByOwnerAndProviderUserId('u1', null, 'p123'); 49 | $this->assertSame('at2', $found2->accessToken); 50 | $this->assertSame(['read', 'identity'], $found2->scopes); 51 | 52 | $storage->deleteByOwnerAndProviderUserId('u1', null, 'p123'); 53 | $this->assertNull($storage->findByOwnerAndProviderUserId('u1', null, 'p123')); 54 | } 55 | } -------------------------------------------------------------------------------- /src/Auth/TokenRefresher.php: -------------------------------------------------------------------------------- 1 | auth->refreshAccessToken($this->clientId, $this->clientSecret, $token->refreshToken ?? ''); 27 | 28 | $newAccessToken = (string) ($data['access_token'] ?? ''); 29 | $newRefreshToken = isset($data['refresh_token']) && is_string($data['refresh_token']) 30 | ? $data['refresh_token'] 31 | : $token->refreshToken; 32 | $expiresIn = (int) ($data['expires_in'] ?? 3600); 33 | $expiresAt = time() + $expiresIn; 34 | 35 | $updated = new Token( 36 | providerUserId: $token->providerUserId, 37 | accessToken: $newAccessToken, 38 | refreshToken: $newRefreshToken, 39 | expiresAtEpoch: $expiresAt, 40 | scopes: $token->scopes, 41 | ownerUserId: $token->ownerUserId, 42 | ownerTenantId: $token->ownerTenantId, 43 | ); 44 | 45 | $this->storage->save($updated); 46 | $this->logger?->info('Refreshed Reddit access token', [ 47 | 'provider_user_id' => $token->providerUserId, 48 | 'owner_user_id' => $token->ownerUserId, 49 | 'owner_tenant_id' => $token->ownerTenantId, 50 | ]); 51 | 52 | return $updated; 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /src/Resources/PrivateMessages.php: -------------------------------------------------------------------------------- 1 | $options 18 | * @return Listing 19 | */ 20 | public function inbox(array $options = []): Listing 21 | { 22 | $json = $this->client->request('GET', '/message/inbox.json', $options); 23 | $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); 24 | $root = is_array($decoded) ? $decoded : []; 25 | $data = isset($root['data']) && is_array($root['data']) ? $root['data'] : []; 26 | $rawChildren = isset($data['children']) && is_array($data['children']) ? $data['children'] : []; 27 | 28 | $items = []; 29 | foreach ($rawChildren as $child) { 30 | if (!is_array($child)) { 31 | continue; 32 | } 33 | $c = isset($child['data']) && is_array($child['data']) ? $child['data'] : []; 34 | $items[] = [ 35 | 'id' => (string) ($c['id'] ?? ''), 36 | 'fullname' => (string) ($c['name'] ?? ''), 37 | 'author' => (string) ($c['author'] ?? ''), 38 | 'subject' => (string) ($c['subject'] ?? ''), 39 | 'body' => (string) ($c['body'] ?? ''), 40 | 'created_utc' => (float) ($c['created_utc'] ?? 0.0), 41 | ]; 42 | } 43 | 44 | return new Listing( 45 | items: $items, 46 | after: isset($data['after']) && is_string($data['after']) ? $data['after'] : null, 47 | before: isset($data['before']) && is_string($data['before']) ? $data['before'] : null, 48 | ); 49 | } 50 | } 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/Resources/Links.php: -------------------------------------------------------------------------------- 1 | vote((string) $fullname, 1); 20 | } 21 | 22 | public function downvote(string|Fullname $fullname): void 23 | { 24 | $this->vote((string) $fullname, -1); 25 | } 26 | 27 | public function unvote(string|Fullname $fullname): void 28 | { 29 | $this->vote((string) $fullname, 0); 30 | } 31 | 32 | private function vote(string $fullname, int $dir): void 33 | { 34 | $this->client->request('POST', '/api/vote', [], [], [ 35 | 'id' => $fullname, 36 | 'dir' => $dir, 37 | 'api_type' => 'json', 38 | ]); 39 | // For simplicity, we are not parsing body; Reddit returns 204/200 40 | } 41 | 42 | public function reply(string|Fullname $fullname, string $text): Comment 43 | { 44 | $json = $this->client->request('POST', '/api/comment', [], [], [ 45 | 'thing_id' => (string) $fullname, 46 | 'text' => $text, 47 | 'api_type' => 'json', 48 | ]); 49 | $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); 50 | 51 | // Simplified response parsing: pick first comment 52 | $thing = $decoded['json']['data']['things'][0]['data'] ?? []; 53 | return new Comment( 54 | id: (string) ($thing['id'] ?? ''), 55 | fullname: (string) ($thing['name'] ?? ''), 56 | author: (string) ($thing['author'] ?? ''), 57 | body: (string) ($thing['body'] ?? ''), 58 | permalink: (string) ($thing['permalink'] ?? ''), 59 | score: (int) ($thing['score'] ?? 0), 60 | ); 61 | } 62 | } 63 | 64 | -------------------------------------------------------------------------------- /tests/Unit/PdoSqliteTokenStorageTest.php: -------------------------------------------------------------------------------- 1 | assertNull($storage->findByOwnerAndProviderUserId('42', null, 'u_123')); 30 | 31 | $storage->save($token); 32 | 33 | $found = $storage->findByOwnerAndProviderUserId('42', null, 'u_123'); 34 | $this->assertNotNull($found); 35 | $this->assertSame('at-1', $found->accessToken); 36 | $this->assertSame(['read', 'identity'], $found->scopes); 37 | 38 | $all = $storage->allForOwner('42', null); 39 | $this->assertCount(1, $all); 40 | 41 | // update token 42 | $updated = new Token( 43 | providerUserId: 'u_123', 44 | accessToken: 'at-2', 45 | refreshToken: 'rt-2', 46 | expiresAtEpoch: time() + 7200, 47 | scopes: ['read'], 48 | ownerUserId: '42', 49 | ownerTenantId: null, 50 | ); 51 | $storage->save($updated); 52 | $found2 = $storage->findByOwnerAndProviderUserId('42', null, 'u_123'); 53 | $this->assertSame('at-2', $found2->accessToken); 54 | $this->assertSame(['read'], $found2->scopes); 55 | 56 | $storage->deleteByOwnerAndProviderUserId('42', null, 'u_123'); 57 | $this->assertNull($storage->findByOwnerAndProviderUserId('42', null, 'u_123')); 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /src/Resources/Search.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | public function get(string $query, array $options = []): Listing 22 | { 23 | $params = ['q' => $query] + $options; 24 | $json = $this->client->request('GET', '/search.json', $params); 25 | $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); 26 | $root = is_array($decoded) ? $decoded : []; 27 | $data = isset($root['data']) && is_array($root['data']) ? $root['data'] : []; 28 | $after = $data['after'] ?? null; 29 | $before = $data['before'] ?? null; 30 | $children = isset($data['children']) && is_array($data['children']) ? $data['children'] : []; 31 | 32 | $items = []; 33 | foreach ($children as $child) { 34 | if (!is_array($child) || ($child['kind'] ?? '') !== 't3' || !isset($child['data']) || !is_array($child['data'])) { 35 | continue; 36 | } 37 | $d = $child['data']; 38 | $items[] = new Link( 39 | id: (string) ($d['id'] ?? ''), 40 | fullname: (string) ($d['name'] ?? ''), 41 | title: (string) ($d['title'] ?? ''), 42 | author: (string) ($d['author'] ?? ''), 43 | subreddit: (string) ($d['subreddit'] ?? ''), 44 | permalink: (string) ($d['permalink'] ?? ''), 45 | url: (string) ($d['url'] ?? ''), 46 | score: (int) ($d['score'] ?? 0), 47 | ); 48 | } 49 | 50 | return new Listing($items, is_string($after) ? $after : null, is_string($before) ? $before : null); 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /tests/Unit/LinksResourceTest.php: -------------------------------------------------------------------------------- 1 | withToken('token'); 23 | 24 | $http->addResponse(new Response(204)); 25 | $http->addResponse(new Response(204)); 26 | $http->addResponse(new Response(204)); 27 | 28 | $client->links()->upvote('t3_abc'); 29 | $client->links()->downvote('t3_abc'); 30 | $client->links()->unvote('t3_abc'); 31 | 32 | $this->assertTrue(true); 33 | } 34 | 35 | public function test_reply_parses_comment(): void 36 | { 37 | $http = new MockHttpClient(); 38 | $psr17 = new Psr17Factory(); 39 | $config = new Config('ua/1.0; contact admin@example.com'); 40 | $client = new RedditApiClient($http, $psr17, $psr17, $config, null); 41 | $client->withToken('token'); 42 | 43 | $payload = [ 44 | 'json' => [ 45 | 'data' => [ 46 | 'things' => [ 47 | ['data' => [ 48 | 'id' => 'c1', 49 | 'name' => 't1_c1', 50 | 'author' => 'me', 51 | 'body' => 'hi', 52 | 'permalink' => '/r/php/comments/1/_/c1', 53 | 'score' => 2, 54 | ]] 55 | ] 56 | ] 57 | ] 58 | ]; 59 | $http->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode($payload, JSON_THROW_ON_ERROR))); 60 | 61 | $c = $client->links()->reply('t3_abc', 'hello'); 62 | $this->assertSame('c1', $c->id); 63 | $this->assertSame('t1_c1', $c->fullname); 64 | $this->assertSame('hi', $c->body); 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /tests/Unit/UserHistoryResourceTest.php: -------------------------------------------------------------------------------- 1 | withToken('tkn'); 22 | 23 | $payload = [ 24 | 'data' => [ 25 | 'after' => 't1_after', 26 | 'before' => null, 27 | 'children' => [ 28 | ['data' => ['id' => 'c1', 'name' => 't1_c1', 'author' => 'u1', 'body' => 'hi', 'permalink' => '/r/php/comments/1', 'score' => 5]], 29 | ], 30 | ], 31 | ]; 32 | $http->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode($payload, JSON_THROW_ON_ERROR))); 33 | 34 | $listing = (new \Avansaber\RedditApi\Resources\User($client))->comments('user1', ['limit' => 1]); 35 | $this->assertSame('t1_after', $listing->after); 36 | $this->assertCount(1, $listing->items); 37 | $this->assertSame('c1', $listing->items[0]->id); 38 | } 39 | 40 | public function test_user_submitted_listing(): void 41 | { 42 | $http = new MockHttpClient(); 43 | $psr17 = new Psr17Factory(); 44 | $client = new RedditApiClient($http, $psr17, $psr17, new Config('ua/1.0; contact admin@example.com')); 45 | $client->withToken('tkn'); 46 | 47 | $payload = [ 48 | 'data' => [ 49 | 'after' => null, 50 | 'before' => null, 51 | 'children' => [ 52 | ['data' => ['id' => 'p1', 'name' => 't3_p1', 'title' => 'T', 'author' => 'u1', 'subreddit' => 'php', 'permalink' => '/r/php/p1', 'url' => 'https://...', 'score' => 10]], 53 | ], 54 | ], 55 | ]; 56 | $http->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode($payload, JSON_THROW_ON_ERROR))); 57 | 58 | $listing = (new \Avansaber\RedditApi\Resources\User($client))->submitted('user1', ['limit' => 1]); 59 | $this->assertNull($listing->after); 60 | $this->assertCount(1, $listing->items); 61 | $this->assertSame('p1', $listing->items[0]->id); 62 | } 63 | } 64 | 65 | 66 | -------------------------------------------------------------------------------- /tests/Unit/AuthAuthorizationCodeTest.php: -------------------------------------------------------------------------------- 1 | generatePkcePair(); 21 | $this->assertArrayHasKey('verifier', $pair); 22 | $this->assertArrayHasKey('challenge', $pair); 23 | $this->assertIsString($pair['verifier']); 24 | $this->assertIsString($pair['challenge']); 25 | $this->assertNotSame('', $pair['verifier']); 26 | $this->assertNotSame('', $pair['challenge']); 27 | } 28 | 29 | public function test_get_auth_url_includes_pkce_when_provided(): void 30 | { 31 | $auth = new Auth(new MockHttpClient(), new Psr17Factory(), new Psr17Factory(), new Config('ua/1.0; contact admin@example.com')); 32 | $url = $auth->getAuthUrl('client', 'https://example.com/callback', ['read','identity'], 'state123', 'challengeABC'); 33 | $this->assertStringContainsString('code_challenge=challengeABC', $url); 34 | $this->assertStringContainsString('code_challenge_method=S256', $url); 35 | $this->assertStringContainsString('client_id=client', $url); 36 | $this->assertStringContainsString('response_type=code', $url); 37 | } 38 | 39 | public function test_exchange_code_success_with_pkce(): void 40 | { 41 | $http = new MockHttpClient(); 42 | $psr17 = new Psr17Factory(); 43 | $auth = new Auth($http, $psr17, $psr17, new Config('ua/1.0; contact admin@example.com')); 44 | 45 | $http->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 46 | 'access_token' => 'user-token', 47 | 'refresh_token' => 'refresh-token', 48 | 'token_type' => 'bearer', 49 | 'expires_in' => 3600, 50 | 'scope' => 'read identity', 51 | ], JSON_THROW_ON_ERROR))); 52 | 53 | $result = $auth->getAccessTokenFromCode('client', null, 'the_code', 'https://example.com/callback', 'the_verifier'); 54 | $this->assertSame('user-token', $result['access_token']); 55 | $this->assertSame('refresh-token', $result['refresh_token']); 56 | } 57 | 58 | public function test_exchange_code_throws_on_error(): void 59 | { 60 | $http = new MockHttpClient(); 61 | $psr17 = new Psr17Factory(); 62 | $auth = new Auth($http, $psr17, $psr17, new Config('ua/1.0; contact admin@example.com')); 63 | 64 | $http->addResponse(new Response(400, ['Content-Type' => 'application/json'], '{"error":"invalid_grant"}')); 65 | 66 | $this->expectException(AuthenticationException::class); 67 | $auth->getAccessTokenFromCode('client', null, 'bad_code', 'https://example.com/callback', 'verifier'); 68 | } 69 | } 70 | 71 | 72 | -------------------------------------------------------------------------------- /tests/Unit/AutoRefreshingClientTest.php: -------------------------------------------------------------------------------- 1 | save($token); 45 | 46 | $refresher = new TokenRefresher($auth, $storage, 'client', 'secret', new NullLogger()); 47 | $client = new AutoRefreshingClient($coreClient, $refresher, $token, new NullLogger()); 48 | 49 | // First call fails with 401, then token refresh call returns new token, then retry succeeds 50 | $httpClient->addResponse(new Response(401, [], 'unauthorized')); 51 | $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 52 | 'access_token' => 'new-token', 53 | 'expires_in' => 3600, 54 | ], JSON_THROW_ON_ERROR))); 55 | $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode([ 56 | 'id' => 'me', 57 | 'name' => 'johndoe' 58 | ], JSON_THROW_ON_ERROR))); 59 | 60 | $body = $client->request('GET', '/api/v1/me'); 61 | $this->assertNotEmpty($body); 62 | } 63 | 64 | public function test_bubble_up_non_401_errors(): void 65 | { 66 | $httpClient = new MockHttpClient(); 67 | $psr17 = new Psr17Factory(); 68 | // Disable retries to ensure 500 bubbles immediately 69 | $config = new Config('ua/1.0; contact admin@example.com', timeoutSeconds: 10.0, maxRetries: 0); 70 | 71 | $coreClient = new RedditApiClient($httpClient, $psr17, $psr17, $config, new NullLogger()); 72 | $auth = new Auth($httpClient, $psr17, $psr17, $config, new NullLogger()); 73 | $storage = new InMemoryTokenStorage(); 74 | $token = new Token('u_1', 'expired', 'refresh-1', time() - 10, ['read'], '42', null); 75 | $storage->save($token); 76 | $refresher = new TokenRefresher($auth, $storage, 'client', 'secret', new NullLogger()); 77 | $client = new AutoRefreshingClient($coreClient, $refresher, $token, new NullLogger()); 78 | 79 | $httpClient->addResponse(new Response(500, [], 'err')); 80 | 81 | $this->expectException(RedditApiException::class); 82 | $client->request('GET', '/api/v1/me'); 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /src/Resources/User.php: -------------------------------------------------------------------------------- 1 | client->request('GET', "/user/{$username}/about.json"); 22 | $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); 23 | $root = is_array($decoded) ? $decoded : []; 24 | $d = isset($root['data']) && is_array($root['data']) ? $root['data'] : []; 25 | 26 | return new UserDTO( 27 | id: (string) ($d['id'] ?? ''), 28 | name: (string) ($d['name'] ?? ''), 29 | isEmployee: (bool) ($d['is_employee'] ?? false), 30 | isMod: (bool) ($d['is_mod'] ?? false), 31 | createdUtc: (float) ($d['created_utc'] ?? 0), 32 | ); 33 | } 34 | 35 | /** 36 | * @return Listing 37 | */ 38 | /** 39 | * @param array $options 40 | * @return Listing 41 | */ 42 | public function comments(string $username, array $options = []): Listing 43 | { 44 | $json = $this->client->request('GET', "/user/{$username}/comments.json", $options); 45 | $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); 46 | $root = is_array($decoded) ? $decoded : []; 47 | $data = isset($root['data']) && is_array($root['data']) ? $root['data'] : []; 48 | $children = isset($data['children']) && is_array($data['children']) ? $data['children'] : []; 49 | 50 | $items = []; 51 | foreach ($children as $child) { 52 | if (!is_array($child)) { 53 | continue; 54 | } 55 | $c = isset($child['data']) && is_array($child['data']) ? $child['data'] : []; 56 | $items[] = new CommentDTO( 57 | id: (string) ($c['id'] ?? ''), 58 | fullname: (string) ($c['name'] ?? ''), 59 | author: (string) ($c['author'] ?? ''), 60 | body: (string) ($c['body'] ?? ''), 61 | permalink: (string) ($c['permalink'] ?? ''), 62 | score: (int) ($c['score'] ?? 0), 63 | ); 64 | } 65 | 66 | return new Listing( 67 | items: $items, 68 | after: isset($data['after']) && is_string($data['after']) ? $data['after'] : null, 69 | before: isset($data['before']) && is_string($data['before']) ? $data['before'] : null, 70 | ); 71 | } 72 | 73 | /** 74 | * @return Listing 75 | */ 76 | /** 77 | * @param array $options 78 | * @return Listing 79 | */ 80 | public function submitted(string $username, array $options = []): Listing 81 | { 82 | $json = $this->client->request('GET', "/user/{$username}/submitted.json", $options); 83 | $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); 84 | $root = is_array($decoded) ? $decoded : []; 85 | $data = isset($root['data']) && is_array($root['data']) ? $root['data'] : []; 86 | $children = isset($data['children']) && is_array($data['children']) ? $data['children'] : []; 87 | 88 | $items = []; 89 | foreach ($children as $child) { 90 | if (!is_array($child)) { 91 | continue; 92 | } 93 | $c = isset($child['data']) && is_array($child['data']) ? $child['data'] : []; 94 | $items[] = new LinkDTO( 95 | id: (string) ($c['id'] ?? ''), 96 | fullname: (string) ($c['name'] ?? ''), 97 | title: (string) ($c['title'] ?? ''), 98 | author: (string) ($c['author'] ?? ''), 99 | subreddit: (string) ($c['subreddit'] ?? ''), 100 | permalink: (string) ($c['permalink'] ?? ''), 101 | url: (string) ($c['url'] ?? ''), 102 | score: (int) ($c['score'] ?? 0), 103 | ); 104 | } 105 | 106 | return new Listing( 107 | items: $items, 108 | after: isset($data['after']) && is_string($data['after']) ? $data['after'] : null, 109 | before: isset($data['before']) && is_string($data['before']) ? $data['before'] : null, 110 | ); 111 | } 112 | } 113 | 114 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Property Avansaber\\\\RedditApi\\\\Auth\\\\Auth\\:\\:\\$logger is never read, only written\\.$#" 5 | count: 1 6 | path: src/Auth/Auth.php 7 | 8 | - 9 | message: "#^Cannot access offset 'access_token' on mixed\\.$#" 10 | count: 1 11 | path: src/Auth/PdoSqliteTokenStorage.php 12 | 13 | - 14 | message: "#^Cannot access offset 'expires_at_epoch' on mixed\\.$#" 15 | count: 1 16 | path: src/Auth/PdoSqliteTokenStorage.php 17 | 18 | - 19 | message: "#^Cannot access offset 'owner_tenant_id' on mixed\\.$#" 20 | count: 1 21 | path: src/Auth/PdoSqliteTokenStorage.php 22 | 23 | - 24 | message: "#^Cannot access offset 'owner_user_id' on mixed\\.$#" 25 | count: 1 26 | path: src/Auth/PdoSqliteTokenStorage.php 27 | 28 | - 29 | message: "#^Cannot access offset 'provider_user_id' on mixed\\.$#" 30 | count: 1 31 | path: src/Auth/PdoSqliteTokenStorage.php 32 | 33 | - 34 | message: "#^Cannot access offset 'refresh_token' on mixed\\.$#" 35 | count: 1 36 | path: src/Auth/PdoSqliteTokenStorage.php 37 | 38 | - 39 | message: "#^Cannot access offset 'scopes' on mixed\\.$#" 40 | count: 1 41 | path: src/Auth/PdoSqliteTokenStorage.php 42 | 43 | - 44 | message: "#^Cannot cast mixed to int\\.$#" 45 | count: 1 46 | path: src/Auth/PdoSqliteTokenStorage.php 47 | 48 | - 49 | message: "#^Cannot cast mixed to string\\.$#" 50 | count: 5 51 | path: src/Auth/PdoSqliteTokenStorage.php 52 | 53 | - 54 | message: "#^Cannot cast mixed to int\\.$#" 55 | count: 1 56 | path: src/Auth/TokenRefresher.php 57 | 58 | - 59 | message: "#^Cannot cast mixed to string\\.$#" 60 | count: 1 61 | path: src/Auth/TokenRefresher.php 62 | 63 | - 64 | message: "#^Method Avansaber\\\\RedditApi\\\\Http\\\\RedditApiClient\\:\\:request\\(\\) has parameter \\$form with no value type specified in iterable type array\\.$#" 65 | count: 1 66 | path: src/Http/RedditApiClient.php 67 | 68 | - 69 | message: "#^Property Avansaber\\\\RedditApi\\\\Http\\\\RedditApiClient\\:\\:\\$logger is never read, only written\\.$#" 70 | count: 1 71 | path: src/Http/RedditApiClient.php 72 | 73 | - 74 | message: "#^Cannot access offset 'author' on mixed\\.$#" 75 | count: 1 76 | path: src/Resources/Links.php 77 | 78 | - 79 | message: "#^Cannot access offset 'body' on mixed\\.$#" 80 | count: 1 81 | path: src/Resources/Links.php 82 | 83 | - 84 | message: "#^Cannot access offset 'data' on mixed\\.$#" 85 | count: 2 86 | path: src/Resources/Links.php 87 | 88 | - 89 | message: "#^Cannot access offset 'id' on mixed\\.$#" 90 | count: 1 91 | path: src/Resources/Links.php 92 | 93 | - 94 | message: "#^Cannot access offset 'json' on mixed\\.$#" 95 | count: 1 96 | path: src/Resources/Links.php 97 | 98 | - 99 | message: "#^Cannot access offset 'name' on mixed\\.$#" 100 | count: 1 101 | path: src/Resources/Links.php 102 | 103 | - 104 | message: "#^Cannot access offset 'permalink' on mixed\\.$#" 105 | count: 1 106 | path: src/Resources/Links.php 107 | 108 | - 109 | message: "#^Cannot access offset 'score' on mixed\\.$#" 110 | count: 1 111 | path: src/Resources/Links.php 112 | 113 | - 114 | message: "#^Cannot access offset 'things' on mixed\\.$#" 115 | count: 1 116 | path: src/Resources/Links.php 117 | 118 | - 119 | message: "#^Cannot access offset 0 on mixed\\.$#" 120 | count: 1 121 | path: src/Resources/Links.php 122 | 123 | - 124 | message: "#^Cannot cast mixed to int\\.$#" 125 | count: 1 126 | path: src/Resources/Links.php 127 | 128 | - 129 | message: "#^Cannot cast mixed to string\\.$#" 130 | count: 5 131 | path: src/Resources/Links.php 132 | 133 | - 134 | message: "#^Cannot access offset 'created_utc' on mixed\\.$#" 135 | count: 1 136 | path: src/Resources/Me.php 137 | 138 | - 139 | message: "#^Cannot access offset 'id' on mixed\\.$#" 140 | count: 1 141 | path: src/Resources/Me.php 142 | 143 | - 144 | message: "#^Cannot access offset 'is_employee' on mixed\\.$#" 145 | count: 1 146 | path: src/Resources/Me.php 147 | 148 | - 149 | message: "#^Cannot access offset 'is_mod' on mixed\\.$#" 150 | count: 1 151 | path: src/Resources/Me.php 152 | 153 | - 154 | message: "#^Cannot access offset 'name' on mixed\\.$#" 155 | count: 1 156 | path: src/Resources/Me.php 157 | 158 | - 159 | message: "#^Cannot cast mixed to float\\.$#" 160 | count: 1 161 | path: src/Resources/Me.php 162 | 163 | - 164 | message: "#^Cannot cast mixed to string\\.$#" 165 | count: 2 166 | path: src/Resources/Me.php 167 | 168 | # Cleaned Search.php issues by adding stricter typing and generics annotations 169 | 170 | - 171 | message: "#^Cannot access offset 'data' on mixed\\.$#" 172 | count: 1 173 | path: src/Resources/Subreddit.php 174 | 175 | - 176 | message: "#^Cannot access offset 'display_name' on mixed\\.$#" 177 | count: 1 178 | path: src/Resources/Subreddit.php 179 | 180 | - 181 | message: "#^Cannot access offset 'id' on mixed\\.$#" 182 | count: 1 183 | path: src/Resources/Subreddit.php 184 | 185 | - 186 | message: "#^Cannot access offset 'over18' on mixed\\.$#" 187 | count: 1 188 | path: src/Resources/Subreddit.php 189 | 190 | - 191 | message: "#^Cannot access offset 'public_description' on mixed\\.$#" 192 | count: 1 193 | path: src/Resources/Subreddit.php 194 | 195 | - 196 | message: "#^Cannot access offset 'subscribers' on mixed\\.$#" 197 | count: 1 198 | path: src/Resources/Subreddit.php 199 | 200 | - 201 | message: "#^Cannot access offset 'title' on mixed\\.$#" 202 | count: 1 203 | path: src/Resources/Subreddit.php 204 | 205 | - 206 | message: "#^Cannot access offset 'url' on mixed\\.$#" 207 | count: 1 208 | path: src/Resources/Subreddit.php 209 | 210 | - 211 | message: "#^Cannot cast mixed to int\\.$#" 212 | count: 1 213 | path: src/Resources/Subreddit.php 214 | 215 | - 216 | message: "#^Cannot cast mixed to string\\.$#" 217 | count: 5 218 | path: src/Resources/Subreddit.php 219 | 220 | # Cleaned User.php issues by adding array guards and generics annotations 221 | -------------------------------------------------------------------------------- /src/Http/RedditApiClient.php: -------------------------------------------------------------------------------- 1 | sleeper = $this->sleeper ?? new NoopSleeper(); 33 | } 34 | 35 | public function withToken(string $accessToken): self 36 | { 37 | $this->accessToken = $accessToken; 38 | return $this; 39 | } 40 | 41 | public function getConfig(): Config 42 | { 43 | return $this->config; 44 | } 45 | 46 | private ?RateLimitInfo $lastRateLimitInfo = null; 47 | 48 | /** 49 | * Get the last parsed Reddit rate limit info from response headers, if available. 50 | */ 51 | public function getLastRateLimitInfo(): ?RateLimitInfo 52 | { 53 | return $this->lastRateLimitInfo; 54 | } 55 | 56 | /** 57 | * Sends an HTTP request to Reddit API using PSR-18 client. 58 | * 59 | * @param array $query 60 | * @param array $headers 61 | */ 62 | public function request(string $method, string $uri, array $query = [], array $headers = [], ?array $form = null): string 63 | { 64 | $url = $this->config->getBaseUri() . '/' . ltrim($uri, '/'); 65 | if (!empty($query)) { 66 | $qs = http_build_query($query); 67 | $url .= (str_contains($url, '?') ? '&' : '?') . $qs; 68 | } 69 | 70 | $request = $this->requestFactory->createRequest($method, $url) 71 | ->withHeader('User-Agent', $this->config->getUserAgent()) 72 | ->withHeader('Accept', 'application/json'); 73 | 74 | if ($this->accessToken !== '') { 75 | $request = $request->withHeader('Authorization', 'Bearer ' . $this->accessToken); 76 | } 77 | 78 | foreach ($headers as $name => $value) { 79 | $request = $request->withHeader($name, (string) $value); 80 | } 81 | 82 | if ($form !== null) { 83 | $body = http_build_query($form); 84 | $request = $request->withHeader('Content-Type', 'application/x-www-form-urlencoded'); 85 | $request = $request->withBody($this->streamFactory->createStream($body)); 86 | } 87 | 88 | $attempt = 0; 89 | $maxAttempts = max(1, $this->config->getMaxRetries() + 1); 90 | $lastException = null; 91 | while ($attempt < $maxAttempts) { 92 | $attempt++; 93 | try { 94 | $response = $this->httpClient->sendRequest($request); 95 | } catch (\Throwable $e) { 96 | $lastException = new RedditApiException('Transport error: ' . $e->getMessage(), 0, null, $e); 97 | $this->backoff($attempt, null); 98 | continue; 99 | } 100 | 101 | $status = $response->getStatusCode(); 102 | $body = (string) $response->getBody(); 103 | $this->lastRateLimitInfo = $this->parseRateLimit($response); 104 | 105 | if ($status >= 200 && $status < 300) { 106 | // Optionally parse rate limit headers here (not yet surfaced) 107 | return $body; 108 | } 109 | 110 | if ($status === 429) { 111 | $retryAfter = $response->getHeaderLine('Retry-After'); 112 | $this->backoff($attempt, $retryAfter !== '' ? (int) $retryAfter : null); 113 | continue; 114 | } 115 | 116 | if ($status >= 500 && $status < 600) { 117 | $this->backoff($attempt, null); 118 | continue; 119 | } 120 | 121 | throw new RedditApiException('HTTP ' . $status . ' from Reddit API', $status, $body); 122 | } 123 | 124 | if ($lastException instanceof RedditApiException) { 125 | throw $lastException; 126 | } 127 | throw new RedditApiException('Request failed after retries'); 128 | } 129 | 130 | private function backoff(int $attempt, ?int $retryAfterSeconds): void 131 | { 132 | if ($attempt >= ($this->config->getMaxRetries() + 1)) { 133 | return; 134 | } 135 | $delayMs = $retryAfterSeconds !== null 136 | ? max(0, $retryAfterSeconds * 1000) 137 | : (int) (100.0 * (2 ** ($attempt - 1))); // 100ms, 200ms, 400ms ... 138 | $this->sleeper?->sleep($delayMs); 139 | } 140 | 141 | private function parseRateLimit(\Psr\Http\Message\ResponseInterface $response): RateLimitInfo 142 | { 143 | $remaining = $response->hasHeader('x-ratelimit-remaining') ? (float) $response->getHeaderLine('x-ratelimit-remaining') : null; 144 | $used = $response->hasHeader('x-ratelimit-used') ? (float) $response->getHeaderLine('x-ratelimit-used') : null; 145 | $reset = $response->hasHeader('x-ratelimit-reset') ? (float) $response->getHeaderLine('x-ratelimit-reset') : null; 146 | return new RateLimitInfo($remaining, $used, $reset); 147 | } 148 | 149 | public function me(): Me 150 | { 151 | return new Me($this); 152 | } 153 | 154 | public function search(): Search 155 | { 156 | return new Search($this); 157 | } 158 | 159 | public function subreddit(): Subreddit 160 | { 161 | return new Subreddit($this); 162 | } 163 | 164 | public function user(): User 165 | { 166 | return new User($this); 167 | } 168 | 169 | public function links(): \Avansaber\RedditApi\Resources\Links 170 | { 171 | return new \Avansaber\RedditApi\Resources\Links($this); 172 | } 173 | 174 | public function messages(): PrivateMessages 175 | { 176 | return new PrivateMessages($this); 177 | } 178 | 179 | public function moderation(): Moderation 180 | { 181 | return new Moderation($this); 182 | } 183 | 184 | public function flair(): Flair 185 | { 186 | return new Flair($this); 187 | } 188 | } -------------------------------------------------------------------------------- /src/Auth/PdoSqliteTokenStorage.php: -------------------------------------------------------------------------------- 1 | createTableIfNotExists(); 18 | } 19 | } 20 | 21 | public function save(Token $token): void 22 | { 23 | // Delete existing then insert to avoid requiring UNIQUE constraints 24 | $this->deleteByOwnerAndProviderUserId($token->ownerUserId, $token->ownerTenantId, $token->providerUserId); 25 | 26 | $sql = sprintf( 27 | 'INSERT INTO %s (provider_user_id, access_token, refresh_token, expires_at_epoch, scopes, owner_user_id, owner_tenant_id) VALUES (:pid, :at, :rt, :exp, :sc, :ouid, :otid)', 28 | $this->table 29 | ); 30 | $stmt = $this->pdo->prepare($sql); 31 | $stmt->execute([ 32 | ':pid' => $token->providerUserId, 33 | ':at' => $token->accessToken, 34 | ':rt' => $token->refreshToken, 35 | ':exp' => $token->expiresAtEpoch, 36 | ':sc' => json_encode(array_values($token->scopes), JSON_THROW_ON_ERROR), 37 | ':ouid' => $token->ownerUserId, 38 | ':otid' => $token->ownerTenantId, 39 | ]); 40 | } 41 | 42 | public function findByOwnerAndProviderUserId(?string $ownerUserId, ?string $ownerTenantId, string $providerUserId): ?Token 43 | { 44 | $where = ['provider_user_id = :pid']; 45 | $params = [':pid' => $providerUserId]; 46 | 47 | if ($ownerUserId === null) { 48 | $where[] = 'owner_user_id IS NULL'; 49 | } else { 50 | $where[] = 'owner_user_id = :ouid'; 51 | $params[':ouid'] = $ownerUserId; 52 | } 53 | 54 | if ($ownerTenantId === null) { 55 | $where[] = 'owner_tenant_id IS NULL'; 56 | } else { 57 | $where[] = 'owner_tenant_id = :otid'; 58 | $params[':otid'] = $ownerTenantId; 59 | } 60 | 61 | $sql = sprintf( 62 | 'SELECT provider_user_id, access_token, refresh_token, expires_at_epoch, scopes, owner_user_id, owner_tenant_id FROM %s WHERE %s LIMIT 1', 63 | $this->table, 64 | implode(' AND ', $where) 65 | ); 66 | 67 | $stmt = $this->pdo->prepare($sql); 68 | $stmt->execute($params); 69 | 70 | $row = $stmt->fetch(PDO::FETCH_ASSOC); 71 | if ($row === false) { 72 | return null; 73 | } 74 | 75 | $scopes = []; 76 | if (isset($row['scopes']) && is_string($row['scopes']) && $row['scopes'] !== '') { 77 | try { 78 | $decoded = json_decode($row['scopes'], true, 512, JSON_THROW_ON_ERROR); 79 | if (is_array($decoded)) { 80 | $scopes = array_values(array_map('strval', $decoded)); 81 | } 82 | } catch (\Throwable) { 83 | $scopes = []; 84 | } 85 | } 86 | 87 | return new Token( 88 | providerUserId: (string) $row['provider_user_id'], 89 | accessToken: (string) $row['access_token'], 90 | refreshToken: $row['refresh_token'] !== null ? (string) $row['refresh_token'] : null, 91 | expiresAtEpoch: (int) $row['expires_at_epoch'], 92 | scopes: $scopes, 93 | ownerUserId: $row['owner_user_id'] !== null ? (string) $row['owner_user_id'] : null, 94 | ownerTenantId: $row['owner_tenant_id'] !== null ? (string) $row['owner_tenant_id'] : null, 95 | ); 96 | } 97 | 98 | public function allForOwner(?string $ownerUserId, ?string $ownerTenantId): array 99 | { 100 | $sql = sprintf( 101 | 'SELECT provider_user_id, access_token, refresh_token, expires_at_epoch, scopes, owner_user_id, owner_tenant_id FROM %s WHERE owner_user_id %s AND owner_tenant_id %s', 102 | $this->table, 103 | $ownerUserId === null ? 'IS NULL' : '= :ouid', 104 | $ownerTenantId === null ? 'IS NULL' : '= :otid' 105 | ); 106 | $stmt = $this->pdo->prepare($sql); 107 | $params = []; 108 | if ($ownerUserId !== null) { 109 | $params[':ouid'] = $ownerUserId; 110 | } 111 | if ($ownerTenantId !== null) { 112 | $params[':otid'] = $ownerTenantId; 113 | } 114 | $stmt->execute($params); 115 | 116 | $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; 117 | $tokens = []; 118 | foreach ($rows as $row) { 119 | $scopes = []; 120 | if (isset($row['scopes']) && is_string($row['scopes']) && $row['scopes'] !== '') { 121 | try { 122 | $decoded = json_decode($row['scopes'], true, 512, JSON_THROW_ON_ERROR); 123 | if (is_array($decoded)) { 124 | $scopes = array_values(array_map('strval', $decoded)); 125 | } 126 | } catch (\Throwable) { 127 | $scopes = []; 128 | } 129 | } 130 | 131 | $tokens[] = new Token( 132 | providerUserId: (string) $row['provider_user_id'], 133 | accessToken: (string) $row['access_token'], 134 | refreshToken: $row['refresh_token'] !== null ? (string) $row['refresh_token'] : null, 135 | expiresAtEpoch: (int) $row['expires_at_epoch'], 136 | scopes: $scopes, 137 | ownerUserId: $row['owner_user_id'] !== null ? (string) $row['owner_user_id'] : null, 138 | ownerTenantId: $row['owner_tenant_id'] !== null ? (string) $row['owner_tenant_id'] : null, 139 | ); 140 | } 141 | 142 | return $tokens; 143 | } 144 | 145 | public function deleteByOwnerAndProviderUserId(?string $ownerUserId, ?string $ownerTenantId, string $providerUserId): void 146 | { 147 | $sql = sprintf( 148 | 'DELETE FROM %s WHERE provider_user_id = :pid AND owner_user_id %s AND owner_tenant_id %s', 149 | $this->table, 150 | $ownerUserId === null ? 'IS NULL' : '= :ouid', 151 | $ownerTenantId === null ? 'IS NULL' : '= :otid' 152 | ); 153 | $stmt = $this->pdo->prepare($sql); 154 | $params = [':pid' => $providerUserId]; 155 | if ($ownerUserId !== null) { 156 | $params[':ouid'] = $ownerUserId; 157 | } 158 | if ($ownerTenantId !== null) { 159 | $params[':otid'] = $ownerTenantId; 160 | } 161 | $stmt->execute($params); 162 | } 163 | 164 | private function createTableIfNotExists(): void 165 | { 166 | $sql = sprintf('CREATE TABLE IF NOT EXISTS %s ( 167 | id INTEGER PRIMARY KEY AUTOINCREMENT, 168 | provider_user_id TEXT NOT NULL, 169 | access_token TEXT NOT NULL, 170 | refresh_token TEXT NULL, 171 | expires_at_epoch INTEGER NOT NULL, 172 | scopes TEXT NULL, 173 | owner_user_id TEXT NULL, 174 | owner_tenant_id TEXT NULL 175 | )', $this->table); 176 | 177 | $this->pdo->exec($sql); 178 | // Optional indexes to speed up lookups 179 | $this->pdo->exec(sprintf('CREATE INDEX IF NOT EXISTS idx_%s_owner ON %s (owner_user_id, owner_tenant_id)', $this->table, $this->table)); 180 | $this->pdo->exec(sprintf('CREATE INDEX IF NOT EXISTS idx_%s_provider ON %s (provider_user_id)', $this->table, $this->table)); 181 | } 182 | } 183 | 184 | -------------------------------------------------------------------------------- /src/Auth/Auth.php: -------------------------------------------------------------------------------- 1 | base64UrlEncode($random); 35 | $challenge = $this->base64UrlEncode(hash('sha256', $verifier, true)); 36 | return [ 37 | 'verifier' => $verifier, 38 | 'challenge' => $challenge, 39 | ]; 40 | } 41 | 42 | /** 43 | * Build the OAuth2 authorization URL. 44 | * Include PKCE params when $codeChallenge provided. 45 | * 46 | * @param array $scopes 47 | */ 48 | public function getAuthUrl( 49 | string $clientId, 50 | string $redirectUri, 51 | array $scopes, 52 | string $state, 53 | ?string $codeChallenge = null 54 | ): string { 55 | $params = [ 56 | 'client_id' => $clientId, 57 | 'response_type' => 'code', 58 | 'state' => $state, 59 | 'redirect_uri' => $redirectUri, 60 | 'duration' => 'permanent', 61 | 'scope' => implode(' ', $scopes), 62 | ]; 63 | 64 | if ($codeChallenge !== null && $codeChallenge !== '') { 65 | $params['code_challenge_method'] = 'S256'; 66 | $params['code_challenge'] = $codeChallenge; 67 | } 68 | 69 | return 'https://www.reddit.com/api/v1/authorize?' . http_build_query($params); 70 | } 71 | 72 | /** 73 | * Application-only (client credentials) flow. 74 | * Returns an access token string. 75 | * 76 | * @param array $scopes 77 | */ 78 | public function appOnly(string $clientId, string $clientSecret, array $scopes = ['read', 'identity']): string 79 | { 80 | $endpoint = 'https://www.reddit.com/api/v1/access_token'; 81 | 82 | $bodyParams = [ 83 | 'grant_type' => 'client_credentials', 84 | ]; 85 | if (!empty($scopes)) { 86 | $bodyParams['scope'] = implode(' ', $scopes); 87 | } 88 | 89 | $bodyString = http_build_query($bodyParams); 90 | 91 | $request = $this->requestFactory 92 | ->createRequest('POST', $endpoint) 93 | ->withHeader('User-Agent', $this->config->getUserAgent()) 94 | ->withHeader('Content-Type', 'application/x-www-form-urlencoded') 95 | ->withHeader('Authorization', 'Basic ' . base64_encode($clientId . ':' . $clientSecret)); 96 | 97 | $request = $request->withBody($this->streamFactory->createStream($bodyString)); 98 | 99 | try { 100 | $response = $this->httpClient->sendRequest($request); 101 | } catch (\Throwable $e) { 102 | throw new AuthenticationException('Transport error during token request: ' . $e->getMessage(), 0, null, $e); 103 | } 104 | 105 | $status = $response->getStatusCode(); 106 | $raw = (string) $response->getBody(); 107 | 108 | if ($status !== 200) { 109 | throw new AuthenticationException('Reddit token endpoint returned HTTP ' . $status, $status, $raw); 110 | } 111 | 112 | $data = json_decode($raw, true); 113 | if (!is_array($data)) { 114 | throw new AuthenticationException('Invalid JSON from token endpoint', $status, $raw); 115 | } 116 | 117 | $token = $data['access_token'] ?? null; 118 | if (!is_string($token) || $token === '') { 119 | throw new AuthenticationException('No access_token in token response', $status, $raw); 120 | } 121 | 122 | return $token; 123 | } 124 | 125 | /** 126 | * Exchange an authorization code for tokens. Supports PKCE when $codeVerifier is provided. 127 | * If $clientSecret is null, an empty secret is used in Basic auth (installed app + PKCE). 128 | * 129 | * @return array 130 | */ 131 | public function getAccessTokenFromCode( 132 | string $clientId, 133 | ?string $clientSecret, 134 | string $code, 135 | string $redirectUri, 136 | ?string $codeVerifier = null 137 | ): array { 138 | $endpoint = 'https://www.reddit.com/api/v1/access_token'; 139 | 140 | $bodyParams = [ 141 | 'grant_type' => 'authorization_code', 142 | 'code' => $code, 143 | 'redirect_uri' => $redirectUri, 144 | ]; 145 | if ($codeVerifier !== null && $codeVerifier !== '') { 146 | $bodyParams['code_verifier'] = $codeVerifier; 147 | } 148 | 149 | $bodyString = http_build_query($bodyParams); 150 | 151 | $basic = base64_encode($clientId . ':' . ($clientSecret ?? '')); 152 | 153 | $request = $this->requestFactory 154 | ->createRequest('POST', $endpoint) 155 | ->withHeader('User-Agent', $this->config->getUserAgent()) 156 | ->withHeader('Content-Type', 'application/x-www-form-urlencoded') 157 | ->withHeader('Authorization', 'Basic ' . $basic); 158 | 159 | $request = $request->withBody($this->streamFactory->createStream($bodyString)); 160 | 161 | try { 162 | $response = $this->httpClient->sendRequest($request); 163 | } catch (\Throwable $e) { 164 | throw new AuthenticationException('Transport error during auth code exchange: ' . $e->getMessage(), 0, null, $e); 165 | } 166 | 167 | $status = $response->getStatusCode(); 168 | $raw = (string) $response->getBody(); 169 | if ($status !== 200) { 170 | throw new AuthenticationException('Reddit token exchange returned HTTP ' . $status, $status, $raw); 171 | } 172 | 173 | $data = json_decode($raw, true); 174 | if (!is_array($data) || !isset($data['access_token'])) { 175 | throw new AuthenticationException('Invalid authorization code response', $status, $raw); 176 | } 177 | 178 | return $data; 179 | } 180 | 181 | /** 182 | * Refresh access token using a refresh_token. 183 | * Returns decoded JSON as array, including new access_token and optionally refresh_token. 184 | * 185 | * @return array 186 | */ 187 | public function refreshAccessToken(string $clientId, string $clientSecret, string $refreshToken): array 188 | { 189 | $endpoint = 'https://www.reddit.com/api/v1/access_token'; 190 | 191 | $bodyParams = [ 192 | 'grant_type' => 'refresh_token', 193 | 'refresh_token' => $refreshToken, 194 | ]; 195 | $bodyString = http_build_query($bodyParams); 196 | 197 | $request = $this->requestFactory 198 | ->createRequest('POST', $endpoint) 199 | ->withHeader('User-Agent', $this->config->getUserAgent()) 200 | ->withHeader('Content-Type', 'application/x-www-form-urlencoded') 201 | ->withHeader('Authorization', 'Basic ' . base64_encode($clientId . ':' . $clientSecret)); 202 | 203 | $request = $request->withBody($this->streamFactory->createStream($bodyString)); 204 | 205 | try { 206 | $response = $this->httpClient->sendRequest($request); 207 | } catch (\Throwable $e) { 208 | throw new AuthenticationException('Transport error during token refresh: ' . $e->getMessage(), 0, null, $e); 209 | } 210 | 211 | $status = $response->getStatusCode(); 212 | $raw = (string) $response->getBody(); 213 | if ($status !== 200) { 214 | throw new AuthenticationException('Reddit token refresh returned HTTP ' . $status, $status, $raw); 215 | } 216 | 217 | $data = json_decode($raw, true); 218 | if (!is_array($data) || !isset($data['access_token'])) { 219 | throw new AuthenticationException('Invalid refresh response', $status, $raw); 220 | } 221 | 222 | return $data; 223 | } 224 | 225 | private function base64UrlEncode(string $bytes): string 226 | { 227 | $b64 = base64_encode($bytes); 228 | $url = strtr($b64, '+/', '-_'); 229 | return rtrim($url, '='); 230 | } 231 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | avansaber/php-reddit-api 2 | 3 | Modern, fluent, framework-agnostic Reddit API client for PHP (PSR-18/PSR-7/PSR-3). 4 | 5 | ![CI](https://github.com/avansaber/avansaber-php-reddit-api/actions/workflows/ci.yml/badge.svg) 6 | [![Packagist](https://img.shields.io/packagist/v/avansaber/php-reddit-api.svg)](https://packagist.org/packages/avansaber/php-reddit-api) 7 | [![Downloads](https://img.shields.io/packagist/dt/avansaber/php-reddit-api.svg)](https://packagist.org/packages/avansaber/php-reddit-api) 8 | 9 | Features 10 | - PSR-18 HTTP client and PSR-7/17 factories (bring your own client) 11 | - Typed DTOs and resources (`me`, `search`, `subreddit`, `user`) 12 | - Write actions (`vote`, `reply`) 13 | - Token storage abstraction and optional SQLite storage 14 | - Auto-refresh tokens on 401 15 | - Retries/backoff for 429/5xx 16 | 17 | Requirements 18 | - PHP 8.1+ 19 | - Any PSR-18 HTTP client and PSR-7/17 factories (auto-discovered via `php-http/discovery`) 20 | 21 | Installation 22 | ```bash 23 | composer require avansaber/php-reddit-api 24 | ``` 25 | 26 | To run the examples, install a PSR-18 client implementation (discovery will find it): 27 | ```bash 28 | composer require php-http/guzzle7-adapter guzzlehttp/guzzle 29 | ``` 30 | 31 | Getting Reddit API credentials 32 | - Log in to Reddit, open `https://www.reddit.com/prefs/apps`. 33 | - Click “create another app”. 34 | - For app-only reads, choose type “script”. For end-user auth, choose “web app” (Authorization Code) and set a valid redirect URI. 35 | - Fill name and description, then create. 36 | - Copy: 37 | - Client ID: the short string directly under your app name (next to the app icon). For “personal use script” apps this is a 14‑character string shown under the app name. 38 | - Client Secret: the value labeled “secret” on the app page (not present for “installed” apps). 39 | - Provide a descriptive User-Agent per Reddit policy, e.g. `yourapp/1.0 (by yourdomain.com; contact you@example.com)`. 40 | 41 | Quickstart 42 | ```php 43 | use Avansaber\RedditApi\Config\Config; 44 | use Avansaber\RedditApi\Http\RedditApiClient; 45 | use Http\Discovery\Psr17FactoryDiscovery; 46 | use Http\Discovery\Psr18ClientDiscovery; 47 | 48 | $config = new Config('avansaber-php-reddit-api/1.0; contact you@example.com'); 49 | $http = Psr18ClientDiscovery::find(); 50 | $psr17 = Psr17FactoryDiscovery::findRequestFactory(); 51 | $streamFactory = Psr17FactoryDiscovery::findStreamFactory(); 52 | 53 | $client = new RedditApiClient($http, $psr17, $streamFactory, $config); 54 | $client->withToken('YOUR_ACCESS_TOKEN'); 55 | $me = $client->me()->get(); 56 | ``` 57 | 58 | Authentication 59 | - App-only (client credentials) for read endpoints like search: 60 | ```php 61 | use Avansaber\RedditApi\Auth\Auth; 62 | use Avansaber\RedditApi\Config\Config; 63 | use Http\Discovery\Psr18ClientDiscovery; use Http\Discovery\Psr17FactoryDiscovery; 64 | 65 | $http = Psr18ClientDiscovery::find(); 66 | $psr17 = Psr17FactoryDiscovery::findRequestFactory(); 67 | $streamFactory = Psr17FactoryDiscovery::findStreamFactory(); 68 | $config = new Config('yourapp/1.0 (by yourdomain.com; contact you@example.com)'); 69 | $auth = new Auth($http, $psr17, $streamFactory, $config); 70 | $accessToken = $auth->appOnly('CLIENT_ID', 'CLIENT_SECRET', ['read','identity']); 71 | ``` 72 | - Using an existing user token: 73 | - Set `REDDIT_ACCESS_TOKEN` and run `examples/me.php`. 74 | Authorization Code + PKCE 75 | - Generate PKCE pair and build the authorize URL, then exchange the code on callback: 76 | ```php 77 | use Avansaber\RedditApi\Auth\Auth; 78 | use Avansaber\RedditApi\Config\Config; 79 | use Http\Discovery\Psr18ClientDiscovery; use Http\Discovery\Psr17FactoryDiscovery; 80 | 81 | $http = Psr18ClientDiscovery::find(); 82 | $psr17 = Psr17FactoryDiscovery::findRequestFactory(); 83 | $streamFactory = Psr17FactoryDiscovery::findStreamFactory(); 84 | $config = new Config('yourapp/1.0 (by yourdomain.com; contact you@example.com)'); 85 | $auth = new Auth($http, $psr17, $streamFactory, $config); 86 | 87 | $pkce = $auth->generatePkcePair(); // ['verifier' => '...', 'challenge' => '...'] 88 | $url = $auth->getAuthUrl('CLIENT_ID', 'https://yourapp/callback', ['identity','read','submit'], 'csrf123', $pkce['challenge']); 89 | // Redirect user to $url 90 | 91 | // In your callback handler: 92 | $tokens = $auth->getAccessTokenFromCode('CLIENT_ID', null, $_GET['code'], 'https://yourapp/callback', $pkce['verifier']); 93 | // $tokens contains access_token, refresh_token, expires_in, scope 94 | ``` 95 | 96 | Temporary manual Authorization Code exchange (for testing) 97 | ```bash 98 | # After you obtain ?code=... from the authorize redirect 99 | curl -A "macos:avansaber-php-reddit-api:0.1 (by /u/YourRedditUsername)" \ 100 | -u 'CLIENT_ID:CLIENT_SECRET' \ 101 | -d 'grant_type=authorization_code&code=PASTE_CODE&redirect_uri=http://localhost:8080/callback' \ 102 | https://www.reddit.com/api/v1/access_token 103 | 104 | export REDDIT_USER_AGENT="macos:avansaber-php-reddit-api:0.1 (by /u/YourRedditUsername)" 105 | export REDDIT_ACCESS_TOKEN=PASTE_ACCESS_TOKEN 106 | php examples/me.php 107 | ``` 108 | 109 | Scopes 110 | - Reddit uses space-separated scopes when requesting tokens. Common scopes used by this package: 111 | 112 | | Scope | Description | Used by | 113 | | ---------------- | -------------------------------- | --------------------------------------- | 114 | | identity | Verify the current user | `me()` | 115 | | read | Read public data | `search()`, `subreddit()->about()`, `user()->about()` | 116 | | vote | Vote on posts and comments | `links()->upvote()`, `downvote()`, `unvote()` | 117 | | submit | Submit links or comments | `links()->reply()` | 118 | | privatemessages | Send/read private messages | Private messages (planned) | 119 | 120 | Authorization Code + PKCE (placeholder) 121 | - A guided example will be added soon. It will cover: 122 | - Generating a code verifier/challenge (S256) 123 | - Building the authorize URL with scopes and state 124 | - Handling the redirect URI and exchanging the authorization code for tokens 125 | - Storing tokens (including refresh token) securely and auto-refreshing 126 | - Example controller/route snippets (and Laravel bridge) 127 | - Until then, you can use app-only auth for read operations, or supply an existing user token via `REDDIT_ACCESS_TOKEN`. 128 | 129 | Common usage 130 | - Search posts: 131 | ```php 132 | $listing = $client->search()->get('php', ['limit' => 5, 'sort' => 'relevance']); 133 | foreach ($listing->items as $post) { 134 | echo $post->title . "\n"; 135 | } 136 | ``` 137 | - Pagination helper example: 138 | ```php 139 | $first = $client->search()->get('php', ['limit' => 100]); 140 | foreach ($first->iterate(fn($after) => $client->search()->get('php', ['limit' => 100, 'after' => $after])) as $post) { 141 | // handle $post (Link DTO) across multiple pages 142 | } 143 | ``` 144 | - User history (comments/submitted): 145 | ```php 146 | $comments = $client->user()->comments('spez', ['limit' => 10]); 147 | $posts = $client->user()->submitted('spez', ['limit' => 10]); 148 | ``` 149 | - Subreddit info: `$sr = $client->subreddit()->about('php');` 150 | - User info: `$u = $client->user()->about('spez');` 151 | - Voting and replying (requires user-context token with proper scopes): 152 | ```php 153 | $client->links()->upvote('t3_abc123'); 154 | $comment = $client->links()->reply('t3_abc123', 'Nice post!'); 155 | ``` 156 | - Private messages inbox: 157 | ```php 158 | $inbox = $client->messages()->inbox(['limit' => 10]); 159 | ``` 160 | - Basic moderation: 161 | ```php 162 | $client->moderation()->approve('t3_abc123'); 163 | $client->moderation()->remove('t3_abc123', true); 164 | ``` 165 | 166 | Rate limiting and retries 167 | - Reddit returns `x-ratelimit-remaining`, `x-ratelimit-used`, `x-ratelimit-reset` headers. 168 | - The client parses these headers and exposes the latest via `$client->getLastRateLimitInfo()`. 169 | - The client retries 429/5xx with exponential backoff and respects `Retry-After` when present. 170 | 171 | Error handling 172 | - Methods throw `Avansaber\RedditApi\Exceptions\RedditApiException` on non-2xx. 173 | - You can inspect `getStatusCode()` and `getResponseBody()` for details. 174 | 175 | HTTP client setup 176 | - By default we use discovery to find a PSR-18 client and PSR-7/17 factories. 177 | - Alternatively, install and wire your own (e.g., Guzzle + Nyholm PSR-7) and pass them to the constructor. 178 | 179 | ### Framework integration (Laravel, CodeIgniter, etc.) 180 | - Works in any framework as long as a PSR-18 client and PSR-7/17 factories are available (use discovery): 181 | ```php 182 | use Avansaber\RedditApi\Config\Config; 183 | use Avansaber\RedditApi\Http\RedditApiClient; 184 | use Http\Discovery\Psr18ClientDiscovery; 185 | use Http\Discovery\Psr17FactoryDiscovery; 186 | 187 | $http = Psr18ClientDiscovery::find(); 188 | $psr17 = Psr17FactoryDiscovery::findRequestFactory(); 189 | $streams = Psr17FactoryDiscovery::findStreamFactory(); 190 | $config = new Config(getenv('REDDIT_USER_AGENT')); 191 | $client = new RedditApiClient($http, $psr17, $streams, $config); 192 | ``` 193 | 194 | - Laravel (until the dedicated bridge is released) 195 | - In `App\Providers\AppServiceProvider` → `register()`: 196 | ```php 197 | use Avansaber\RedditApi\Config\Config; 198 | use Avansaber\RedditApi\Http\RedditApiClient; 199 | use Http\Discovery\Psr18ClientDiscovery; 200 | use Http\Discovery\Psr17FactoryDiscovery; 201 | 202 | public function register(): void 203 | { 204 | $this->app->singleton(RedditApiClient::class, function () { 205 | $http = Psr18ClientDiscovery::find(); 206 | $psr17 = Psr17FactoryDiscovery::findRequestFactory(); 207 | $streams = Psr17FactoryDiscovery::findStreamFactory(); 208 | $config = new Config(config('services.reddit.user_agent')); 209 | return new RedditApiClient($http, $psr17, $streams, $config); 210 | }); 211 | } 212 | ``` 213 | - In `config/services.php`: 214 | ```php 215 | 'reddit' => [ 216 | 'client_id' => env('REDDIT_CLIENT_ID'), 217 | 'client_secret' => env('REDDIT_CLIENT_SECRET'), 218 | 'user_agent' => env('REDDIT_USER_AGENT'), 219 | ], 220 | ``` 221 | - Example usage in a controller: 222 | ```php 223 | public function search(\Avansaber\RedditApi\Http\RedditApiClient $client) 224 | { 225 | // For app-only reads you can fetch a token via Auth::appOnly and call withToken() 226 | // $token = ...; $client->withToken($token); 227 | return response()->json($client->search()->get('php', ['limit' => 5])); 228 | } 229 | ``` 230 | - For user-context (vote/reply), obtain a user access token (e.g., Socialite Providers: Reddit, or README’s temporary curl step) and call `$client->withToken($userAccessToken)`. 231 | 232 | - CodeIgniter 4 233 | - Create a service in `app/Config/Services.php` that returns `RedditApiClient` using discovery (same as above), then type-hint it in controllers. 234 | - Provide env keys: `REDDIT_USER_AGENT`, `REDDIT_CLIENT_ID`, `REDDIT_CLIENT_SECRET`. 235 | 236 | Examples 237 | - App-only + Search: `examples/app_only_search.php` 238 | - Me endpoint with existing token: `examples/me.php` 239 | 240 | Laravel 241 | - See `laravel-plan.md` for the planned bridge package. 242 | 243 | Troubleshooting (403 "whoa there, pardner!") 244 | - Reddit may block requests based on IP/UA policies (common with VPN/DC IPs or generic UAs). 245 | - Use a descriptive UA including your Reddit username, e.g. `macos:avansaber-php-reddit-api:0.1 (by /u/YourRedditUsername)`. 246 | - Run from a residential network; avoid VPN/corporate IPs. Add small delays between calls. 247 | - If still blocked, file a ticket with Reddit and include the block code from the response page. 248 | 249 | Security notes 250 | - Treat client secrets and access tokens as sensitive. Use environment variables and do not commit them. 251 | - Rotate secrets if they were exposed during testing. 252 | 253 | Contributing 254 | - See CONTRIBUTING.md 255 | 256 | Changelog 257 | - See `CHANGELOG.md`. We follow Conventional Commits and tag releases. 258 | 259 | Security 260 | - See SECURITY.md 261 | 262 | License 263 | - MIT 264 | 265 | --------------------------------------------------------------------------------