diff --git a/README.md b/README.md index 8797968..c18c7a5 100644 --- a/README.md +++ b/README.md @@ -1,179 +1,157 @@ -# BlueskySDK +

+ Logo +

+ +# BlueSky SDK + +![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/shahmal1yev/blueskysdk?label=latest&style=flat) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) +![GitHub last commit](https://img.shields.io/github/last-commit/shahmal1yev/blueskysdk) +![GitHub issues](https://img.shields.io/github/issues/shahmal1yev/blueskysdk) +![GitHub stars](https://img.shields.io/github/stars/shahmal1yev/blueskysdk) +![GitHub forks](https://img.shields.io/github/forks/shahmal1yev/blueskysdk) +![GitHub contributors](https://img.shields.io/github/contributors/shahmal1yev/blueskysdk) ## Project Description -BlueskySDK is a PHP library used to interact with the Bluesky API. This library allows you to perform file uploads, create records, and other operations using the Bluesky API. +BlueSky SDK is a PHP library used for interacting with the [BlueSky API](https://docs.bsky.app/docs/get-started). This library allows you to perform various +operations using the BlueSky API. ## Requirements -```json -"require-dev": { - "phpunit/phpunit": "9.6.20", - "fakerphp/faker": "^1.23", - "phpstan/phpstan": "^1.12" -}, -"require": { - "ext-json": "*", - "ext-curl": "*", - "ext-fileinfo": "*", - "php": ">=7.4", - "nesbot/carbon": "2.x", - "shahmal1yev/gcollection": "^1.0" -}, -``` +- **PHP**: 7.4 or newer +- **Composer**: [Dependency management tool](https://getcomposer.org/) for PHP ## Installation -```shell +To install BlueSky SDK via Composer, use the following command: + +```bash composer require shahmal1yev/blueskysdk ``` ## Usage -After including the library in your project, you can refer to the following examples: +Once installed, you can start using the SDK to interact with the BlueSky API. Below are examples of how to +authenticate and perform various operations using the library. +### Authentication and Basic Usage -### Get Profile +First, instantiate the `Client` class and authenticate using your BlueSky credentials: ```php -use Atproto\Clients\BlueskyClient; -use Atproto\API\App\Bsky\Actor\GetProfile; -use Atproto\Resources\App\Bsky\Actor\GetProfileResource; -use Atproto\Resources\Assets\LabelsAsset; -use Atproto\Resources\Assets\LabelAsset; -use Atproto\Resources\Assets\FollowersAsset; -use Atproto\Resources\Assets\FollowerAsset; +use Atproto\Client; +use Atproto\Resources\Com\Atproto\Server\CreateSessionResource; -$client = new BlueskyClient(new GetProfile()); +$client = new Client(); -$client->authenticate([ - 'identifier' => 'user@example.com', - 'password' => 'password' -]); - -/** @var GetProfileResource $user */ -$user = $client->send(); +// Authenticate using your identifier (e.g., email) and password +$client->authenticate($identifier, $password); -/** @var Carbon\Carbon $created */ -$created = $user->createdAt(); +// Once authenticated, you can retrieve the user's session resource +/** @var CreateSessionResource $session */ +$session = $client->authenticated(); +``` -/** @var LabelsAsset $labels */ -$labels = $user->labels(); +### Making Requests -/** @var FollowersAsset $knownFollowers */ -$knownFollowers = $user->viewer() - ->knownFollowers() - ->followers(); +BlueSky SDK provides a fluent interface to construct API requests. Use chained method calls to navigate through the +API lexicons and forge the request: -foreach($knownFollowers as $follower) { - /** @var FollowerAsset $follower */ - - $name = $follower->displayName(); - $createdAt = $follower->createdAt()->format(DATE_ATOM); - - echo "$name's account created at $createdAt"; -} +```php +use Atproto\Contracts\ResourceContract; + +// Example: Fetching a profile +$profile = $client->app() + ->bsky() + ->actor() + ->getProfile() + ->forge() + ->actor('some-actor-handle') // Specify the actor handle + ->send(); ``` -### File Upload +### Handling Responses + +BlueSky SDK supports both Resource and Castable interfaces, providing flexibility in handling API responses and +enabling smooth data manipulation and casting for a more streamlined development experience. + +Responses are returned as resource instances that implement the `ResourceContract`. These resources provide methods +for accessing data returned by the API. ```php -use Atproto\API\Com\Atrproto\Repo\UploadBlobRequest; -use Atproto\Clients\BlueskyClient; -use Atproto\Auth\Strategies\PasswordAuthentication; +// Retrieve properties from the profile +/** @var string $displayName */ +$displayName = $profile->displayName(); + +/** @var Carbon\Carbon $createdAt */ +$createdAt = $profile->createdAt(); +``` -$client = new BlueskyClient(new UploadBlobRequest); +### Working with Assets and Relationships -$client->setStrategy(new PasswordAuthentication) - ->authenticate([ - 'identifier' => 'user@example.com', - 'password' => 'password' - ]); +BlueSky SDK allows you to access complex assets like followers and labels directly through the resource instances. -$client->getRequest() - ->setBlob('/var/www/blueskysdk/assets/file.png'); +```php +use Atproto\Resources\Assets\FollowersAsset; +use Atproto\Resources\Assets\FollowerAsset; -$response = $client->execute(); +// Fetch the user's followers +/** @var FollowersAsset $followers */ +$followers = $profile->viewer() + ->knownFollowers() + ->followers(); -echo "Blob uploaded successfully. CID: {$response->cid}"; +foreach ($followers as $follower) { + /** @var FollowerAsset $follower */ + echo $follower->displayName() . " - Created at: " . $follower->createdAt()->format(DATE_ATOM) . "\n"; +} ``` -### Record Creation +### Example: Fetching Profile Information + +Here is a more complete example of fetching and displaying profile information, including created dates and labels: + ```php -use Atproto\API\Com\Atrproto\Repo\CreateRecordRequest; use Atproto\Clients\BlueskyClient; -use Atproto\Auth\Strategies\PasswordAuthentication; +use Atproto\API\App\Bsky\Actor\GetProfile; +use Atproto\Resources\App\Bsky\Actor\GetProfileResource; -$client = new BlueskyClient(new CreateRecordRequest); +$client = new BlueskyClient(new GetProfile()); -$client->setStrategy(new PasswordAuthentication) - ->authenticate([ - 'identifier' => 'user@example.com', - 'password' => 'password' - ]); - -$record = new \Atproto\Builders\Bluesky\RecordBuilder(); +$client->authenticate([ + 'identifier' => 'user@example.com', + 'password' => 'password' +]); -$record->addText("Hello World!") - ->addText("") - ->addText("I was sent via BlueskySDK: https://github.com/shahmal1yev/blueskysdk") - ->addCreatedAt(date_format(date_create_from_format("d/m/Y", "08/11/2020"), "c")) - ->addType(); +/** @var GetProfileResource $user */ +$user = $client->send(); -$client->getRequest() - ->setRecord($record); +// Output profile details +echo "Display Name: " . $user->displayName() . "\n"; +echo "Created At: " . $user->createdAt()->toDateTimeString() . "\n"; +echo "Labels: " . implode(', ', $user->labels()->pluck('name')->toArray()) . "\n"; -echo "Record created successfully. URI: {$response->uri}"; -``` -### Create Record (with blob) +// Accessing and iterating over followers +$followers = $user->viewer()->knownFollowers()->followers(); -```php -use Atproto\API\Com\Atrproto\Repo\UploadBlobRequest; -use Atproto\Auth\Strategies\PasswordAuthentication; -use Atproto\Clients\BlueskyClient; -use Atproto\API\Com\Atrproto\Repo\CreateRecordRequest; - -$client = new BlueskyClient(new UploadBlobRequest); - -$client->setStrategy(new PasswordAuthentication) - ->authenticate([ - 'identifier' => 'user@example.com', - 'password' => 'password' - ]); - -$client->getRequest() - ->setBlob('/var/www/blueskysdk/assets/file.png') - ->setHeaders([ - 'Content-Type' => $client->getRequest() - ->getBlob() - ->getMimeType() - ]); - -$image = $client->execute(); - -$client->setRequest(new CreateRecordRequest); - -$record = (new \Atproto\Builders\Bluesky\RecordBuilder) - ->addText("Hello World!") - ->addText("") - ->addText("I was sent from 'test BlueskyClient execute method with both UploadBlob and CreateRecord'") - ->addText("") - ->addText("Here are the pictures: ") - ->addImage($image->blob, "Image 1: Alt text") - ->addImage($image->blob, "Image 2: Alt text") - ->addType() - ->addCreatedAt(); - -$client->getRequest() - ->setRecord($record); - -$response = $client->execute(); +foreach ($followers as $follower) { + echo $follower->displayName() . " followed on " . $follower->createdAt()->format(DATE_ATOM) . "\n"; +} ``` +### Extending the SDK + +BlueSky SDK is built with extensibility in mind. You can add custom functionality by extending existing classes and +creating your own request and resource types. Follow the structure used in the SDK to maintain consistency. + ## Contribution -- If you find any bug or issue, please open an issue. -- If you want to contribute to the code, feel free to submit a pull request. + +We welcome contributions from the community! If you find any bugs or would like to add new features, feel free to: + +- **Open an issue**: Report bugs, request features, or suggest improvements. +- **Submit a pull request**: Contributions to the codebase are welcome. Please follow best practices and ensure that your code adheres to the existing architecture and coding standards. ## License -This project is licensed under the MIT License. For more information, see the LICENSE file. +BlueSky SDK is licensed under the MIT License. See the LICENSE file for full details. \ No newline at end of file diff --git a/assets/file.png b/art/file.png similarity index 100% rename from assets/file.png rename to art/file.png diff --git a/art/logo-small.webp b/art/logo-small.webp new file mode 100644 index 0000000..24d7735 Binary files /dev/null and b/art/logo-small.webp differ diff --git a/art/logo.webp b/art/logo.webp new file mode 100644 index 0000000..26baa1b Binary files /dev/null and b/art/logo.webp differ diff --git a/composer.json b/composer.json index 18f0d11..1223b0b 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,31 @@ { "name": "shahmal1yev/blueskysdk", - "description": "BlueskySDK is a PHP library for easy integration with the Bluesky API, enabling effortless file uploads, record creation, and authentication in PHP applications.", + "description": "BlueSky SDK is a PHP library used for interacting with the BlueSky API. This library allows you to perform various operations using the BlueSky API.", + "keywords": [ + "bluesky", + "sdk", + "api", + "social-network", + "atproto", + "decentralized", + "php", + "client", + "library", + "wrapper", + "federation", + "microblogs", + "web3" + ], "minimum-stability": "stable", "license": "mit", "autoload": { "psr-4": { "Atproto\\": "src/", "Tests\\": "tests/" - } + }, + "files": [ + "src/helpers.php" + ] }, "require-dev": { "phpunit/phpunit": "9.6.20", diff --git a/composer.lock b/composer.lock index 53215ae..93b0564 100644 --- a/composer.lock +++ b/composer.lock @@ -1040,16 +1040,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.2", + "version": "1.12.3", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "0ca1c7bb55fca8fe6448f16fff0f311ccec960a1" + "reference": "0fcbf194ab63d8159bb70d9aa3e1350051632009" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0ca1c7bb55fca8fe6448f16fff0f311ccec960a1", - "reference": "0ca1c7bb55fca8fe6448f16fff0f311ccec960a1", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0fcbf194ab63d8159bb70d9aa3e1350051632009", + "reference": "0fcbf194ab63d8159bb70d9aa3e1350051632009", "shasum": "" }, "require": { @@ -1094,7 +1094,7 @@ "type": "github" } ], - "time": "2024-09-05T16:09:28+00:00" + "time": "2024-09-09T08:10:35+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/src/API/App/Bsky/Actor/GetProfile.php b/src/API/App/Bsky/Actor/GetProfile.php index a5bfde8..1337e1e 100644 --- a/src/API/App/Bsky/Actor/GetProfile.php +++ b/src/API/App/Bsky/Actor/GetProfile.php @@ -13,6 +13,8 @@ * The GetProfile class represents a request to retrieve a user's profile information from the Bluesky API. * * This class implements the RequestContract interface, providing methods to construct and handle the request. + * + * @deprecated This class deprecated and will be removed in a future version. */ class GetProfile implements RequestContract { diff --git a/src/API/Com/Atrproto/Repo/CreateRecord.php b/src/API/Com/Atrproto/Repo/CreateRecord.php index 4b9bd8c..3183e01 100644 --- a/src/API/Com/Atrproto/Repo/CreateRecord.php +++ b/src/API/Com/Atrproto/Repo/CreateRecord.php @@ -8,6 +8,9 @@ use Atproto\Resources\Com\Atproto\Repo\CreateRecordResource; use InvalidArgumentException; +/** + * @deprecated This class deprecated and will be removed in a future version. + */ class CreateRecord implements RequestContract { /** @var object $body The request body */ @@ -21,6 +24,11 @@ class CreateRecord implements RequestContract public function __construct() { + trigger_error( + "This class deprecated and will be removed in a future version.", + E_USER_DEPRECATED + ); + $this->body = (object) [ 'repo' => '', 'rkey' => '', diff --git a/src/API/Com/Atrproto/Repo/UploadBlob.php b/src/API/Com/Atrproto/Repo/UploadBlob.php index 7ac11b0..6a5c498 100644 --- a/src/API/Com/Atrproto/Repo/UploadBlob.php +++ b/src/API/Com/Atrproto/Repo/UploadBlob.php @@ -12,6 +12,8 @@ * Class UploadBlobRequest * * Represents a request to upload a blob. + * + * @deprecated This class deprecated and will be removed in a future version. */ class UploadBlob implements RequestContract { @@ -34,6 +36,11 @@ class UploadBlob implements RequestContract */ public function __construct() { + trigger_error( + "This class deprecated and will be removed in a future version.", + E_USER_DEPRECATED + ); + $this->body = (object) []; } diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..4e5e438 --- /dev/null +++ b/src/Client.php @@ -0,0 +1,18 @@ + 'application/json', + 'Accept' => 'application/json', + ]; + + public function build(): RequestContract; +} diff --git a/src/Contracts/RequestContract.php b/src/Contracts/RequestContract.php new file mode 100644 index 0000000..abcdcd1 --- /dev/null +++ b/src/Contracts/RequestContract.php @@ -0,0 +1,95 @@ +origin(self::API_BASE_URL) + ->headers(self::API_BASE_HEADERS); + + if ($prefix) { + $this->path($this->routePath($prefix)); + } + } + + public function send(): ResourceContract + { + $response = parent::send(); + return $this->resource($response); + } + + private function routePath(string $prefix): string + { + $classNamespace = static::class; + + if (strpos($classNamespace, $prefix) === 0) { + $routePath = substr($classNamespace, strlen($prefix)); + } else { + $routePath = $classNamespace; + } + + $routeParts = explode("\\", $routePath); + $routePath = array_reduce( + $routeParts, + fn ($carry, $part) => $carry .= ".".lcfirst($part) + ); + + return "/xrpc/" . trim($routePath, '.'); + } + + abstract public function resource(array $data): ResourceContract; +} diff --git a/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php b/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php new file mode 100644 index 0000000..7f037d1 --- /dev/null +++ b/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php @@ -0,0 +1,88 @@ +prefix()); + + if (! $client->authenticated()) { + return; + } + + try { + $this->header('Authorization', 'Bearer ' . $client->authenticated()->accessJwt()); + $this->queryParameter('actor', $client->authenticated()->did()); + } catch (AuthRequired $e) {} + } + + /** + * @return RequestContract|string + */ + public function actor(string $actor = null) + { + if (is_null($actor)) { + return $this->queryParameter('actor'); + } + + $this->queryParameter('actor', $actor); + + return $this; + } + + /** + * @return RequestContract|string + */ + public function token(string $token = null) + { + if (is_null($token)) { + return $this->header('Authorization'); + } + + $this->header('Authorization', "Bearer $token"); + + return $this; + } + + /** + * @throws Exception + */ + public function build(): RequestContract + { + $missing = []; + + if (! $this->queryParameter('actor')) { + $missing[] = 'actor'; + } + + if (! $this->header('Authorization')) { + $missing[] = 'token'; + } + + if (! empty($missing)) { + throw new MissingFieldProvidedException(implode(", ", $missing)); + } + + return $this; + } + + public function resource(array $data): ResourceContract + { + return new GetProfileResource($data); + } +} \ No newline at end of file diff --git a/src/HTTP/API/Requests/Com/Atproto/Repo/CreateRecord.php b/src/HTTP/API/Requests/Com/Atproto/Repo/CreateRecord.php new file mode 100644 index 0000000..31279c2 --- /dev/null +++ b/src/HTTP/API/Requests/Com/Atproto/Repo/CreateRecord.php @@ -0,0 +1,107 @@ +parameter('repo') ?? null; + } + + $this->parameter('repo', $repo); + + return $this; + } + + public function collection(string $collection = null) + { + if (is_null($collection)) { + return $this->parameter('collection') ?? null; + } + + $this->parameter('collection', $collection); + + return $this; + } + + public function rkey(string $rkey = null) + { + if (is_null($rkey)) { + return $this->parameter('rkey') ?? null; + } + + $this->parameter('rkey', $rkey); + + return $this; + } + + public function validate(bool $validate = null) + { + if (is_null($validate)) { + return $this->parameter('validate') ?? null; + } + + $this->parameter('validate', $validate); + + return $this; + } + + public function record(object $record = null) + { + if (is_null($record)) { + return $this->parameter('record') ?? null; + } + + $this->parameter('record', $record); + + return $this; + } + + public function swapCommit(string $swapCommit = null) + { + if (is_null($swapCommit)) { + return $this->parameter('swapCommit') ?? null; + } + + $this->parameter('swapCommit', $swapCommit); + + return $this; + } + + /** + * @throws MissingFieldProvidedException + */ + public function build(): RequestContract + { + $parameters = array_keys($this->parameters(false)); + $missing = array_diff( + $this->required, + $parameters + ); + + if (count($missing)) { + throw new MissingFieldProvidedException(implode(", ", $missing)); + } + + return $this; + } + + public function resource(array $data): ResourceContract + { + return new CreateRecordResource($data); + } +} diff --git a/src/HTTP/API/Requests/Com/Atproto/Repo/UploadBlob.php b/src/HTTP/API/Requests/Com/Atproto/Repo/UploadBlob.php new file mode 100644 index 0000000..2d4b71c --- /dev/null +++ b/src/HTTP/API/Requests/Com/Atproto/Repo/UploadBlob.php @@ -0,0 +1,62 @@ +blob; + } + + $this->blob = $blob; + + return $this; + } + + public function token(string $token = null) + { + if (is_null($token)) { + return $this->token; + } + + $this->token = $token; + + $this->header('Authorization', "Bearer $this->token"); + + return $this; + } + + /** + * @throws MissingFieldProvidedException + */ + public function build(): RequestContract + { + $missing = array_filter( + [$this->token => 'token', $this->blob => 'blob'], + fn ($key, $value) => ! $value, + ARRAY_FILTER_USE_BOTH + ); + + if (count($missing)) { + throw new MissingFieldProvidedException(implode(", ", $missing)); + } + + return $this; + } + + public function resource(array $data): ResourceContract + { + return new UploadBlobResource($data); + } +} diff --git a/src/HTTP/API/Requests/Com/Atproto/Server/CreateSession.php b/src/HTTP/API/Requests/Com/Atproto/Server/CreateSession.php new file mode 100644 index 0000000..b56ad03 --- /dev/null +++ b/src/HTTP/API/Requests/Com/Atproto/Server/CreateSession.php @@ -0,0 +1,31 @@ +method('POST')->origin('https://bsky.social/')->parameters([ + 'identifier' => $username, + 'password' => $password, + ]); + } + + public function build(): RequestContract + { + return $this; + } + + public function resource(array $data): ResourceContract + { + return new CreateSessionResource($data); + } +} diff --git a/src/HTTP/Request.php b/src/HTTP/Request.php new file mode 100644 index 0000000..f476f4b --- /dev/null +++ b/src/HTTP/Request.php @@ -0,0 +1,13 @@ + trim($part, "/"), [ + 'origin' => $this->origin(), + 'path' => $this->path(), + 'query' => $this->queryParameters(true), + ]); + + $url = sprintf("%s/%s?%s", $parts['origin'], $parts['path'], $parts['query']); + + return $url; + } + + public function origin(string $origin = null) + { + if (is_null($origin)) { + return $this->origin; + } + + $this->origin = rtrim($origin, "/"); + + return $this; + } + + public function path(string $path = null) + { + if (is_null($path)) { + return $this->path; + } + + $this->path = trim($path, "/"); + + return $this; + } + + public function method(string $method = null) + { + if (is_null($method)) { + return $this->method; + } + + $this->method = $method; + + return $this; + } + + public function header(string $name, string $value = null) + { + if (is_null($value)) { + return $this->headers[$name] ?? null; + } + + $this->headers[$name] = $value; + + return $this; + } + + public function parameter(string $name, $value = null) + { + if (is_null($value)) { + return $this->parameters[$name] ?? null; + } + + $this->parameters[$name] = $value; + + return $this; + } + + public function queryParameter(string $name, string $value = null) + { + if (is_null($value)) { + return $this->queryParameters[$name] ?? null; + } + + $this->queryParameters[$name] = $value; + + return $this; + } + + public function headers($headers = null) + { + if (is_bool($headers)) { + if ($headers) { + return array_map( + fn ($name, $value) => "$name: $value", + array_keys($this->headers), + array_values($this->headers) + ); + } + + return $this->headers; + } + + if (is_null($headers)) { + return $this->headers; + } + + $this->headers = $headers; + + return $this; + } + + public function parameters($parameters = null) + { + if (is_bool($parameters)) { + if ($parameters) { + return json_encode($this->parameters); + } + + return $this->parameters; + } + + if (is_null($parameters)) { + return $this->parameters; + } + + $this->parameters = $parameters; + + return $this; + } + + public function queryParameters($queryParameters = null) + { + if (is_bool($queryParameters) && $queryParameters) { + return http_build_query($this->queryParameters); + } + + if (is_null($queryParameters)) { + return $this->queryParameters; + } + + $this->queryParameters = $queryParameters; + + return $this; + } +} diff --git a/src/HTTP/Traits/RequestHandler.php b/src/HTTP/Traits/RequestHandler.php new file mode 100644 index 0000000..74cffdc --- /dev/null +++ b/src/HTTP/Traits/RequestHandler.php @@ -0,0 +1,110 @@ +request(); + $this->handle(); + + return $this->content; + } + + /** + * @throws BlueskyException + */ + private function handle(): void + { + $this->handleError(); + $this->handleResponse(); + } + + /** + * @throws BlueskyException + */ + private function handleResponse(): void + { + $this->content = json_decode($this->content, true); + $this->handleResponseError(); + } + + /** + * @throws BlueskyException + */ + private function handleResponseError(): void + { + $statusCode = curl_getinfo($this->resource, CURLINFO_HTTP_CODE); + + if ($statusCode < 200 || $statusCode > 299) { + $exception = "\\Atproto\\Exceptions\\Http\\Response\\{$this->content['error']}Exception"; + + if (class_exists($exception)) { + throw new $exception($this->content['message'], $statusCode); + } + + throw new BlueskyException($this->content['message'] ?? "Unknown error.", $statusCode); + } + } + + private function handleResponseHeader($ch, $header): int + { + $length = strlen($header); + $header = explode(':', $header, 2); + + if (count($header) < 2) { + return $length; + } + + $name = trim(current($header)); + $value = trim(next($header)); + + $this->responseHeaders[$name] = $value; + + return $length; + } + + /** + * @throws cURLException + */ + private function handleError(): void + { + if (curl_errno($this->resource)) { + throw new cURLException(curl_error($this->resource)); + } + } + + private function request(): void + { + $this->resource = curl_init(); + + curl_setopt_array($this->resource, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $this->headers(true), + CURLOPT_HEADERFUNCTION => [$this, 'handleResponseHeader'], + CURLOPT_CUSTOMREQUEST => $this->method(), + CURLOPT_POSTFIELDS => $this->parameters(true), + CURLOPT_URL => $this->url(), + ]); + + $this->content = curl_exec($this->resource); + } +} diff --git a/src/Resources/Assets/AssociatedAsset.php b/src/Resources/Assets/AssociatedAsset.php index 474fa36..fd30e66 100644 --- a/src/Resources/Assets/AssociatedAsset.php +++ b/src/Resources/Assets/AssociatedAsset.php @@ -5,6 +5,7 @@ use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\Resources\BaseResource; +use Atproto\Traits\Castable; /** * @method int lists() @@ -17,6 +18,7 @@ class AssociatedAsset implements ResourceContract, AssetContract { use BaseResource; use BaseAsset; + use Castable; public function __construct($content) { diff --git a/src/Resources/Assets/BaseAsset.php b/src/Resources/Assets/BaseAsset.php index 5d10c92..9ffc96e 100644 --- a/src/Resources/Assets/BaseAsset.php +++ b/src/Resources/Assets/BaseAsset.php @@ -3,7 +3,6 @@ namespace Atproto\Resources\Assets; use Atproto\Contracts\HTTP\Resources\AssetContract; -use GenericCollection\Exceptions\InvalidArgumentException; trait BaseAsset { diff --git a/src/Resources/Assets/BlockingByListAsset.php b/src/Resources/Assets/BlockingByListAsset.php index ee3565f..18a90bb 100644 --- a/src/Resources/Assets/BlockingByListAsset.php +++ b/src/Resources/Assets/BlockingByListAsset.php @@ -20,4 +20,4 @@ class BlockingByListAsset implements ResourceContract, AssetContract { use ByListAsset; -} \ No newline at end of file +} diff --git a/src/Resources/Assets/ByListAsset.php b/src/Resources/Assets/ByListAsset.php index 36968d0..2b80120 100644 --- a/src/Resources/Assets/ByListAsset.php +++ b/src/Resources/Assets/ByListAsset.php @@ -3,6 +3,7 @@ namespace Atproto\Resources\Assets; use Atproto\Resources\BaseResource; +use Atproto\Traits\Castable; use Carbon\Carbon; /** @@ -18,7 +19,9 @@ */ trait ByListAsset { - use BaseResource, BaseAsset; + use BaseResource; + use BaseAsset; + use Castable; public function __construct($content) { @@ -33,4 +36,4 @@ public function casts(): array 'indexedAt' => DatetimeAsset::class, ]; } -} \ No newline at end of file +} diff --git a/src/Resources/Assets/ChatAsset.php b/src/Resources/Assets/ChatAsset.php index b3f655f..ca442c5 100644 --- a/src/Resources/Assets/ChatAsset.php +++ b/src/Resources/Assets/ChatAsset.php @@ -11,7 +11,8 @@ */ class ChatAsset implements ResourceContract, AssetContract { - use BaseResource, BaseAsset; + use BaseResource; + use BaseAsset; public function __construct(array $content) { @@ -22,9 +23,4 @@ public function cast(): self { return $this; } - - protected function casts(): array - { - return []; - } -} \ No newline at end of file +} diff --git a/src/Resources/Assets/CollectionAsset.php b/src/Resources/Assets/CollectionAsset.php index dbd4904..adc74d1 100644 --- a/src/Resources/Assets/CollectionAsset.php +++ b/src/Resources/Assets/CollectionAsset.php @@ -28,4 +28,4 @@ public function cast(): self abstract protected function item($data): AssetContract; abstract protected function type(): TypeInterface; -} \ No newline at end of file +} diff --git a/src/Resources/Assets/DatetimeAsset.php b/src/Resources/Assets/DatetimeAsset.php index 88a3d7c..4616f2a 100644 --- a/src/Resources/Assets/DatetimeAsset.php +++ b/src/Resources/Assets/DatetimeAsset.php @@ -13,4 +13,4 @@ public function cast(): Carbon { return Carbon::parse($this->value); } -} \ No newline at end of file +} diff --git a/src/Resources/Assets/FollowerAsset.php b/src/Resources/Assets/FollowerAsset.php index 2457d86..a2e3944 100644 --- a/src/Resources/Assets/FollowerAsset.php +++ b/src/Resources/Assets/FollowerAsset.php @@ -18,4 +18,4 @@ class FollowerAsset implements ResourceContract, AssetContract { use UserAsset; -} \ No newline at end of file +} diff --git a/src/Resources/Assets/JoinedViaStarterPackAsset.php b/src/Resources/Assets/JoinedViaStarterPackAsset.php index 80a2315..097e381 100644 --- a/src/Resources/Assets/JoinedViaStarterPackAsset.php +++ b/src/Resources/Assets/JoinedViaStarterPackAsset.php @@ -5,6 +5,7 @@ use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\Resources\BaseResource; +use Atproto\Traits\Castable; use Carbon\Carbon; /** @@ -19,7 +20,9 @@ */ class JoinedViaStarterPackAsset implements ResourceContract, AssetContract { - use BaseResource, BaseAsset; + use BaseResource; + use BaseAsset; + use Castable; public function __construct($content) { @@ -34,4 +37,4 @@ public function casts(): array 'indexedAt' => DatetimeAsset::class, ]; } -} \ No newline at end of file +} diff --git a/src/Resources/Assets/KnownFollowersAsset.php b/src/Resources/Assets/KnownFollowersAsset.php index 45263f3..b2a90db 100644 --- a/src/Resources/Assets/KnownFollowersAsset.php +++ b/src/Resources/Assets/KnownFollowersAsset.php @@ -4,12 +4,13 @@ use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Resources\BaseResource; -use GenericCollection\GenericCollection; -use GenericCollection\Interfaces\TypeInterface; +use Atproto\Traits\Castable; class KnownFollowersAsset implements AssetContract { - use BaseResource, BaseAsset; + use BaseResource; + use BaseAsset; + use Castable; public function __construct(array $content) { @@ -22,4 +23,4 @@ protected function casts(): array 'followers' => FollowersAsset::class, ]; } -} \ No newline at end of file +} diff --git a/src/Resources/Assets/LabelAsset.php b/src/Resources/Assets/LabelAsset.php index b0c9f3e..0e90dfc 100644 --- a/src/Resources/Assets/LabelAsset.php +++ b/src/Resources/Assets/LabelAsset.php @@ -5,6 +5,7 @@ use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\Resources\BaseResource; +use Atproto\Traits\Castable; /** * @method int ver @@ -18,7 +19,9 @@ */ class LabelAsset implements ResourceContract, AssetContract { - use BaseResource, BaseAsset; + use BaseResource; + use BaseAsset; + use Castable; public function __construct(array $content) { @@ -32,4 +35,4 @@ public function casts(): array 'exp' => DatetimeAsset::class, ]; } -} \ No newline at end of file +} diff --git a/src/Resources/Assets/LabelsAsset.php b/src/Resources/Assets/LabelsAsset.php index ab8d21e..6c9dc19 100644 --- a/src/Resources/Assets/LabelsAsset.php +++ b/src/Resources/Assets/LabelsAsset.php @@ -22,6 +22,6 @@ protected function item($data): AssetContract protected function type(): TypeInterface { - return new LabelAssetType; + return new LabelAssetType(); } -} \ No newline at end of file +} diff --git a/src/Resources/Assets/MutedByListAsset.php b/src/Resources/Assets/MutedByListAsset.php index 1367d22..9c1a282 100644 --- a/src/Resources/Assets/MutedByListAsset.php +++ b/src/Resources/Assets/MutedByListAsset.php @@ -20,4 +20,4 @@ class MutedByListAsset implements ResourceContract, AssetContract { use ByListAsset; -} \ No newline at end of file +} diff --git a/src/Resources/Assets/UserAsset.php b/src/Resources/Assets/UserAsset.php index d50e0aa..1936975 100644 --- a/src/Resources/Assets/UserAsset.php +++ b/src/Resources/Assets/UserAsset.php @@ -3,6 +3,7 @@ namespace Atproto\Resources\Assets; use Atproto\Resources\BaseResource; +use Atproto\Traits\Castable; /** * @method string did() @@ -23,7 +24,9 @@ */ trait UserAsset { - use BaseResource, BaseAsset; + use BaseResource; + use BaseAsset; + use Castable; public function __construct(array $content) { @@ -41,4 +44,4 @@ protected function casts(): array 'labels' => LabelsAsset::class, ]; } -} \ No newline at end of file +} diff --git a/src/Resources/Assets/ViewerAsset.php b/src/Resources/Assets/ViewerAsset.php index ba25337..148054f 100644 --- a/src/Resources/Assets/ViewerAsset.php +++ b/src/Resources/Assets/ViewerAsset.php @@ -5,6 +5,7 @@ use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\Resources\BaseResource; +use Atproto\Traits\Castable; /** * @method bool muted @@ -19,7 +20,9 @@ */ class ViewerAsset implements ResourceContract, AssetContract { - use BaseResource, BaseAsset; + use BaseResource; + use BaseAsset; + use Castable; public function __construct($content) { @@ -35,4 +38,4 @@ public function casts(): array 'labels' => LabelsAsset::class, ]; } -} \ No newline at end of file +} diff --git a/src/Resources/BaseResource.php b/src/Resources/BaseResource.php index 9704d81..0701335 100644 --- a/src/Resources/BaseResource.php +++ b/src/Resources/BaseResource.php @@ -5,6 +5,7 @@ use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Exceptions\Resource\BadAssetCallException; use Atproto\Helpers\Arr; +use Atproto\Traits\Castable; trait BaseResource { @@ -53,15 +54,15 @@ private function parse(string $name) { $value = Arr::get($this->content, $name); - /** @var ?AssetContract $cast */ - $asset = Arr::get($this->casts(), $name); + if (in_array(Castable::class, class_uses_recursive(static::class))) { + /** @var ?AssetContract $cast */ + $asset = Arr::get($this->casts(), $name); - if ($asset) { - $value = (new $asset($value))->cast(); + if ($asset) { + $value = (new $asset($value))->cast(); + } } return $value; } - - abstract protected function casts(): array; } \ No newline at end of file diff --git a/src/Resources/Com/Atproto/Repo/CreateRecordResource.php b/src/Resources/Com/Atproto/Repo/CreateRecordResource.php new file mode 100644 index 0000000..72ec7fa --- /dev/null +++ b/src/Resources/Com/Atproto/Repo/CreateRecordResource.php @@ -0,0 +1,12 @@ +send(); + + $this->authenticated = $response; + } + + public function authenticated(): ?CreateSessionResource + { + return $this->authenticated; + } +} diff --git a/src/Traits/Castable.php b/src/Traits/Castable.php new file mode 100644 index 0000000..9c82a4e --- /dev/null +++ b/src/Traits/Castable.php @@ -0,0 +1,8 @@ +path[] = $name; + + return $this; + } + + protected function refresh(): void + { + $this->path = []; + } + + /** + * @throws RequestNotFoundException + */ + public function forge(): RequestContract + { + $namespace = $this->namespace(); + + if (! class_exists($namespace)) { + throw new RequestNotFoundException("$namespace class does not exist."); + } + + return new $namespace($this); + } + + protected function namespace(): string + { + $namespace = $this->prefix() . implode('\\', array_map( + 'ucfirst', + $this->path + )); + + $this->refresh(); + + return $namespace; + } + abstract public function prefix(): string; +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..f2f025f --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,43 @@ + $class] as $class) { + $results += trait_uses_recursive($class); + } + + return array_unique($results); + } +} + +if (! function_exists('trait_uses_recursive')) { + /** + * Returns all traits used by a trait and its traits. + * + * @param object|string $trait + * @return array + */ + function trait_uses_recursive($trait): array + { + $traits = class_uses($trait) ?: []; + + foreach ($traits as $trait) { + $traits += trait_uses_recursive($trait); + } + + return $traits; + } +} \ No newline at end of file diff --git a/tests/Feature/BlueskyClientTest.php b/tests/Feature/BlueskyClientTest.php index a065c50..0631bef 100644 --- a/tests/Feature/BlueskyClientTest.php +++ b/tests/Feature/BlueskyClientTest.php @@ -73,8 +73,8 @@ public function testAuthenticateWithValidCredentials() $client->setStrategy(new PasswordAuthentication); $authenticated = $client->authenticate([ - 'identifier' => 'shahmal1yevv.bsky.social', - 'password' => 'ucvlqcq8' + 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], + 'password' => $_ENV["BLUESKY_PASSWORD"] ]); $this->assertIsObject($authenticated); @@ -110,12 +110,12 @@ public function testExecuteWithCreateRecord() $request->setRecord($recordBuilder); - $client = new BlueskyClient(new CreateRecord); + $client = new BlueskyClient($request); $client->setStrategy(new PasswordAuthentication) ->authenticate([ - 'identifier' => 'shahmal1yevv.bsky.social', - 'password' => 'ucvlqcq8' + 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], + 'password' => $_ENV["BLUESKY_PASSWORD"] ]); $response = $client->execute(); @@ -133,11 +133,11 @@ public function testExecuteWithUploadBlob() $client->setStrategy(new PasswordAuthentication) ->authenticate([ - 'identifier' => 'shahmal1yevv.bsky.social', - 'password' => 'ucvlqcq8' + 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], + 'password' => $_ENV["BLUESKY_PASSWORD"] ]); - $client->getRequest()->setBlob('assets/file.png'); + $client->getRequest()->setBlob('art/file.png'); $response = $client->execute(); @@ -151,8 +151,8 @@ public function testExecuteWithGetProfile() $client = new BlueskyClient(new GetProfile); $client->authenticate([ - 'identifier' => 'shahmal1yevv.bsky.social', - 'password' => 'ucvlqcq8' + 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], + 'password' => $_ENV["BLUESKY_PASSWORD"] ]); $client->getRequest()->setActor('shahmal1yevv.bsky.social'); @@ -175,8 +175,8 @@ public function testSendWithGetProfile() $client = new BlueskyClient($request); $client->authenticate([ - 'identifier' => 'shahmal1yevv.bsky.social', - 'password' => 'ucvlqcq8' + 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], + 'password' => $_ENV["BLUESKY_PASSWORD"] ]); /** @var GetProfileResource $response */ @@ -206,13 +206,13 @@ public function testSendWithGetProfile() */ public function testSendWithRequestWhichHasNotResourceSupport() { - $request = (new UploadBlob())->setBlob('assets/file.png'); + $request = (new UploadBlob())->setBlob('art/file.png'); $client = new BlueskyClient($request); $client->authenticate([ - 'identifier' => 'shahmal1yevv.bsky.social', - 'password' => 'ucvlqcq8' + 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], + 'password' => $_ENV["BLUESKY_PASSWORD"] ]); $response = $client->send(); @@ -228,12 +228,12 @@ public function testExecuteWithUploadBlobAndCreateRecord() $client->setStrategy(new PasswordAuthentication) ->authenticate([ - 'identifier' => 'shahmal1yevv.bsky.social', - 'password' => 'ucvlqcq8' + 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], + 'password' => $_ENV["BLUESKY_PASSWORD"] ]); $client->getRequest() - ->setBlob('assets/file.png') + ->setBlob('art/file.png') ->setHeaders([ 'Content-Type' => $client->getRequest() ->getBlob() diff --git a/tests/Feature/ClientTest.php b/tests/Feature/ClientTest.php new file mode 100644 index 0000000..89d8ba6 --- /dev/null +++ b/tests/Feature/ClientTest.php @@ -0,0 +1,99 @@ +client = new Client(); + } + + /** + * @throws BlueskyException + * @throws ReflectionException + */ + public function testGetProfile(): void + { + $username = $_ENV['BLUESKY_IDENTIFIER']; + $password = $_ENV['BLUESKY_PASSWORD']; + + $this->assertIsString($username); + $this->assertIsString($password); + + $this->client->authenticate( + $username, + $password + ); + + /** @var CreateSessionResource $authenticated */ + $authenticated = $this->getPropertyValue('authenticated', $this->client); + + $this->assertInstanceOf(ResourceContract::class, $authenticated); + + $this->assertIsString($authenticated->handle()); + ; + $this->assertSame($username, $authenticated->handle()); + + $profile = $this->client + ->app() + ->bsky() + ->actor() + ->getProfile() + ->forge() + ->send(); + + $this->assertInstanceOf(ResourceContract::class, $profile); + $this->assertInstanceOf(GetProfileResource::class, $profile); + + $this->assertInstanceOf(Carbon::class, $profile->createdAt()); + } + + /** + * @throws BlueskyException + */ + public function testClientThrowsExceptionWhenAuthenticationFails(): void + { + $this->expectException(AuthenticationRequiredException::class); + $this->expectExceptionMessage("Invalid identifier or password"); + $this->expectExceptionCode(401); + + $this->client->authenticate( + 'invalid', + 'credentials' + ); + } + + public function testClientThrowsExceptionWhenAuthenticationRequired(): void + { + $this->expectException(AuthMissingException::class); + $this->expectExceptionMessage("Authentication Required"); + $this->expectExceptionCode(401); + + $this->client + ->app() + ->bsky() + ->actor() + ->getProfile() + ->forge() + ->send(); + } +} diff --git a/tests/Supports/Reflection.php b/tests/Supports/Reflection.php new file mode 100644 index 0000000..036d5ea --- /dev/null +++ b/tests/Supports/Reflection.php @@ -0,0 +1,51 @@ +getMethod($name); + $method->setAccessible(true); + + return $method; + } + + /** + * @throws ReflectionException + */ + protected function property(string $name, object $object): ReflectionProperty + { + $reflection = new ReflectionClass($object); + $property = $reflection->getProperty($name); + $property->setAccessible(true); + + return $property; + } + + /** + * @throws ReflectionException + */ + protected function getPropertyValue(string $propertyName, object $object) + { + return $this->property($propertyName, $object)->getValue($object); + } + + /** + * @throws ReflectionException + */ + protected function setPropertyValue(string $propertyName, $value, $object): void + { + $this->property($propertyName, $object)->setValue($object, $value); + } +} \ No newline at end of file diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php new file mode 100644 index 0000000..48dfb77 --- /dev/null +++ b/tests/Unit/ClientTest.php @@ -0,0 +1,94 @@ +client = new Client(); + } + + /** + * @throws ReflectionException + */ + public function testDynamicMethodCalls(): void + { + $this->client->app()->bsky()->actor(); + + $path = $this->getPropertyValue('path', $this->client); + + $this->assertSame(['app', 'bsky', 'actor'], $path); + } + + /** + * @throws ReflectionException + */ + public function testNamespaceGeneration(): void + { + $this->client->app()->bsky()->actor(); + + $method = $this->method('namespace', $this->client); + + $namespace = $method->invoke($this->client); + + $expectedNamespace = 'Atproto\\HTTP\\API\\Requests\\App\\Bsky\\Actor'; + $this->assertSame($expectedNamespace, $namespace); + } + + public function testBuildThrowsRequestNotFoundException(): void + { + $this->client->nonExistentMethod(); + + $this->expectException(RequestNotFoundException::class); + $this->expectExceptionMessage("Atproto\\HTTP\\API\\Requests\\NonExistentMethod class does not exist."); + + $this->client->forge(); + } + + + /** + * @throws RequestNotFoundException + */ + public function testBuildReturnsRequestContract(): void + { + $this->client->app()->bsky()->actor()->getProfile(); + + $request = $this->client->forge(); + + $this->assertInstanceOf(RequestContract::class, $request); + } + + /** + * @throws ReflectionException + */ + public function testRefreshClearsPath(): void + { + $this->client->app()->bsky()->actor(); + + $method = $this->method('refresh', $this->client); + + $method->invoke($this->client); + + $path = $this->getPropertyValue('path', $this->client); + + $this->assertEmpty($path); + } + + public function testAuthenticatedReturnsNullWhenNotAuthenticated(): void + { + $this->assertNull($this->client->authenticated()); + } +} diff --git a/tests/Unit/HTTP/API/APIRequestTest.php b/tests/Unit/HTTP/API/APIRequestTest.php new file mode 100644 index 0000000..1091bef --- /dev/null +++ b/tests/Unit/HTTP/API/APIRequestTest.php @@ -0,0 +1,59 @@ +faker = Factory::create(); + + $this->parameters = [ + 'identifier' => $this->faker->userName, + 'password' => $this->faker->password, + ]; + + $this->request = new CreateSession( + 'Atproto\\HTTP\\API\\Requests\\', + $this->parameters['identifier'], + $this->parameters['password'] + ); + } + + public function testPath(): void + { + $expected = "xrpc/com.atproto.server.createSession"; + $actual = $this->request->path(); + + $this->assertSame($expected, $actual); + } + + public function testParameters(): void + { + $expected = $this->parameters; + $actual = $this->request->parameters(); + + $this->assertSame($expected, $actual); + } + + public function testOrigin(): void + { + $expected = 'https://bsky.social'; + $actual = $this->request->origin(); + + $this->assertSame($expected, $actual); + } +} diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php new file mode 100644 index 0000000..d7878a4 --- /dev/null +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php @@ -0,0 +1,97 @@ +faker = Factory::create(); + $this->request = new GetProfile(); + } + + /** + * @throws ReflectionException + */ + public function testActorMethodSetsCorrectData(): void + { + // Arrange + $expected = $this->faker->word; + + // Act + $this->request->actor($expected); + $actual = $this->request->queryParameter('actor'); + + // Assert + $this->assertSame($expected, $actual, 'Actor should be set correctly.'); + $this->assertIsString($actual, 'Actor should be a string.'); + } + + /** + * @throws ReflectionException + */ + public function testActorMethodGetsCorrectData(): void + { + // Arrange + $expected = $this->faker->word; + $this->request->queryParameter('actor', $expected); + + // Act + $actual = $this->request->actor(); + + // Assert + $this->assertSame($expected, $actual, 'Actor should be retrieved correctly.'); + $this->assertIsString($actual, 'Actor should be a string.'); + } + + public function testActorMethodReturnsRequestInstance(): void + { + // Arrange & Act + $actual = $this->request->actor($this->faker->word); + + // Assert + $this->assertInstanceOf(RequestContract::class, $actual, 'Should return an instance of RequestContract.'); + $this->assertInstanceOf(APIRequestContract::class, $actual, 'Should return an instance of APIRequestContract.'); + $this->assertInstanceOf(Request::class, $actual, 'Should return an instance of Request.'); + $this->assertInstanceOf(APIRequest::class, $actual, 'Should return an instance of APIRequest.'); + } + + public function testBuildReturnsSameInterface(): void + { + $this->request->actor($this->faker->word); + $this->request->token($this->faker->word); + + $actual = $this->request->build(); + + $this->assertInstanceOf(RequestContract::class, $actual, 'Should return an instance of RequestContract.'); + $this->assertInstanceOf(APIRequestContract::class, $actual, 'Should return an instance of APIRequestContract.'); + $this->assertInstanceOf(Request::class, $actual, 'Should return an instance of Request.'); + $this->assertInstanceOf(APIRequest::class, $actual, 'Should return an instance of APIRequest.'); + } + + public function testBuildThrowsAnExceptionWhenActorDoesNotExist(): void + { + $this->expectException(MissingFieldProvidedException::class); + $this->expectExceptionMessage("Missing provided fields: actor, token"); + + $this->request->build(); + } +} diff --git a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php new file mode 100644 index 0000000..c7256c8 --- /dev/null +++ b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php @@ -0,0 +1,111 @@ +createRecord = new CreateRecord(); + $this->faker = Factory::create(); + } + + public function testRepo() + { + $this->assertNull($this->createRecord->repo()); + + $expected = $this->faker->word; + $this->createRecord->repo($expected); + $this->assertEquals($expected, $this->createRecord->repo()); + } + + public function testCollection() + { + $this->assertNull($this->createRecord->collection()); + + $expected = $this->faker->word; + $this->createRecord->collection($expected); + $this->assertEquals($expected, $this->createRecord->collection()); + } + + public function testRkey() + { + $this->assertNull($this->createRecord->rkey()); + + $expected = $this->faker->word; + $this->createRecord->rkey($expected); + $this->assertEquals($expected, $this->createRecord->rkey()); + } + + public function testValidate() + { + $this->assertNull($this->createRecord->validate()); + + $this->createRecord->validate(true); + $this->assertTrue($this->createRecord->validate()); + } + + public function testRecord() + { + $this->assertNull($this->createRecord->record()); + + $record = (object)['key' => 'value']; + $this->createRecord->record($record); + $this->assertEquals($record, $this->createRecord->record()); + } + + public function testSwapCommit() + { + $this->assertNull($this->createRecord->swapCommit()); + + $expected = $this->faker->word; + $this->createRecord->swapCommit($expected); + $this->assertEquals($expected, $this->createRecord->swapCommit()); + } + + /** + * @throws MissingFieldProvidedException + */ + public function testBuildWithAllRequiredFields() + { + $this->createRecord->repo($this->faker->word) + ->collection($this->faker->word) + ->record((object)['key' => 'value']); + + $result = $this->createRecord->build(); + + $this->assertInstanceOf(CreateRecord::class, $result); + } + + public function testBuildWithMissingRequiredFields() + { + $this->expectException(MissingFieldProvidedException::class); + $this->expectExceptionMessage("record"); + + $this->createRecord->repo($this->faker->word) + ->collection($this->faker->word); + + $this->createRecord->build(); + } + + public function testChaining() + { + $result = $this->createRecord->repo($this->faker->word) + ->collection($this->faker->word) + ->rkey($this->faker->word) + ->validate(true) + ->record((object)['key' => 'value']) + ->swapCommit($this->faker->word); + + $this->assertInstanceOf(CreateRecord::class, $result); + } +} \ No newline at end of file diff --git a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php new file mode 100644 index 0000000..0f1216b --- /dev/null +++ b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php @@ -0,0 +1,86 @@ +uploadBlob = new UploadBlob(); + $this->faker = Factory::create(); + } + + public function testBlobMethodSetsAndReturnsValue(): void + { + $blobData = $this->faker->word; + + $result = $this->uploadBlob->blob($blobData); + + $this->assertSame($this->uploadBlob, $result); + $this->assertSame($blobData, $this->uploadBlob->blob()); + } + + public function testTokenMethodSetsAndReturnsValue(): void + { + $token = $this->faker->word; + + $result = $this->uploadBlob->token($token); + + $this->assertSame($this->uploadBlob, $result); + $this->assertSame($token, $this->uploadBlob->token()); + } + + public function testTokenMethodSetsAuthorizationHeader(): void + { + $token = $this->faker->word; + + $this->uploadBlob->token($token); + + $headers = $this->getPropertyValue('headers', $this->uploadBlob); + + $this->assertArrayHasKey('Authorization', $headers); + $this->assertSame("Bearer $token", $headers['Authorization']); + } + + public function testBuildThrowsExceptionWhenBlobIsMissing(): void + { + $this->expectException(MissingFieldProvidedException::class); + $this->expectExceptionMessage('blob'); + + $this->uploadBlob->token($this->faker->word)->build(); + } + + public function testBuildThrowsExceptionWhenTokenIsMissing(): void + { + $this->expectException(MissingFieldProvidedException::class); + $this->expectExceptionMessage('token'); + + $this->uploadBlob->blob($this->faker->word)->build(); + } + + /** + * @throws MissingFieldProvidedException + */ + public function testBuildReturnsInstanceWhenAllFieldsAreSet(): void + { + $result = $this->uploadBlob + ->blob($this->faker->word) + ->token($this->faker->word) + ->build(); + + $this->assertInstanceOf(UploadBlob::class, $result); + } +} \ No newline at end of file diff --git a/tests/Unit/HTTP/RequestTest.php b/tests/Unit/HTTP/RequestTest.php new file mode 100644 index 0000000..542d07f --- /dev/null +++ b/tests/Unit/HTTP/RequestTest.php @@ -0,0 +1,195 @@ +request = new Request(); + $this->faker = Factory::create(); + } + + /** @dataProvider methodProvider */ + public function testMethodSetsCorrectValue(string $method, string $property): void + { + $key = $this->faker->word; + $expected = $this->faker->word; + + try { + $reflectedProperty = $this->getPropertyValue($property, $this->request); + + $value = (is_array($reflectedProperty)) + ? [$key => $expected] + : $expected; + + $this->request->$method($value); + } catch (TypeError $e) { + $this->request->$method($key, $expected); + } + + $actual = $this->getPropertyValue($property, $this->request); + + if (is_array($actual)) { + $this->assertArrayHasKey($key, $actual); + $actual = $actual[$key]; + } + + $this->assertSame($expected, $actual); + } + + /** @dataProvider methodProvider */ + public function testMethodReturnsCorrectValue(string $method, string $property): void + { + $key = $this->faker->word; + $expected = $this->faker->word; + + $propertyValue = $this->getPropertyValue($property, $this->request); + + if (is_array($propertyValue)) { + $expected = [$key => $expected]; + } + + $this->setPropertyValue($property, $expected, $this->request); + + try { + $actual = $this->request->$method(); + } catch (ArgumentCountError $e) { + $actual = $this->request->$method($key); + $expected = current($expected); + } + + $this->assertSame($expected, $actual); + } + + /** @dataProvider methodProvider */ + public function testMethodReturnsSameInstanceWhenSettingValue(string $method, string $property): void + { + $propertyValue = $this->getPropertyValue($property, $this->request); + + $value = is_array($propertyValue) + ? [$this->faker->word] + : $this->faker->word; + + try { + $actual = $this->request->$method($value); + } catch (TypeError $e) { + $actual = $this->request->$method($this->faker->word, $this->faker->word); + } + + $this->assertInstanceOf(RequestContract::class, $actual); + $this->assertInstanceOf(Request::class, $actual); + $this->assertSame($this->request, $actual); + } + + /** @dataProvider encodableProvider */ + public function testMethodsReturnEncodableContentCorrectly(string $property, string $method, string $verifier): void + { + $content = $this->randomArray(); + + $this->setPropertyValue($property, $content, $this->request); + + $expected = $content; + $actual = $this->request->$method(true); + + $this->$verifier($expected, $actual); + } + + public function testUrlReturnsCorrectlyAddress(): void + { + $origin = "https://example.com"; + $path = "path/for/example/url"; + $queryParameters = ['foo' => 'bar', 'baz' => 'qux']; + + $this->request->origin($origin) + ->path($path) + ->queryParameters($queryParameters); + + $actual = $this->request->url(); + $expected = "$origin/$path?" . http_build_query($queryParameters); + + $this->assertSame($expected, $actual); + } + + public function encodableProvider(): array + { + return [ + ['headers', 'headers', 'assertHeadersEncodedCorrectly'], + ['parameters', 'parameters', 'assertParametersEncodedCorrectly'], + ['queryParameters', 'queryParameters', 'assertQueryParametersEncodedCorrectly'], + ]; + } + + public function methodProvider(): array + { + # [method, property] + + return [ + ['path', 'path'], + ['origin', 'origin'], + ['method', 'method'], + ['header', 'headers'], + ['headers', 'headers'], + ['parameter', 'parameters'], + ['parameters', 'parameters'], + ['queryParameter', 'queryParameters'], + ['queryParameters', 'queryParameters'], + ]; + } + + protected function assertHeadersEncodedCorrectly(array $expected, array $actual): void + { + $expected = array_map(fn ($key, $value) => "$key: $value", array_keys($expected), array_values($expected)); + + $this->assertEquals($expected, $actual); + $this->assertIsArray($actual); + } + + protected function assertParametersEncodedCorrectly(array $expected, string $actual): void + { + $expected = json_encode($expected); + + $this->assertSame($expected, $actual); + $this->assertIsString($actual); + } + + protected function assertQueryParametersEncodedCorrectly(array $expected, string $actual): void + { + $expected = http_build_query($expected); + + $this->assertSame($expected, $actual); + $this->assertIsString($actual); + } + + protected function randomArray(int $count = null): array + { + $count = $count ?: $this->faker->numberBetween(1, 100); + + $keys = []; + $values = []; + + for ($i = 0; $i < $count; $i++) { + $keys[] = $this->faker->word; + $values[] = $this->faker->word; + } + + return array_combine( + $keys, + $values + ); + } +} diff --git a/tests/Unit/Resources/BaseResourceTest.php b/tests/Unit/Resources/BaseResourceTest.php index 47cba4c..8a61f90 100644 --- a/tests/Unit/Resources/BaseResourceTest.php +++ b/tests/Unit/Resources/BaseResourceTest.php @@ -7,6 +7,7 @@ use Atproto\Exceptions\Resource\BadAssetCallException; use Atproto\Resources\Assets\BaseAsset; use Atproto\Resources\BaseResource; +use Atproto\Traits\Castable; use PHPUnit\Framework\TestCase; class BaseResourceTest extends TestCase @@ -54,6 +55,7 @@ public function testMagicCall() class TestableResource implements ResourceContract { use BaseResource; + use Castable; protected function casts(): array {