diff --git a/composer.json b/composer.json index 7d63ee1..40d18e5 100644 --- a/composer.json +++ b/composer.json @@ -25,8 +25,8 @@ "ext-json": "*", "illuminate/support": "^9.0 || ^10.0", "nesbot/carbon": "^2.53.1", - "sammyjo20/saloon": "^1.5.3", - "sammyjo20/saloon-laravel": "^1.5.0", + "sammyjo20/saloon": "^2.0", + "sammyjo20/saloon-laravel": "^2.0", "spatie/laravel-data": "^3.1", "symfony/css-selector": "^5.4 || ^6.0", "symfony/dom-crawler": "^6.1", diff --git a/src/Exceptions/BadGatewayException.php b/src/Exceptions/BadGatewayException.php deleted file mode 100644 index 8395003..0000000 --- a/src/Exceptions/BadGatewayException.php +++ /dev/null @@ -1,7 +0,0 @@ -toPsrResponse()->getBody()->getContents(); - - return new static($response, $body, $response->status(), $response->getGuzzleException()); - } - - public function getResponse(): SaloonResponse - { - return $this->getSaloonResponse(); - } -} diff --git a/src/Exceptions/ClientException.php b/src/Exceptions/ClientException.php deleted file mode 100644 index d287a1f..0000000 --- a/src/Exceptions/ClientException.php +++ /dev/null @@ -1,7 +0,0 @@ -values(), total: $pagination['total'], perPage: $pagination['end'] - $pagination['start'], + currentPage: $this->currentPageFromSelect($crawler->filter('#banlist table + #banlist-nav select')), ); } } diff --git a/src/Extractors/Banlist/FluentExtractor.php b/src/Extractors/Banlist/FluentExtractor.php index 3c79eb3..b1adf13 100644 --- a/src/Extractors/Banlist/FluentExtractor.php +++ b/src/Extractors/Banlist/FluentExtractor.php @@ -65,6 +65,7 @@ public function handle(Crawler $crawler): ?LengthAwarePaginator ->values(), total: $pagination['total'], perPage: $pagination['end'] - $pagination['start'], + currentPage: $this->currentPageFromSelect($crawler->filter('#banlist table + #banlist-nav select')), ); } } diff --git a/src/Extractors/Extractor.php b/src/Extractors/Extractor.php index aae6f05..30e6dbe 100644 --- a/src/Extractors/Extractor.php +++ b/src/Extractors/Extractor.php @@ -82,4 +82,13 @@ protected function paginationFromString(string $value): array 'total' => (int) $matches[3], ]; } + + protected function currentPageFromSelect(Crawler $select): int + { + return rescue( + callback: fn () => $select->filter('option[selected]')->attr('value'), + report: false, + rescue: 1, + ); + } } diff --git a/src/Paginator/FirstResponsePagedPaginator.php b/src/Paginator/FirstResponsePagedPaginator.php new file mode 100644 index 0000000..c145b40 --- /dev/null +++ b/src/Paginator/FirstResponsePagedPaginator.php @@ -0,0 +1,80 @@ +limit = null; + } + + protected function applyPagination(Request $request): void + { + $request->query()->add($this->getPageKeyName(), $this->getCurrentPage()); + } + + public function limit(): int + { + if (is_null($this->currentResponse)) { + $this->current(); + } + + if (is_null($this->limit)) { + $this->limit = call_user_func($this->limitCallback, $this->currentResponse); + + if (is_null($this->limit)) { + throw new PaginatorException('Unable to calculate the limit from the response. Make sure the limit callback is correct.'); + } + } + + return $this->limit; + } + + public function totalResults(): int + { + if (is_null($this->currentResponse)) { + $this->current(); + } + + if (is_null($this->total)) { + $this->total = call_user_func($this->totalCallback, $this->currentResponse); + + if (is_null($this->total)) { + throw new PaginatorException('Unable to calculate the total results from the response. Make sure the callback key is correct.'); + } + } + + return $this->total; + } + + public function totalPages(): int + { + return (int) ceil($this->totalResults() / $this->limit()); + } + + protected function isFinished(): bool + { + return $this->getCurrentPage() > $this->totalPages(); + } +} diff --git a/src/Requests/QueryBansRequest.php b/src/Requests/QueryBansRequest.php index 55401e5..25ba55f 100644 --- a/src/Requests/QueryBansRequest.php +++ b/src/Requests/QueryBansRequest.php @@ -7,30 +7,33 @@ use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Str; use OutOfBoundsException; -use Sammyjo20\Saloon\Http\SaloonRequest; -use Sammyjo20\Saloon\Http\SaloonResponse; -use Sammyjo20\Saloon\Traits\Plugins\CastsToDto; +use Saloon\Contracts\Response; +use Saloon\Enums\Method; +use Saloon\Http\Request; +use Saloon\Traits\Request\CastDtoFromResponse; use SteamID; -class QueryBansRequest extends SaloonRequest +class QueryBansRequest extends Request { - use CastsToDto; + use CastDtoFromResponse; - protected ?string $method = 'GET'; + protected Method $method = Method::GET; public function __construct( - public readonly int $page = 1, public readonly ?SteamID $steamid = null, public readonly ?DateTimeInterface $date = null, - public readonly ?int $perPage = null, ) { } + public function resolveEndpoint(): string + { + return ''; + } + public function defaultQuery(): array { return array_merge([ 'p' => 'banlist', - 'page' => $this->page, ], $this->filter()); } @@ -46,7 +49,7 @@ protected function filter(): array }; } - protected function castToDto(SaloonResponse $response): ?LengthAwarePaginator + public function createDtoFromResponse(Response $response): ?LengthAwarePaginator { $crawler = $response->dom(); @@ -55,24 +58,12 @@ protected function castToDto(SaloonResponse $response): ?LengthAwarePaginator ->first(fn (Extractor $extractor) => $extractor->canHandle($crawler)); if ($extractor === null) { - throw new OutOfBoundsException("[{$response->getOriginalRequest()->getFullRequestUrl()}] is not supported by any extractor."); + throw new OutOfBoundsException("[{$response->getPendingRequest()->getUrl()}] is not supported by any extractor."); } - $paginator = $extractor->handle($crawler); - - if ($paginator === null) { - return null; - } + return $extractor->handle($crawler) + ?->setPath($response->getPendingRequest()->getUrl()) + ?->appends($response->getPendingRequest()->query()->all()); - return new LengthAwarePaginator( - items: $paginator->items(), - total: $paginator->total(), - perPage: max($paginator->perPage(), $this->perPage), - currentPage: $this->page, - options: [ - 'path' => $response->getOriginalRequest()->getFullRequestUrl(), - 'query' => $response->getOriginalRequest()->getQuery(), - ] - ); } } diff --git a/src/Responses/SourceBansResponse.php b/src/Responses/SourceBansResponse.php deleted file mode 100644 index 9ca15e0..0000000 --- a/src/Responses/SourceBansResponse.php +++ /dev/null @@ -1,25 +0,0 @@ -clientError() => ClientException::fromResponse($this), - $this->serverError() => match ($this->status()) { - 502 => BadGatewayException::fromResponse($this), - default => ServerException::fromResponse($this), - }, - $this->failed() => BadResponseException::fromResponse($this), - default => null, - }; - } -} diff --git a/src/SourceBansConnector.php b/src/SourceBansConnector.php index 6f790f8..35da4a9 100644 --- a/src/SourceBansConnector.php +++ b/src/SourceBansConnector.php @@ -2,43 +2,67 @@ namespace Astrotomic\SourceBansSdk; +use Astrotomic\SourceBansSdk\Paginator\FirstResponsePagedPaginator; use Astrotomic\SourceBansSdk\Requests\QueryBansRequest; -use Astrotomic\SourceBansSdk\Responses\SourceBansResponse; use DateTimeInterface; use Illuminate\Pagination\LengthAwarePaginator; -use Sammyjo20\Saloon\Http\SaloonConnector; -use Sammyjo20\Saloon\Traits\Plugins\AlwaysThrowsOnErrors; +use Illuminate\Support\LazyCollection; +use Saloon\Contracts\HasPagination; +use Saloon\Contracts\Request; +use Saloon\Contracts\Response; +use Saloon\Http\Connector; +use Saloon\Http\Paginators\PagedPaginator; +use Saloon\Traits\Plugins\AlwaysThrowOnErrors; use SteamID; -class SourceBansConnector extends SaloonConnector +class SourceBansConnector extends Connector implements HasPagination { - use AlwaysThrowsOnErrors; - - protected ?string $response = SourceBansResponse::class; + use AlwaysThrowOnErrors; public function __construct( public readonly string $baseUrl, ) { } - public function defineBaseUrl(): string + public function resolveBaseUrl(): string { return $this->baseUrl; } public function queryBans( - int $page = 1, SteamID $steamid = null, DateTimeInterface $date = null, - int $perPage = null - ): ?LengthAwarePaginator { - return $this->send( - new QueryBansRequest( - page: $page, - steamid: $steamid, - date: $date, - perPage: $perPage - ) - )->dto(); + int $page = null, + ): LengthAwarePaginator|LazyCollection|null { + $request = new QueryBansRequest( + steamid: $steamid, + date: $date, + ); + + if (! is_null($page)) { + $request->query()->add('page', $page); + + return $this->send($request)->dto(); + } + + return $this->paginate($request) + ->collect() + ->map(fn (Response $response) => $response->dto()->items()) + ->collapse(); + } + + public function paginate(Request $request, ...$additionalArguments): PagedPaginator + { + $paginator = new FirstResponsePagedPaginator( + connector: $this, + originalRequest: $request, + limitCallback: fn (Response $response) => $response->dtoOrFail()->perPage(), + totalCallback: fn (Response $response) => $response->dtoOrFail()->total(), + ); + + $paginator->setLimitKeyName('_limit'); + $paginator->setPageKeyName('page'); + + return $paginator; } } diff --git a/tests/Feature/Requests/QueryBansRequestTest.php b/tests/Feature/Requests/QueryBansRequestTest.php index 67badab..c2468a1 100644 --- a/tests/Feature/Requests/QueryBansRequestTest.php +++ b/tests/Feature/Requests/QueryBansRequestTest.php @@ -3,10 +3,11 @@ use Astrotomic\SourceBansSdk\Data\Ban; use Carbon\CarbonImmutable; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\LazyCollection; use PHPUnit\Framework\Assert; it('can load first page of bans', function (string $baseUrl): void { - $bans = $this->sourcebans($baseUrl)->queryBans(); + $bans = $this->sourcebans($baseUrl)->queryBans(page: 1); Assert::assertGreaterThanOrEqual(0, $bans->count()); Assert::assertLessThanOrEqual($bans->perPage(), $bans->count()); @@ -30,17 +31,26 @@ } })->with('baseurls')->with(range(1, 20)); +it('can load all bans', function (string $baseUrl): void { + $bans = $this->sourcebans($baseUrl)->queryBans(); + + Assert::assertGreaterThanOrEqual(0, $bans->count()); + Assert::assertContainsOnlyInstancesOf(Ban::class, $bans); +})->with([ + 'http://freiheit-servers.ru/bans/index.php', // default + 'https://www.weallplay.eu/sourcebans/index.php', // fluent +]); + it('can search for steamid', function (): void { $steamid = new SteamID('76561198928142028'); $bans = $this->sourcebans('https://firepoweredgaming.com/sourcebanspp/index.php')->queryBans(steamid: $steamid); - Assert::assertInstanceOf(LengthAwarePaginator::class, $bans); - Assert::assertSame(2, $bans->total()); + Assert::assertInstanceOf(LazyCollection::class, $bans); Assert::assertSame(2, $bans->count()); - Assert::assertContainsOnlyInstancesOf(Ban::class, $bans->items()); + Assert::assertContainsOnlyInstancesOf(Ban::class, $bans); - $bans->collect()->each(function (Ban $ban) use ($steamid): void { + $bans->each(function (Ban $ban) use ($steamid): void { Assert::assertSame($steamid->ConvertToUInt64(), $ban->steam_id->ConvertToUInt64()); Assert::assertSame(2, $ban->total_bans); }); @@ -51,12 +61,11 @@ $bans = $this->sourcebans('https://firepoweredgaming.com/sourcebanspp/index.php')->queryBans(date: $date); - Assert::assertInstanceOf(LengthAwarePaginator::class, $bans); - Assert::assertSame(3, $bans->total()); + Assert::assertInstanceOf(LazyCollection::class, $bans); Assert::assertSame(3, $bans->count()); - Assert::assertContainsOnlyInstancesOf(Ban::class, $bans->items()); + Assert::assertContainsOnlyInstancesOf(Ban::class, $bans); - $bans->collect()->each(function (Ban $ban) use ($date): void { + $bans->each(function (Ban $ban) use ($date): void { Assert::assertTrue($date->isSameDay($ban->invoked_on)); }); }); diff --git a/tests/Fixtures/Saloon/firepoweredgaming.com/GET/sourcebanspp/index.php/advSearch=0%3A483938150&advType=steam&p=banlist&page=1.json b/tests/Fixtures/Saloon/firepoweredgaming.com/GET/sourcebanspp/index.php/advSearch=0%3A483938150&advType=steam&p=banlist&page=1.json new file mode 100644 index 0000000..f166bbc --- /dev/null +++ b/tests/Fixtures/Saloon/firepoweredgaming.com/GET/sourcebanspp/index.php/advSearch=0%3A483938150&advType=steam&p=banlist&page=1.json @@ -0,0 +1 @@ +{"statusCode":200,"headers":{"Date":"Mon, 17 Jul 2023 10:30:44 GMT","Content-Type":"text\/html; charset=UTF-8","Transfer-Encoding":"chunked","Connection":"keep-alive","Set-Cookie":"SourceBans_Session=2ha234h0ql0u1rumfq8a2i90h6; expires=Tue, 18-Jul-2023 10:30:43 GMT; Max-Age=86400; path=\/; domain=firepoweredgaming.com; HttpOnly","Expires":"Thu, 19 Nov 1981 08:52:00 GMT","Cache-Control":"no-store, no-cache, must-revalidate, post-check=0, pre-check=0","Pragma":"no-cache","CF-Cache-Status":"DYNAMIC","Report-To":"{\"endpoints\":[{\"url\":\"https:\\\/\\\/a.nel.cloudflare.com\\\/report\\\/v3?s=XwJoAH804yuhqUTnhO5DAmJQYG5ULRSIokoao3lNp8VAvowFF42X424RTJzHJTMe5UIBrdui78B8zIzP95Xn8KJkiJoX3NfN6rz1R%2BYRHoqfhMy%2BJV8hvpYcFFBCRJtFlib6ywJ4CEFhtKigTMBTxznvmX8%3D\"}],\"group\":\"cf-nel\",\"max_age\":604800}","NEL":"{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}","Server":"cloudflare","CF-RAY":"7e81cfaafb24874d-DUS"},"data":"\n\n
\n\n