├── 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 | 
6 | [](https://packagist.org/packages/avansaber/php-reddit-api)
7 | [](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 |
--------------------------------------------------------------------------------