diff --git a/README.md b/README.md
index 8797968..c18c7a5 100644
--- a/README.md
+++ b/README.md
@@ -1,179 +1,157 @@
-# BlueskySDK
+
+
+
+
+# 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
{