From c5bce8026d7d97801fbe2c8dc5c15d0475c52bff Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Wed, 18 Sep 2024 20:31:42 +0400 Subject: [PATCH 01/59] Implement GetFollowers request and resource --- .../Requests/App/Bsky/Graph/GetFollowers.php | 41 +++++++++++++++++++ .../App/Bsky/Graph/GetFollowersResource.php | 28 +++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php create mode 100644 src/Resources/App/Bsky/Graph/GetFollowersResource.php diff --git a/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php b/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php new file mode 100644 index 0000000..aea6468 --- /dev/null +++ b/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php @@ -0,0 +1,41 @@ +prefix()); + + if (! $client->authenticated()) { + return; + } + + try { + $this->header('Authorization', 'Bearer ' . $client->authenticated()->accessJwt()); + $this->queryParameter('actor', $client->authenticated()->did()); + } catch (AuthRequired $e) {} + } + + public function resource(array $data): ResourceContract + { + return new GetFollowersResource($data); + } + + public function build(): RequestContract + { + return $this; + } +} \ No newline at end of file diff --git a/src/Resources/App/Bsky/Graph/GetFollowersResource.php b/src/Resources/App/Bsky/Graph/GetFollowersResource.php new file mode 100644 index 0000000..e97c562 --- /dev/null +++ b/src/Resources/App/Bsky/Graph/GetFollowersResource.php @@ -0,0 +1,28 @@ +content = $content; + } + + protected function casts(): array + { + return [ + 'followers' => FollowersAsset::class, + ]; + } +} From 94324e70cc38ea69aa496d7b9dd944a6b2e4b8b0 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Fri, 20 Sep 2024 17:25:18 +0400 Subject: [PATCH 02/59] Update GetFollowers Request and add Unit Tests - Added parameter setters for `actor`, `limit`, and `cursor` with validation for `limit`. - Implemented error handling with `AuthMissingException` and `MissingFieldProvidedException` during request building. - Enhanced `Authentication` trait with token management. - Added comprehensive unit tests for `GetFollowers` to ensure proper parameter handling, validation, and response. --- src/Exceptions/InvalidArgumentException.php | 10 ++ .../Requests/App/Bsky/Actor/GetProfile.php | 7 +- .../Requests/App/Bsky/Graph/GetFollowers.php | 65 +++++++-- src/HTTP/Traits/Authentication.php | 12 ++ src/HTTP/Traits/RequestBuilder.php | 8 +- .../App/Bsky/Graph/GetFollowersTest.php | 124 ++++++++++++++++++ 6 files changed, 210 insertions(+), 16 deletions(-) create mode 100644 src/Exceptions/InvalidArgumentException.php create mode 100644 tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php diff --git a/src/Exceptions/InvalidArgumentException.php b/src/Exceptions/InvalidArgumentException.php new file mode 100644 index 0000000..7c06522 --- /dev/null +++ b/src/Exceptions/InvalidArgumentException.php @@ -0,0 +1,10 @@ +headers(), 'Authorization')) { + throw new AuthMissingException(); + } + $missing = []; if (! $this->queryParameter('actor')) { diff --git a/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php b/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php index aea6468..d53bef8 100644 --- a/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php +++ b/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php @@ -2,31 +2,58 @@ namespace Atproto\HTTP\API\Requests\App\Bsky\Graph; -use Atproto\Client; use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\Contracts\RequestContract; -use Atproto\Exceptions\Auth\AuthRequired; +use Atproto\Exceptions\Http\MissingFieldProvidedException; +use Atproto\Exceptions\Http\Response\AuthMissingException; +use Atproto\Exceptions\InvalidArgumentException; +use Atproto\Helpers\Arr; use Atproto\HTTP\API\APIRequest; +use Atproto\HTTP\Traits\Authentication; use Atproto\Resources\App\Bsky\Graph\GetFollowersResource; class GetFollowers extends APIRequest { - public function __construct(Client $client = null) + use Authentication; + + public function actor(string $actor = null) + { + if (is_null($actor)) { + return $this->queryParameter('actor'); + } + + $this->queryParameter('actor', $actor); + + return $this; + } + + /** + * @throws InvalidArgumentException + */ + public function limit(int $limit = null) { - if (! $client) { - return; + if (is_null($limit)) { + return (int) $this->queryParameter('limit') ?: null; + } + + if (! ($limit >= 1 && $limit <= 100)) { + throw new InvalidArgumentException("Limit must be between 1 and 100."); } - parent::__construct($client->prefix()); + $this->queryParameter('limit', $limit); - if (! $client->authenticated()) { - return; + return $this; + } + + public function cursor(string $cursor = null) + { + if (is_null($cursor)) { + return $this->queryParameter('cursor'); } - try { - $this->header('Authorization', 'Bearer ' . $client->authenticated()->accessJwt()); - $this->queryParameter('actor', $client->authenticated()->did()); - } catch (AuthRequired $e) {} + $this->queryParameter('cursor', $cursor); + + return $this; } public function resource(array $data): ResourceContract @@ -34,8 +61,20 @@ public function resource(array $data): ResourceContract return new GetFollowersResource($data); } + /** + * @throws MissingFieldProvidedException + * @throws AuthMissingException + */ public function build(): RequestContract { + if (! Arr::exists($this->headers(false), 'Authorization')) { + throw new AuthMissingException(); + } + + if (! $this->queryParameter('actor')) { + throw new MissingFieldProvidedException('actor'); + } + return $this; } -} \ No newline at end of file +} diff --git a/src/HTTP/Traits/Authentication.php b/src/HTTP/Traits/Authentication.php index cef08cc..f1a08b1 100644 --- a/src/HTTP/Traits/Authentication.php +++ b/src/HTTP/Traits/Authentication.php @@ -3,6 +3,7 @@ namespace Atproto\HTTP\Traits; use Atproto\Client; +use Atproto\Contracts\RequestContract; use Atproto\HTTP\API\APIRequest; trait Authentication @@ -19,4 +20,15 @@ public function __construct(Client $client) $this->header("Authorization", "Bearer " . $authenticated->accessJwt()); } } + + public function token(string $token = null) + { + if (is_null($token)) { + return $this->header('Authorization'); + } + + $this->header('Authorization', "Bearer " . $token); + + return $this; + } } diff --git a/src/HTTP/Traits/RequestBuilder.php b/src/HTTP/Traits/RequestBuilder.php index 0f262b7..af3d557 100644 --- a/src/HTTP/Traits/RequestBuilder.php +++ b/src/HTTP/Traits/RequestBuilder.php @@ -134,8 +134,12 @@ public function parameters($parameters = null) public function queryParameters($queryParameters = null) { - if (is_bool($queryParameters) && $queryParameters) { - return http_build_query($this->queryParameters); + if (is_bool($queryParameters)) { + if ($queryParameters) { + return http_build_query($this->queryParameters); + } + + return $this->queryParameters; } if (is_null($queryParameters)) { diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php new file mode 100644 index 0000000..4883f9b --- /dev/null +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php @@ -0,0 +1,124 @@ +clientMock = $this->createMock(Client::class); + $this->request = new GetFollowers($this->clientMock); + } + + public function testActorSetterAndGetter() + { + $actor = 'testActor'; + $this->request->actor($actor); + $this->assertSame($actor, $this->request->actor(), 'Actor getter should return the value set by the setter.'); + } + + public function testLimitSetterAndGetter() + { + $limit = 50; + $this->request->limit($limit); + $this->assertSame($limit, $this->request->limit(), 'Limit getter should return the value set by the setter.'); + } + + public function testLimitSetterThrowsExceptionForZero() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Limit must be between 1 and 100.'); + $this->request->limit(0); + } + + public function testLimitSetterThrowsExceptionForNegativeValue() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Limit must be between 1 and 100.'); + $this->request->limit(-10); + } + + public function testLimitSetterThrowsExceptionForValueAboveMaximum() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Limit must be between 1 and 100.'); + $this->request->limit(101); + } + + public function testCursorSetterAndGetter() + { + $cursor = 'testCursor'; + $this->request->cursor($cursor); + $this->assertSame($cursor, $this->request->cursor(), 'Cursor getter should return the value set by the setter.'); + } + + public function testBuildThrowsExceptionWhenAuthorizationHeaderMissing() + { + $this->expectException(AuthMissingException::class); + $this->expectExceptionMessage('Authentication Required'); + + // Set required 'actor' parameter + $this->request->actor('testActor'); + + // Do not set 'Authorization' header + $this->request->build(); + } + + public function testBuildThrowsExceptionWhenActorParameterMissing() + { + $this->expectException(MissingFieldProvidedException::class); + $this->expectExceptionMessage("Missing provided fields: actor"); + + // Set 'Authorization' header + $this->request->token('Bearer token'); + + // Do not set 'actor' parameter + $this->request->build(); + } + + public function testBuildSucceedsWithRequiredParameters() + { + // Set required 'Authorization' header and 'actor' parameter + $this->request->token('Bearer token'); + $this->request->actor('testActor'); + + // Should not throw any exceptions + $builtRequest = $this->request->build(); + $this->assertInstanceOf(GetFollowers::class, $builtRequest, 'Build should return an instance of GetFollowers.'); + } + + public function testResourceMethodReturnsCorrectInstance() + { + $data = ['followers' => []]; + $resource = $this->request->resource($data); + $this->assertInstanceOf(ResourceContract::class, $resource, 'Resource method should return an instance of ResourceContract.'); + } + + public function testLimitGetterReturnsNullWhenNotSet() + { + $this->assertNull($this->request->limit(), 'Limit getter should return null when not set.'); + } + + public function testActorGetterReturnsNullWhenNotSet() + { + $this->assertNull($this->request->actor(), 'Actor getter should return null when not set.'); + } + + public function testCursorGetterReturnsNullWhenNotSet() + { + $this->assertNull($this->request->cursor(), 'Cursor getter should return null when not set.'); + } +} From dc6deb156f271e8dd0ec9d5b6898aad74dda272c Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 22 Sep 2024 02:31:23 +0400 Subject: [PATCH 03/59] Add GetProfiles request for fetching multiple user profiles - Implement ProfileAssetType for type validation - Add token method to Authentication trait for easier token handling - Create InvalidArgumentException - Modify MissingFieldProvidedException message - Update .gitignore to exclude PHP-CS-Fixer files - Update RequestContract to allow array query parameters --- .gitignore | 13 +- src/Contracts/RequestContract.php | 4 +- .../Http/MissingFieldProvidedException.php | 2 +- src/Exceptions/InvalidArgumentException.php | 3 +- .../Types/NonPrimitive/ProfileAssetType.php | 14 +++ .../Requests/App/Bsky/Actor/GetProfiles.php | 71 +++++++++++ src/HTTP/Traits/Authentication.php | 3 +- src/HTTP/Traits/RequestBuilder.php | 2 +- .../App/Bsky/Actor/GetProfilesResource.php | 21 ++++ src/Resources/Assets/ProfileAsset.php | 11 ++ src/Resources/Assets/ProfilesAsset.php | 28 +++++ tests/Feature/ClientTest.php | 3 - .../App/Bsky/Actor/GetProfilesTest.php | 116 ++++++++++++++++++ .../App/Bsky/Actor/GetProfileTest.php | 2 +- .../App/Bsky/Actor/GetProfilesTest.php | 94 ++++++++++++++ 15 files changed, 374 insertions(+), 13 deletions(-) create mode 100644 src/GenericCollection/Types/NonPrimitive/ProfileAssetType.php create mode 100644 src/HTTP/API/Requests/App/Bsky/Actor/GetProfiles.php create mode 100644 src/Resources/App/Bsky/Actor/GetProfilesResource.php create mode 100644 src/Resources/Assets/ProfileAsset.php create mode 100644 src/Resources/Assets/ProfilesAsset.php create mode 100644 tests/Feature/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php create mode 100644 tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php diff --git a/.gitignore b/.gitignore index f183cec..206dca0 100644 --- a/.gitignore +++ b/.gitignore @@ -112,4 +112,15 @@ fabric.properties # Build data /build/ -# End of https://www.toptal.com/developers/gitignore/api/phpstorm+all,composer,phpunit +### PHP-CS-Fixer ### +# Covers PHP CS Fixer +# Reference: https://cs.symfony.com/ + +# Generated files +.php-cs-fixer.cache + +# Local config See: https://cs.symfony.com/doc/config.html +.php-cs-fixer.php + +# End of https://www.toptal.com/developers/gitignore/api/php-cs-fixer + diff --git a/src/Contracts/RequestContract.php b/src/Contracts/RequestContract.php index abcdcd1..7d1fea2 100644 --- a/src/Contracts/RequestContract.php +++ b/src/Contracts/RequestContract.php @@ -62,10 +62,10 @@ public function parameter(string $name, $value = null); * Get or set a query parameter in the request. * * @param string $name The query parameter name - * @param string|null $value The query parameter value to set, or null to get the current value. + * @param array|string|null $value The query parameter value to set, or null to get the current value. * @return mixed|string|null The query parameter value or instance for chaining */ - public function queryParameter(string $name, string $value = null); + public function queryParameter(string $name, $value = null); /** * Get or set multiple headers at once. diff --git a/src/Exceptions/Http/MissingFieldProvidedException.php b/src/Exceptions/Http/MissingFieldProvidedException.php index de3f9fe..7e0c342 100644 --- a/src/Exceptions/Http/MissingFieldProvidedException.php +++ b/src/Exceptions/Http/MissingFieldProvidedException.php @@ -8,6 +8,6 @@ class MissingFieldProvidedException extends BlueskyException { public function __construct($message = "", $code = 0, $previous = null) { - parent::__construct("Missing provided fields: $message", $code, $previous); + parent::__construct("Missing fields provided: $message", $code, $previous); } } diff --git a/src/Exceptions/InvalidArgumentException.php b/src/Exceptions/InvalidArgumentException.php index 7c06522..4c80bfc 100644 --- a/src/Exceptions/InvalidArgumentException.php +++ b/src/Exceptions/InvalidArgumentException.php @@ -6,5 +6,4 @@ class InvalidArgumentException extends BlueskyException { - -} \ No newline at end of file +} diff --git a/src/GenericCollection/Types/NonPrimitive/ProfileAssetType.php b/src/GenericCollection/Types/NonPrimitive/ProfileAssetType.php new file mode 100644 index 0000000..6b43ffe --- /dev/null +++ b/src/GenericCollection/Types/NonPrimitive/ProfileAssetType.php @@ -0,0 +1,14 @@ +actors; + } + + if ($actors->gettype() !== StringType::class) { + throw new InvalidArgumentException(sprintf( + "'\$actors' collection must be of type '%s' but is of type '%s'", + StringType::class, + $actors->gettype() + )); + } + + if (! ($actors->count() >= 1 && $actors->count() <= 25)) { + throw new InvalidArgumentException("'\$actors' collection count must be between 1 and 25"); + } + + $this->actors = $actors; + + $this->queryParameter('actors', array_values($this->actors->toArray())); + + return $this; + } + + /** + * @throws AuthRequired + * @throws MissingFieldProvidedException + */ + public function build(): RequestContract + { + if (is_null($this->header('Authorization'))) { + throw new AuthRequired(); + } + + if (is_null($this->actors)) { + throw new MissingFieldProvidedException("actors"); + } + + return $this; + } +} diff --git a/src/HTTP/Traits/Authentication.php b/src/HTTP/Traits/Authentication.php index 94d15d6..69e54ab 100644 --- a/src/HTTP/Traits/Authentication.php +++ b/src/HTTP/Traits/Authentication.php @@ -3,7 +3,6 @@ namespace Atproto\HTTP\Traits; use Atproto\Client; -use Atproto\Contracts\RequestContract; use Atproto\HTTP\API\APIRequest; use SplSubject; @@ -35,7 +34,7 @@ public function token(string $token = null) return $this->header('Authorization'); } - $this->header('Authorization', "Bearer " . $token); + $this->header('Authorization', "Bearer $token"); return $this; } diff --git a/src/HTTP/Traits/RequestBuilder.php b/src/HTTP/Traits/RequestBuilder.php index af3d557..e0ab102 100644 --- a/src/HTTP/Traits/RequestBuilder.php +++ b/src/HTTP/Traits/RequestBuilder.php @@ -79,7 +79,7 @@ public function parameter(string $name, $value = null) return $this; } - public function queryParameter(string $name, string $value = null) + public function queryParameter(string $name, $value = null) { if (is_null($value)) { return $this->queryParameters[$name] ?? null; diff --git a/src/Resources/App/Bsky/Actor/GetProfilesResource.php b/src/Resources/App/Bsky/Actor/GetProfilesResource.php new file mode 100644 index 0000000..3c35c4f --- /dev/null +++ b/src/Resources/App/Bsky/Actor/GetProfilesResource.php @@ -0,0 +1,21 @@ + ProfilesAsset::class + ]; + } +} diff --git a/src/Resources/Assets/ProfileAsset.php b/src/Resources/Assets/ProfileAsset.php new file mode 100644 index 0000000..9f45a39 --- /dev/null +++ b/src/Resources/Assets/ProfileAsset.php @@ -0,0 +1,11 @@ +send(); } - /** - * @throws BlueskyException - */ public function testObserverNotificationOnAuthentication(): void { $request = $this->client->app() diff --git a/tests/Feature/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php b/tests/Feature/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php new file mode 100644 index 0000000..4d18679 --- /dev/null +++ b/tests/Feature/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php @@ -0,0 +1,116 @@ +client = new Client(); + + $this->request = $this->client + ->app() + ->bsky() + ->actor() + ->getProfiles() + ->forge(); + } + + /** + * @throws BlueskyException + */ + private function authenticate(): void + { + $username = $_ENV['BLUESKY_IDENTIFIER']; + $password = $_ENV['BLUESKY_PASSWORD']; + + $this->assertIsString($username); + $this->assertIsString($password); + + $this->client->authenticate($username, $password); + } + + /** + * @throws InvalidArgumentException + * @throws \Atproto\Exceptions\InvalidArgumentException + * @throws BlueskyException + */ + public function testGettingProfiles(): void + { + $this->authenticate(); + + $profiles = new GenericCollection(new StringType(), [ + $this->client->authenticated()->did() + ]); + + $response = $this->client + ->app() + ->bsky() + ->actor() + ->getProfiles() + ->forge() + ->actors($profiles) + ->build() + ->send(); + + $this->assertInstanceOf(GetProfilesResource::class, $response); + $this->assertSame($profiles->count(), $response->profiles()->count()); + + $actualDidArr = array_map(fn (ProfileAsset $profile) => $profile->did(), $response->profiles()->toArray()); + + $this->assertSame($profiles->toArray(), $actualDidArr); + } + + public function testGettingProfilesThrowsExceptionWhenAuthTokenIsMissing(): void + { + $this->expectException(BlueskyException::class); + $this->expectException(AuthMissingException::class); + $this->expectExceptionMessage("Authentication Required"); + + $this->request->send(); + } + + public function testGettingProfilesThrowsExceptionWhenAuthTokenIsInvalid(): void + { + $this->expectException(BlueskyException::class); + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage("Malformed authorization header"); + $this->expectExceptionCode(400); + + $this->request->token('Bearer token')->send(); + } + + public function testGettingProfilesThrowsExceptionWhenActorsMissing(): void + { + $this->expectException(BlueskyException::class); + $this->expectException(MissingFieldProvidedException::class); + $this->expectExceptionMessage("Missing fields provided: actors"); + + $this->authenticate(); + + $request = (new GetProfiles($this->client))->build(); + + $request->send(); + } +} diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php index 6302cfa..6b842ac 100644 --- a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php @@ -91,7 +91,7 @@ public function testBuildReturnsSameInterface(): void public function testBuildThrowsAnExceptionWhenActorDoesNotExist(): void { $this->expectException(MissingFieldProvidedException::class); - $this->expectExceptionMessage("Missing provided fields: actor, token"); + $this->expectExceptionMessage("Missing fields provided: actor, token"); $this->request->build(); } diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php new file mode 100644 index 0000000..6bc3a4e --- /dev/null +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php @@ -0,0 +1,94 @@ +client = $this->createMock(Client::class); + $this->request = new GetProfiles($this->client); + } + + /** + * @throws InvalidArgumentException + * @throws \GenericCollection\Exceptions\InvalidArgumentException + */ + public function testActorsSetterAndGetter(): void + { + $actors = new GenericCollection(new StringType(), ['actor1', 'actor2']); + $this->request->actors($actors); + $this->assertSame($actors, $this->request->actors(), 'Actors getter should return the value set by the setter.'); + } + + public function testActorsSetterThrowsExceptionForInvalidType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("'\$actors' collection must be of type 'GenericCollection\Types\Primitive\StringType'"); + $this->request->actors(new GenericCollection(\stdClass::class)); + } + + public function testActorsSetterThrowsExceptionForTooFewActors(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("'\$actors' collection count must be between 1 and 25"); + $this->request->actors(new GenericCollection(StringType::class)); + } + + /** + * @throws \GenericCollection\Exceptions\InvalidArgumentException + */ + public function testActorsSetterThrowsExceptionForTooManyActors(): void + { + $actors = new GenericCollection(new StringType()); + for ($i = 0; $i <= 25; $i++) { + $actors->add($i, "actor$i"); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("'\$actors' collection count must be between 1 and 25"); + $this->request->actors($actors); + } + + public function testBuildThrowsAuthRequiredException(): void + { + $this->expectException(AuthRequired::class); + $this->request->build(); + } + + public function testBuildThrowsMissingFieldProvidedException(): void + { + $this->request->token('Bearer token'); + $this->expectException(MissingFieldProvidedException::class); + $this->expectExceptionMessage('actors'); + $this->request->build(); + } + + public function testBuildSucceeds(): void + { + $actors = new GenericCollection(new StringType(), ['actor1']); + $this->request->token('Bearer token')->actors($actors); + + $this->assertInstanceOf(GetProfiles::class, $this->request->build(), 'Build should return an instance of GetProfiles.'); + } + + public function testResourceMethodReturnsCorrectInstance(): void + { + $data = ['actors' => []]; + $resource = $this->request->resource($data); + $this->assertInstanceOf(GetProfilesResource::class, $resource, 'Resource method should return an instance of GetProfilesResource.'); + } +} From 52d51f35d4fa5acd600744fdc5f2b55a269155f8 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 22 Sep 2024 02:39:26 +0400 Subject: [PATCH 04/59] FIX: Refactor GetProfile request and update related tests --- .../API/Requests/App/Bsky/Actor/GetProfile.php | 14 ++------------ .../API/Requests/App/Bsky/Actor/GetProfileTest.php | 4 +++- .../Requests/App/Bsky/Graph/GetFollowersTest.php | 2 +- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php b/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php index c5b3fe1..954ac2f 100644 --- a/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php +++ b/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php @@ -49,22 +49,12 @@ public function token(string $token = null) */ public function build(): RequestContract { - if (! Arr::exists($this->headers(), 'Authorization')) { + if (! $this->header('Authorization')) { throw new AuthMissingException(); } - $missing = []; - if (! $this->queryParameter('actor')) { - $missing[] = 'actor'; - } - - if (! $this->header('Authorization')) { - $missing[] = 'token'; - } - - if (! empty($missing)) { - throw new MissingFieldProvidedException(implode(", ", $missing)); + throw new MissingFieldProvidedException('actor'); } return $this; diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php index 6b842ac..9bbce9a 100644 --- a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php @@ -90,8 +90,10 @@ public function testBuildReturnsSameInterface(): void public function testBuildThrowsAnExceptionWhenActorDoesNotExist(): void { + $this->request->token($this->faker->word); + $this->expectException(MissingFieldProvidedException::class); - $this->expectExceptionMessage("Missing fields provided: actor, token"); + $this->expectExceptionMessage("Missing fields provided: actor"); $this->request->build(); } diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php index 4883f9b..508bf92 100644 --- a/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php @@ -80,7 +80,7 @@ public function testBuildThrowsExceptionWhenAuthorizationHeaderMissing() public function testBuildThrowsExceptionWhenActorParameterMissing() { $this->expectException(MissingFieldProvidedException::class); - $this->expectExceptionMessage("Missing provided fields: actor"); + $this->expectExceptionMessage("Missing fields provided: actor"); // Set 'Authorization' header $this->request->token('Bearer token'); From 93b85f7d3fa60ee5d3875ce6f86793d4a9d44bac Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Mon, 23 Sep 2024 22:12:03 +0400 Subject: [PATCH 05/59] WIP: Implement Facet lexicon - Add new RichText classes: FeatureAbstract, Link, Mention, and Tag - Implement Facet lexicon structure as per app.bsky.richtext.facet schema - Add unit tests for FeatureAbstract serialization - Improve type safety and add input validation for union facet classes --- .../App/Bsky/RichText/FeatureAbstract.php | 25 +++++++++ src/Lexicons/App/Bsky/RichText/Link.php | 35 +++++++++++++ src/Lexicons/App/Bsky/RichText/Mention.php | 25 +++++++++ src/Lexicons/App/Bsky/RichText/Tag.php | 34 +++++++++++++ .../App/Bsky/RichText/FeatureAbstractTest.php | 49 ++++++++++++++++++ .../Lexicons/App/Bsky/RichText/LinkTest.php | 51 +++++++++++++++++++ .../App/Bsky/RichText/MentionTest.php | 20 ++++++++ .../Lexicons/App/Bsky/RichText/TagTest.php | 22 ++++++++ 8 files changed, 261 insertions(+) create mode 100644 src/Lexicons/App/Bsky/RichText/FeatureAbstract.php create mode 100644 src/Lexicons/App/Bsky/RichText/Link.php create mode 100644 src/Lexicons/App/Bsky/RichText/Mention.php create mode 100644 src/Lexicons/App/Bsky/RichText/Tag.php create mode 100644 tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php diff --git a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php new file mode 100644 index 0000000..9ea75a0 --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php @@ -0,0 +1,25 @@ + $this->type(),], + $this->schema() + ); + } + + public function __toString(): string + { + return json_encode($this); + } + + abstract public function schema(): array; + abstract public function type(): string; +} diff --git a/src/Lexicons/App/Bsky/RichText/Link.php b/src/Lexicons/App/Bsky/RichText/Link.php new file mode 100644 index 0000000..7bc4e4f --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/Link.php @@ -0,0 +1,35 @@ +url = $url; + } + + public function schema(): array + { + return [ + "uri" => $this->url, + ]; + } + + public function type(): string + { + return "link"; + } +} diff --git a/src/Lexicons/App/Bsky/RichText/Mention.php b/src/Lexicons/App/Bsky/RichText/Mention.php new file mode 100644 index 0000000..dbbfc16 --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/Mention.php @@ -0,0 +1,25 @@ +did = $did; + } + + public function schema(): array + { + return [ + "did" => $this->did, + ]; + } + + public function type(): string + { + return "mention"; + } +} diff --git a/src/Lexicons/App/Bsky/RichText/Tag.php b/src/Lexicons/App/Bsky/RichText/Tag.php new file mode 100644 index 0000000..bcb2108 --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/Tag.php @@ -0,0 +1,34 @@ + 640) { + throw new InvalidArgumentException("Tag cannot be longer than 640 characters."); + } + + $this->tag = $tag; + } + + public function schema(): array + { + return [ + "tag" => $this->tag + ]; + } + + public function type(): string + { + return "tag"; + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php new file mode 100644 index 0000000..5144cce --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php @@ -0,0 +1,49 @@ +faker = Factory::create(); + } + + /** @dataProvider featureProvider */ + public function testFeatureSerializationIsCorrect(string $class, string $input, array $expectedSchema): void + { + $instance = new $class($input); + + $this->assertSame($this->buildExpectedJson($expectedSchema), json_encode($instance)); + $this->assertSame($this->buildExpectedJson($expectedSchema), (string)$instance); + } + + public function featureProvider(): array + { + $faker = Factory::create(); + + $url = $faker->url; + $did = $faker->uuid; + $tag = $faker->word; + + return [ + [Link::class, $url, ['type' => 'link', 'uri' => $url]], + [Mention::class, $did, ['type' => 'mention', 'did' => $did]], + [Tag::class, $tag, ['type' => 'tag', 'tag' => $tag]], + ]; + } + + private function buildExpectedJson(array $schema): string + { + return json_encode($schema); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php new file mode 100644 index 0000000..9ae978f --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php @@ -0,0 +1,51 @@ +faker = Factory::create(); + } + + public function testConstructorThrowsExceptionWhenPassedInvalidDataType() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid URI: 123"); + + new Link(123); + } + + public function testLinkThrowsExceptionWhenPassedInvalidURL() + { + $invalidURL = $this->faker->word; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid URI: $invalidURL"); + + new Link($invalidURL); + } + + /** + * @throws InvalidArgumentException|ReflectionException + */ + public function testLinkConstructorWorksCorrectly() + { + $url = $this->faker->url; + $link = new Link($url); + + $this->assertSame($url, $this->getPropertyValue('url', $link)); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php new file mode 100644 index 0000000..b07447d --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php @@ -0,0 +1,20 @@ +faker->word; + $mention = new Mention($expected); + + $this->assertSame($expected, $this->getPropertyValue('did', $mention)); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php new file mode 100644 index 0000000..e0507de --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php @@ -0,0 +1,22 @@ +faker->word; + $tag = new Tag($expected); + + $this->assertSame($expected, $this->getPropertyValue('tag', $tag)); + } +} From 0ac209e71f8a08f7730d5fc6f2fcb37ee3e8c5ea Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Mon, 30 Sep 2024 21:41:01 +0400 Subject: [PATCH 06/59] Update dependencies --- composer.lock | 74 +++++++++++++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/composer.lock b/composer.lock index 93b0564..44e0bc8 100644 --- a/composer.lock +++ b/composer.lock @@ -232,16 +232,16 @@ }, { "name": "shahmal1yev/gcollection", - "version": "1.0.6", + "version": "1.0.7", "source": { "type": "git", "url": "https://github.com/shahmal1yev/gcollection.git", - "reference": "17c04887dd852eeedb4451e1d276d4faa22eb15a" + "reference": "1ec135b2c65e15b1ab97b886c21bf49adc1358cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/shahmal1yev/gcollection/zipball/17c04887dd852eeedb4451e1d276d4faa22eb15a", - "reference": "17c04887dd852eeedb4451e1d276d4faa22eb15a", + "url": "https://api.github.com/repos/shahmal1yev/gcollection/zipball/1ec135b2c65e15b1ab97b886c21bf49adc1358cc", + "reference": "1ec135b2c65e15b1ab97b886c21bf49adc1358cc", "shasum": "" }, "require": { @@ -261,9 +261,9 @@ "description": "GenericCollection is a versatile PHP library that provides a type-safe collection class for managing various data types. Simplify your data management with intuitive methods and strong type constraints.", "support": { "issues": "https://github.com/shahmal1yev/gcollection/issues", - "source": "https://github.com/shahmal1yev/gcollection/tree/1.0.6" + "source": "https://github.com/shahmal1yev/gcollection/tree/1.0.7" }, - "time": "2024-07-27T22:29:24+00:00" + "time": "2024-09-30T17:35:29+00:00" }, { "name": "symfony/deprecation-contracts", @@ -334,20 +334,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c" + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fd22ab50000ef01661e2a31d850ebaa297f8e03c", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "provide": { "ext-mbstring": "*" @@ -394,7 +394,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" }, "funding": [ { @@ -410,24 +410,24 @@ "type": "tidelift" } ], - "time": "2024-06-19T12:30:46+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433" + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/77fa7995ac1b21ab60769b7323d600a991a90433", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { @@ -474,7 +474,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" }, "funding": [ { @@ -490,20 +490,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/translation", - "version": "v5.4.42", + "version": "v5.4.44", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "1d702caccb9f091b738696185f778b1bfef7b5b2" + "reference": "6fed3a20b5b87ee9cdd9dacf545922b8fd475921" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/1d702caccb9f091b738696185f778b1bfef7b5b2", - "reference": "1d702caccb9f091b738696185f778b1bfef7b5b2", + "url": "https://api.github.com/repos/symfony/translation/zipball/6fed3a20b5b87ee9cdd9dacf545922b8fd475921", + "reference": "6fed3a20b5b87ee9cdd9dacf545922b8fd475921", "shasum": "" }, "require": { @@ -571,7 +571,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v5.4.42" + "source": "https://github.com/symfony/translation/tree/v5.4.44" }, "funding": [ { @@ -587,7 +587,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:14:19+00:00" + "time": "2024-09-15T08:12:35+00:00" }, { "name": "symfony/translation-contracts", @@ -864,16 +864,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.1.0", + "version": "v5.3.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1" + "reference": "3abf7425cd284141dc5d8d14a9ee444de3345d1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/683130c2ff8c2739f4822ff7ac5c873ec529abd1", - "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3abf7425cd284141dc5d8d14a9ee444de3345d1a", + "reference": "3abf7425cd284141dc5d8d14a9ee444de3345d1a", "shasum": "" }, "require": { @@ -916,9 +916,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.1.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.0" }, - "time": "2024-07-01T20:03:41+00:00" + "time": "2024-09-29T13:56:26+00:00" }, { "name": "phar-io/manifest", @@ -1040,16 +1040,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.3", + "version": "1.12.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "0fcbf194ab63d8159bb70d9aa3e1350051632009" + "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0fcbf194ab63d8159bb70d9aa3e1350051632009", - "reference": "0fcbf194ab63d8159bb70d9aa3e1350051632009", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", + "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", "shasum": "" }, "require": { @@ -1094,7 +1094,7 @@ "type": "github" } ], - "time": "2024-09-09T08:10:35+00:00" + "time": "2024-09-26T12:45:22+00:00" }, { "name": "phpunit/php-code-coverage", From 37acb541089c6d24f80e1ca90f32c0a5b41786b1 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Wed, 9 Oct 2024 01:50:01 +0400 Subject: [PATCH 07/59] WIP: Implement Post Builder --- src/API/Com/Atrproto/Repo/UploadBlob.php | 2 +- src/Collections/FacetCollection.php | 15 ++ src/Collections/FeatureCollection.php | 14 ++ .../Types/NonPrimitive/FollowerAssetType.php | 2 +- .../Types/NonPrimitive/LabelAssetType.php | 2 +- .../Types/NonPrimitive/ProfileAssetType.php | 2 +- src/Contracts/BuilderInterface.php | 8 + src/Contracts/LexiconBuilder.php | 9 + .../App/Bsky/Embed/EmbedInterface.php | 9 + .../App/Bsky/Embed/ImageInterface.php | 12 + .../App/Bsky/Embed/VideoInterface.php | 15 ++ .../App/Bsky/Feed/PostBuilderContract.php | 25 +++ .../App/Bsky/RichText/ByteSliceContract.php | 11 + .../App/Bsky/RichText/FacetContract.php | 13 ++ src/Contracts/Stringable.php | 8 + src/Exceptions/RuntimeException.php | 7 + .../Requests/App/Bsky/Actor/GetProfile.php | 2 +- .../Requests/App/Bsky/Graph/GetFollowers.php | 2 +- src/Lexicons/App/Bsky/Embed/BlobHandler.php | 49 +++++ src/Lexicons/App/Bsky/Embed/Caption.php | 67 ++++++ .../Embed/Collections/CaptionCollection.php | 42 ++++ .../Embed/Collections/ImageCollection.php | 57 +++++ src/Lexicons/App/Bsky/Embed/File.php | 35 +++ src/Lexicons/App/Bsky/Embed/Image.php | 70 ++++++ src/Lexicons/App/Bsky/Embed/Video.php | 85 ++++++++ src/Lexicons/App/Bsky/Feed/Post.php | 205 ++++++++++++++++++ src/Lexicons/App/Bsky/RichText/ByteSlice.php | 35 +++ src/Lexicons/App/Bsky/RichText/Facet.php | 47 ++++ .../App/Bsky/RichText/FeatureAbstract.php | 28 ++- .../App/Bsky/RichText/FeatureFactory.php | 22 ++ src/Lexicons/App/Bsky/RichText/Link.php | 24 +- src/Lexicons/App/Bsky/RichText/Mention.php | 19 +- src/Lexicons/App/Bsky/RichText/Tag.php | 28 +-- src/Resources/Assets/FollowersAsset.php | 2 +- src/Resources/Assets/LabelsAsset.php | 2 +- src/Resources/BaseResource.php | 2 +- src/{Helpers => Support}/Arr.php | 2 +- src/{Helpers => Support}/File.php | 2 +- src/Support/Media.php | 10 + .../Unit/Lexicons/App/Bsky/Feed/PostTest.php | 195 +++++++++++++++++ .../App/Bsky/RichText/FeatureAbstractTest.php | 58 ++--- .../App/Bsky/RichText/FeatureTests.php | 110 ++++++++++ .../Lexicons/App/Bsky/RichText/LinkTest.php | 46 +--- .../App/Bsky/RichText/MentionTest.php | 15 +- .../Lexicons/App/Bsky/RichText/TagTest.php | 17 +- 45 files changed, 1276 insertions(+), 156 deletions(-) create mode 100644 src/Collections/FacetCollection.php create mode 100644 src/Collections/FeatureCollection.php rename src/{GenericCollection => Collections}/Types/NonPrimitive/FollowerAssetType.php (82%) rename src/{GenericCollection => Collections}/Types/NonPrimitive/LabelAssetType.php (81%) rename src/{GenericCollection => Collections}/Types/NonPrimitive/ProfileAssetType.php (81%) create mode 100644 src/Contracts/BuilderInterface.php create mode 100644 src/Contracts/LexiconBuilder.php create mode 100644 src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php create mode 100644 src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php create mode 100644 src/Contracts/Lexicons/App/Bsky/Embed/VideoInterface.php create mode 100644 src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php create mode 100644 src/Contracts/Lexicons/App/Bsky/RichText/ByteSliceContract.php create mode 100644 src/Contracts/Lexicons/App/Bsky/RichText/FacetContract.php create mode 100644 src/Contracts/Stringable.php create mode 100644 src/Exceptions/RuntimeException.php create mode 100644 src/Lexicons/App/Bsky/Embed/BlobHandler.php create mode 100644 src/Lexicons/App/Bsky/Embed/Caption.php create mode 100644 src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php create mode 100644 src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php create mode 100644 src/Lexicons/App/Bsky/Embed/File.php create mode 100644 src/Lexicons/App/Bsky/Embed/Image.php create mode 100644 src/Lexicons/App/Bsky/Embed/Video.php create mode 100644 src/Lexicons/App/Bsky/Feed/Post.php create mode 100644 src/Lexicons/App/Bsky/RichText/ByteSlice.php create mode 100644 src/Lexicons/App/Bsky/RichText/Facet.php create mode 100644 src/Lexicons/App/Bsky/RichText/FeatureFactory.php rename src/{Helpers => Support}/Arr.php (99%) rename src/{Helpers => Support}/File.php (99%) create mode 100644 src/Support/Media.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php diff --git a/src/API/Com/Atrproto/Repo/UploadBlob.php b/src/API/Com/Atrproto/Repo/UploadBlob.php index ce205f2..a81d934 100644 --- a/src/API/Com/Atrproto/Repo/UploadBlob.php +++ b/src/API/Com/Atrproto/Repo/UploadBlob.php @@ -4,7 +4,7 @@ use Atproto\Contracts\HTTP\RequestContract; use Atproto\Exceptions\Http\Request\RequestBodyHasMissingRequiredFields; -use Atproto\Helpers\File; +use Atproto\Support\File; use Atproto\Resources\Com\Atproto\Repo\UploadBlobResource; use InvalidArgumentException; diff --git a/src/Collections/FacetCollection.php b/src/Collections/FacetCollection.php new file mode 100644 index 0000000..fd55269 --- /dev/null +++ b/src/Collections/FacetCollection.php @@ -0,0 +1,15 @@ + $item instanceof FacetContract, $collection); + } +} diff --git a/src/Collections/FeatureCollection.php b/src/Collections/FeatureCollection.php new file mode 100644 index 0000000..02e0569 --- /dev/null +++ b/src/Collections/FeatureCollection.php @@ -0,0 +1,14 @@ + $feature instanceof FeatureAbstract, $features); + } +} diff --git a/src/GenericCollection/Types/NonPrimitive/FollowerAssetType.php b/src/Collections/Types/NonPrimitive/FollowerAssetType.php similarity index 82% rename from src/GenericCollection/Types/NonPrimitive/FollowerAssetType.php rename to src/Collections/Types/NonPrimitive/FollowerAssetType.php index 9d0c79c..9e156e3 100644 --- a/src/GenericCollection/Types/NonPrimitive/FollowerAssetType.php +++ b/src/Collections/Types/NonPrimitive/FollowerAssetType.php @@ -1,6 +1,6 @@ path = $path; + $this->handle(); + } + + /** + * @throws InvalidArgumentException + */ + private function handle(): void + { + $this->isFile(); + $this->isReadable(); + $this->isValid(); + } + + /** + * @throws InvalidArgumentException + */ + private function isFile(): void + { + if (! is_file($this->path)) { + throw new InvalidArgumentException("$this->path is not a file."); + } + } + + /** + * @throws InvalidArgumentException + */ + private function isReadable(): void + { + if (! is_readable($this->path)) { + throw new InvalidArgumentException("$this->path is not readable."); + } + } +} \ No newline at end of file diff --git a/src/Lexicons/App/Bsky/Embed/Caption.php b/src/Lexicons/App/Bsky/Embed/Caption.php new file mode 100644 index 0000000..9dd7fb6 --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Caption.php @@ -0,0 +1,67 @@ +lang($lang); + $this->file($file); + } + + public function lang(string $lang = null): string + { + if (is_null($lang)) { + return $this->lang; + } + + $this->lang = $lang; + + return $this->lang; + } + + /** + * @throws InvalidArgumentException + */ + public function file(File $file = null) + { + if (is_null($file)) { + return $this->file; + } + + if ($file->size() > self::MAX_SIZE) { + throw new InvalidArgumentException($file->path().' is too large.'); + } + + if ($file->type() !== 'text/vtt') { + throw new InvalidArgumentException($file->path().' is not a text vtt.'); + } + + $this->file = $file; + + return $this; + } + + /** + * @throws InvalidArgumentException + */ + public function jsonSerialize(): array + { + return [ + 'lang' => $this->lang(), + 'file' => $this->file()->blob(), + ]; + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php new file mode 100644 index 0000000..e2c66b7 --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php @@ -0,0 +1,42 @@ + $item instanceof Caption, $collection); + } + + public function validate($value): bool + { + return parent::validate($value) && $this->validateLength(); + } + + private function validateLength(): bool + { + return $this->count() <= self::MAX_SIZE; + } + + public function validateWithException($value): void + { + parent::validateWithException($value); + + if (! $this->validateLength()) { + throw new InvalidArgumentException("Caption length must be less than or equal " . self::MAX_SIZE); + } + } + + public function jsonSerialize() + { + return $this->toArray(); + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php new file mode 100644 index 0000000..6f3b3aa --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php @@ -0,0 +1,57 @@ + $item instanceof ImageInterface, $collection); + } + + private function validateLength(): bool + { + return ($this->count() <= self::MAX_ITEM); + } + + private function validateSize(ImageInterface $image): bool + { + return $image->size() <= self::MAX_SIZE; + } + + public function validate($value): bool + { + return parent::validate($value) + && $this->validateSize($value) + && $this->validateLength(); + } + + /** + * @throws InvalidArgumentException + */ + public function validateWithException($value): void + { + parent::validateWithException($value); + + if (! $this->validateLength()) { + throw new InvalidArgumentException("Image limit exceeded. Maximum allowed images: ".self::MAX_ITEM); + } + + if (! $this->validateSize($value)) { + throw new InvalidArgumentException("Image size exceeded. Maximum allowed size: ".self::MAX_SIZE); + } + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Lexicons/App/Bsky/Embed/File.php b/src/Lexicons/App/Bsky/Embed/File.php new file mode 100644 index 0000000..61d1b93 --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/File.php @@ -0,0 +1,35 @@ +path); + } + + public function type(): string + { + return mime_content_type($this->path); + } + + public function blob(): string + { + return file_get_contents($this->path); + } + + public function path(): string + { + return $this->path; + } + + public function __toString(): string + { + return $this->blob(); + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Image.php b/src/Lexicons/App/Bsky/Embed/Image.php new file mode 100644 index 0000000..7036263 --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Image.php @@ -0,0 +1,70 @@ +type(), 'image/')) { + throw new InvalidArgumentException($file->path()." is not a valid image file."); + } + + $this->file = $file; + $this->alt = $alt; + } + + public function alt(string $alt = null) + { + if (is_null($alt)) { + return $this->alt; + } + + $this->alt = $alt; + + return $this; + } + + /** + * @throws InvalidArgumentException + */ + public function aspectRatio(int $width = null, int $height = null) + { + if (is_null($width) && is_null($height)) { + return $this->aspectRatio; + } + + if ($width < 1 || $height < 1) { + throw new InvalidArgumentException("Width and height must be at least 1"); + } + + $this->aspectRatio = [ + 'width' => $width, + 'height' => $height + ]; + + return $this; + } + + /** + * @throws InvalidArgumentException + */ + public function jsonSerialize(): array + { + return array_filter([ + 'alt' => $this->alt(), + 'image' => $this->file->blob(), + 'aspectRatio' => $this->aspectRatio() ?: null, + ]); + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php new file mode 100644 index 0000000..f4de269 --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -0,0 +1,85 @@ +type()) { + throw new InvalidArgumentException($file->path()." is not a valid video file."); + } + + $this->file = $file; + + $this->captions = new CaptionCollection(); + } + + /** + * @throws InvalidArgumentException + */ + public function jsonSerialize(): array + { + return array_filter([ + 'alt' => $this->alt() ?: null, + 'video' => $this->file->blob(), + 'aspectRatio' => $this->aspectRatio() ?: null, + 'captions' => $this->captions()->toArray() ?: null, + ]); + } + + public function alt(string $alt = null) + { + if (is_null($alt)) { + return $this->alt; + } + + $this->alt = $alt; + + return $this; + } + + /** + * @throws InvalidArgumentException + */ + public function aspectRatio(int $width = null, int $height = null) + { + if (is_null($width) && is_null($height)) { + return $this->aspectRatio; + } + + if ($width < 1 || $height < 1) { + throw new InvalidArgumentException("Width and height must be greater than 1"); + } + + $this->aspectRatio = [ + 'width' => $width, + 'height' => $height + ]; + + return $this; + } + + public function captions(CaptionCollection $captions = null) + { + if (is_null($captions)) { + return $this->captions; + } + + $this->captions = $captions; + + return $this; + } +} diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php new file mode 100644 index 0000000..560b1af --- /dev/null +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -0,0 +1,205 @@ +facets = new FacetCollection(); + } + + /** + * @throws InvalidArgumentException + */ + public function text(...$items): PostBuilderContract + { + foreach ($items as $index => $item) { + $this->validate($item, $index); + $this->processItem($item); + } + + return $this; + } + + /** + * Adds a tag to the post. + * + * @throws InvalidArgumentException + */ + public function tag(string $reference, string $label = null): PostBuilderContract + { + return $this->addFeatureItem('tag', $reference, $label); + } + + /** + * Adds a link to the post. + * + * @throws InvalidArgumentException + */ + public function link(string $reference, string $label = null): PostBuilderContract + { + return $this->addFeatureItem('link', $reference, $label); + } + + /** + * Adds a mention to the post. + * + * @throws InvalidArgumentException + */ + public function mention(string $reference, string $label = null): PostBuilderContract + { + return $this->addFeatureItem('mention', $reference, $label); + } + + public function embed(...$embeds): PostBuilderContract + { + return $this; + } + + public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract + { + $this->createdAt = $dateTime; + + return $this; + } + + public function jsonSerialize(): array + { + return [ + '$type' => self::TYPE_NAME, + 'createdAt' => $this->getFormattedCreatedAt(), + 'text' => $this->text, + 'facets' => $this->facets->toArray(), + ]; + } + + public function __toString(): string + { + return json_encode($this); + } + + private function isString($item): bool + { + return is_scalar($item); + } + + private function isFeature($item): bool + { + return $item instanceof FeatureAbstract; + } + + /** + * Validates the given item. + * + * @throws InvalidArgumentException + */ + private function validate($item, int $index = 0): void + { + if (!$this->isString($item) && !$this->isFeature($item)) { + throw new InvalidArgumentException( + sprintf( + 'Argument at index %d is invalid: must be a string or an instance of %s.', + $index + 1, + FeatureAbstract::class + ) + ); + } + + if ($this->isString($item) && mb_strlen($item) > self::TEXT_LIMIT) { + throw new InvalidArgumentException( + sprintf( + 'Text must be less than or equal to %d characters.', + self::TEXT_LIMIT + ) + ); + } + } + + /** + * Processes the item, adding it to the post text or as a feature. + * @throws InvalidArgumentException + */ + private function processItem($item): void + { + if ($this->isString($item)) { + $this->addString($item); + } else { + $this->addFeature($item); + } + } + + /** + * Adds a feature item like tag, link, or mention to the post. + * + * @throws InvalidArgumentException + */ + private function addFeatureItem(string $type, string $reference, ?string $label): PostBuilderContract + { + $feature = FeatureFactory::{$type}($reference, $label); + $this->addFeature($feature); + + return $this; + } + + /** + * Adds a string to the post text. + * + * @param string $string + */ + private function addString(string $string): void + { + $this->text .= $string; + } + + /** + * Adds a feature to the post. + * + * @throws InvalidArgumentException + */ + private function addFeature(FeatureAbstract $feature): void + { + $label = (string) $feature; + $this->text .= $label; + + try { + $facet = new Facet( + new FeatureCollection([$feature]), + new ByteSlice($this->text, $label) + ); + $this->facets[] = $facet; + } catch (\GenericCollection\Exceptions\InvalidArgumentException $e) { + throw new InvalidArgumentException( + sprintf('Feature must be an instance of %s.', FeatureAbstract::class) + ); + } + } + + /** + * Returns the formatted creation date. + */ + private function getFormattedCreatedAt(): string + { + $createdAt = $this->createdAt ?: Carbon::now(); + + return $createdAt->format(DATE_ATOM); + } +} diff --git a/src/Lexicons/App/Bsky/RichText/ByteSlice.php b/src/Lexicons/App/Bsky/RichText/ByteSlice.php new file mode 100644 index 0000000..9f5ab96 --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/ByteSlice.php @@ -0,0 +1,35 @@ +text = $text; + $this->added = $added; + } + + public function start(): int + { + return mb_strpos($this->text, $this->added); + } + + public function end(): int + { + return mb_strpos($this->text, $this->added) + mb_strlen($this->added); + } + + public function jsonSerialize(): array + { + return [ + 'byteStart' => $this->start(), + 'byteEnd' => $this->end(), + ]; + } +} \ No newline at end of file diff --git a/src/Lexicons/App/Bsky/RichText/Facet.php b/src/Lexicons/App/Bsky/RichText/Facet.php new file mode 100644 index 0000000..dfbb9a4 --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/Facet.php @@ -0,0 +1,47 @@ +features = $features; + $this->byteSlice = $byteSlice; + } + + public function features(): FeatureCollection + { + return $this->features; + } + + public function byteSlice(): ByteSliceContract + { + return $this->byteSlice; + } + + public function jsonSerialize(): array + { + return [ + 'index' => $this->byteSlice, + 'features' => $this->features->toArray() + ]; + } + + public function __toString(): string + { + return json_encode($this); + } +} diff --git a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php index 9ea75a0..bb50204 100644 --- a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php +++ b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php @@ -3,23 +3,29 @@ namespace Atproto\Lexicons\App\Bsky\RichText; use Atproto\Contracts\LexiconBuilder; -use Atproto\Contracts\Lexicons\App\Bsky\RichText\FeatureContract; +use Atproto\Contracts\Stringable; -abstract class FeatureAbstract implements LexiconBuilder +abstract class FeatureAbstract implements LexiconBuilder, Stringable { - public function jsonSerialize(): array + protected string $reference; + protected string $label; + + public function __construct(string $reference, string $label = null) { - return array_merge( - ['type' => $this->type(),], - $this->schema() - ); + if (is_null($label)) { + $label = $reference; + } + + $this->reference = $reference; + $this->label = $label; } - public function __toString(): string + final public function jsonSerialize(): array { - return json_encode($this); + return ['type' => $this->type()] + $this->schema(); } - abstract public function schema(): array; - abstract public function type(): string; + abstract protected function type(): string; + + abstract protected function schema(): array; } diff --git a/src/Lexicons/App/Bsky/RichText/FeatureFactory.php b/src/Lexicons/App/Bsky/RichText/FeatureFactory.php new file mode 100644 index 0000000..01a9682 --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/FeatureFactory.php @@ -0,0 +1,22 @@ +url = $url; + return [ + "label" => $this->label, + "uri" => $this->reference, + ]; } - public function schema(): array + public function __toString(): string { - return [ - "uri" => $this->url, - ]; + return $this->label; } - public function type(): string + protected function type(): string { return "link"; } diff --git a/src/Lexicons/App/Bsky/RichText/Mention.php b/src/Lexicons/App/Bsky/RichText/Mention.php index dbbfc16..989626b 100644 --- a/src/Lexicons/App/Bsky/RichText/Mention.php +++ b/src/Lexicons/App/Bsky/RichText/Mention.php @@ -4,22 +4,23 @@ class Mention extends FeatureAbstract { - private string $did; + protected ?string $type = 'mention'; - public function __construct(string $did) + protected function schema(): array { - $this->did = $did; + return [ + 'label' => "@$this->label", + 'did' => $this->reference, + ]; } - public function schema(): array + public function __toString(): string { - return [ - "did" => $this->did, - ]; + return "@$this->label"; } - public function type(): string + protected function type(): string { - return "mention"; + return 'mention'; } } diff --git a/src/Lexicons/App/Bsky/RichText/Tag.php b/src/Lexicons/App/Bsky/RichText/Tag.php index bcb2108..9f3291e 100644 --- a/src/Lexicons/App/Bsky/RichText/Tag.php +++ b/src/Lexicons/App/Bsky/RichText/Tag.php @@ -2,33 +2,23 @@ namespace Atproto\Lexicons\App\Bsky\RichText; -use Atproto\Exceptions\InvalidArgumentException; - class Tag extends FeatureAbstract { - private string $tag; - - /** - * @throws InvalidArgumentException - */ - public function __construct(string $tag) + protected function schema(): array { - if (mb_strlen($tag, "UTF-8") > 640) { - throw new InvalidArgumentException("Tag cannot be longer than 640 characters."); - } - - $this->tag = $tag; + return [ + "label" => "#$this->label", + "tag" => $this->reference, + ]; } - public function schema(): array + public function __toString(): string { - return [ - "tag" => $this->tag - ]; + return "#$this->label"; } - public function type(): string + protected function type(): string { - return "tag"; + return 'tag'; } } diff --git a/src/Resources/Assets/FollowersAsset.php b/src/Resources/Assets/FollowersAsset.php index b502797..637fcc3 100644 --- a/src/Resources/Assets/FollowersAsset.php +++ b/src/Resources/Assets/FollowersAsset.php @@ -3,7 +3,7 @@ namespace Atproto\Resources\Assets; use Atproto\Contracts\HTTP\Resources\AssetContract; -use Atproto\GenericCollection\Types\NonPrimitive\FollowerAssetType; +use Atproto\Collections\Types\NonPrimitive\FollowerAssetType; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; use GenericCollection\Interfaces\TypeInterface; diff --git a/src/Resources/Assets/LabelsAsset.php b/src/Resources/Assets/LabelsAsset.php index 6c9dc19..9894d25 100644 --- a/src/Resources/Assets/LabelsAsset.php +++ b/src/Resources/Assets/LabelsAsset.php @@ -3,7 +3,7 @@ namespace Atproto\Resources\Assets; use Atproto\Contracts\HTTP\Resources\AssetContract; -use Atproto\GenericCollection\Types\NonPrimitive\LabelAssetType; +use Atproto\Collections\Types\NonPrimitive\LabelAssetType; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; use GenericCollection\Interfaces\TypeInterface; diff --git a/src/Resources/BaseResource.php b/src/Resources/BaseResource.php index 0d0f346..fa1df67 100644 --- a/src/Resources/BaseResource.php +++ b/src/Resources/BaseResource.php @@ -4,7 +4,7 @@ use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Exceptions\Resource\BadAssetCallException; -use Atproto\Helpers\Arr; +use Atproto\Support\Arr; use Atproto\Traits\Castable; trait BaseResource diff --git a/src/Helpers/Arr.php b/src/Support/Arr.php similarity index 99% rename from src/Helpers/Arr.php rename to src/Support/Arr.php index 5bed62b..d8777ab 100644 --- a/src/Helpers/Arr.php +++ b/src/Support/Arr.php @@ -1,6 +1,6 @@ post = new Post(); + } + + /** + * @throws InvalidArgumentException + */ + public function testTextMethodWithBasicUsage() + { + $this->post->text('Hello, world!'); + $result = json_decode($this->post, true); + $this->assertEquals('Hello, world!', $result['text']); + } + + /** + * @throws InvalidArgumentException + */ + public function testTextMethod() + { + $this->post->text('Hello', ', ', new Mention('example:did:123', 'user'), "! It's ", 5, " o'clock now."); + $result = json_decode(json_encode($this->post), true); + $this->assertEquals("Hello, @user! It's 5 o'clock now.", $result['text']); + + $this->post->text('This ', new Mention('example:did:123', 'user'), '!'); + } + + /** + * @throws InvalidArgumentException + */ + public function testTagMethod() + { + $this->post->tag('example', 'test'); + $result = json_decode($this->post, true); + $this->assertEquals('#test', $result['text']); + $this->assertCount(1, $result['facets']); + } + + /** + * @throws InvalidArgumentException + */ + public function testLinkMethod() + { + $this->post->link('https://example.com', 'Example'); + $result = json_decode($this->post, true); + $this->assertEquals('Example', $result['text']); + $this->assertCount(1, $result['facets']); + } + + /** + * @throws InvalidArgumentException + */ + public function testMentionMethod() + { + $this->post->mention('did:example:123', 'user'); + $result = json_decode($this->post, true); + $this->assertEquals('@user', $result['text']); + $this->assertCount(1, $result['facets']); + } + + public function testCombinationOfMethods() + { + $this->post = $this->post(); + + $result = json_decode($this->post, true); + $this->assertEquals('Hello @user! Check out this link #example_tag', $result['text']); + $this->assertCount(3, $result['facets']); + } + + public function testCoordinatesOfFacets(): void + { + $this->post = $this->post(); + + $result = json_decode($this->post, true); + + $text = $result['text']; + $facets = $result['facets']; + + $this->assertSame($facets[0]['index'], $this->bytes($text, "@user")); + $this->assertSame($facets[1]['index'], $this->bytes($text, "this link")); + $this->assertSame($facets[2]['index'], $this->bytes($text, "#example_tag")); + } + + private function bytes(string $haystack, string $needle): array + { + $pos = mb_strpos($haystack, $needle); + $len = $pos + mb_strlen($needle); + + return [ + 'byteStart' => $pos, + 'byteEnd' => $len, + ]; + } + + private function post(): Post + { + return $this->post->text('Hello ') + ->mention('did:example:123', 'user') + ->text('! Check out ') + ->link('https://example.com', 'this link') + ->text(' ') + ->tag('example_tag'); + } + + /** + * @throws InvalidArgumentException + */ + public function testTextThrowsExceptionWhenLimitIsExceeded() + { + $this->post->text(str_repeat('a', 3000)); + + $this->expectException(InvalidArgumentException::class); + + $this->post->text(str_repeat('a', 3001)); + } + + public function testTextThrowsExceptionWhenPassedInvalidArgument(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "Argument at index 4 is invalid: must be a string or an instance of " . FeatureAbstract::class + ); + + $this->post->text(1, true, 'string', new Post); + } + + public function testCreatedAtField() + { + $result = json_decode($this->post, true); + $this->assertArrayHasKey('createdAt', $result); + $this->assertNotNull($result['createdAt']); + } + + public function testJsonSerialize() + { + $this->post->text('Test post'); + $result = $this->post->jsonSerialize(); + $this->assertArrayHasKey('$type', $result); + $this->assertEquals('app.bsky.feed.post', $result['$type']); + $this->assertArrayHasKey('text', $result); + $this->assertArrayHasKey('createdAt', $result); + $this->assertArrayHasKey('facets', $result); + } + + /** + * @throws InvalidArgumentException + */ + public function testCreatedAtAssignedByDefault(): void + { + $post = $this->post->text('Test post'); + $result = json_decode($post, true); + + $this->assertSame($result['createdAt'], Carbon::now()->toIso8601String()); + } + + public function testCreatedAtReturnsAssignedTime(): void + { + $timestamp = time() + 3600; // Get the current timestamp + $this->post->createdAt(new DateTimeImmutable("@$timestamp")); + + $actual = json_decode($this->post, false)->createdAt; + $expected = Carbon::now()->modify("+1 hour")->toIso8601String(); + + $this->assertSame( + $actual, + $expected, + ); + } + + public function testConstructorWorksCorrectlyOnDirectBuild(): void + { + $array = json_decode($this->post, true); + $json = json_encode($array); + + $this->assertTrue(is_array($array)); + $this->assertTrue(json_encode($array) === $json); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php index 5144cce..abdfb50 100644 --- a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php +++ b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php @@ -2,48 +2,52 @@ namespace Tests\Unit\Lexicons\App\Bsky\RichText; -use Atproto\Lexicons\App\Bsky\RichText\Link; -use Atproto\Lexicons\App\Bsky\RichText\Mention; -use Atproto\Lexicons\App\Bsky\RichText\Tag; -use Faker\Factory; +use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use PHPUnit\Framework\TestCase; +use ReflectionException; use Tests\Supports\Reflection; class FeatureAbstractTest extends TestCase { use Reflection; - public function setUp(): void + /** + * @throws ReflectionException + */ + public function testConstructorAssignsReferenceAndLabel() { - $this->faker = Factory::create(); - } + $mock = $this->getMockBuilder(FeatureAbstract::class) + ->setConstructorArgs(['reference', 'label']) + ->onlyMethods(['schema']) + ->getMockForAbstractClass(); - /** @dataProvider featureProvider */ - public function testFeatureSerializationIsCorrect(string $class, string $input, array $expectedSchema): void - { - $instance = new $class($input); + $reference = $this->getPropertyValue('reference', $mock); + $label = $this->getPropertyValue('label', $mock); - $this->assertSame($this->buildExpectedJson($expectedSchema), json_encode($instance)); - $this->assertSame($this->buildExpectedJson($expectedSchema), (string)$instance); + $this->assertSame('reference', $reference); + $this->assertSame('label', $label); } - public function featureProvider(): array + public function testJsonSerializeReturnsCorrectArray() { - $faker = Factory::create(); + $mock = $this->getMockBuilder(FeatureAbstract::class) + ->setConstructorArgs(['reference', 'label']) + ->onlyMethods(['schema', 'type']) + ->getMockForAbstractClass(); - $url = $faker->url; - $did = $faker->uuid; - $tag = $faker->word; + $schema = ['key' => 'value']; - return [ - [Link::class, $url, ['type' => 'link', 'uri' => $url]], - [Mention::class, $did, ['type' => 'mention', 'did' => $did]], - [Tag::class, $tag, ['type' => 'tag', 'tag' => $tag]], - ]; - } + $mock->expects($this->once()) + ->method('schema') + ->willReturn($schema); - private function buildExpectedJson(array $schema): string - { - return json_encode($schema); + $mock->expects($this->once()) + ->method('type') + ->willReturn('feature'); + + $this->assertSame( + ['type' => 'feature'] + $schema, + $mock->jsonSerialize() + ); } } diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php new file mode 100644 index 0000000..b6237d5 --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php @@ -0,0 +1,110 @@ +label = 'label'; + $this->reference = 'reference'; + } + + /** + * @throws ReflectionException + */ + public function testTypeReturnsCorrectType(): void + { + $feature = $this->feature($this->reference); + $method = $this->method('type', $feature); + $expected = $this->type; + + $this->assertSame( + $expected, + $method->invoke($feature) + ); + } + + public function test__toStringReturnsCorrectLabelWhenPassedBothParameters(): void + { + $feature = $this->feature($this->reference, $this->label); + $this->assert__toString($this->label, $feature); + } + + public function test__toStringReturnsCorrectLabelWhenPassedSingleParameter(): void + { + $feature = $this->feature($this->reference); + + $this->assert__toString($this->reference, $feature); + } + + private function assert__toString(string $expected, FeatureAbstract $feature): void + { + $this->assertEquals( + $this->prefix . $expected, + $feature + ); + } + + public function testSchemaReturnsCorrectSchemaWhenPassedBothParameters(): void + { + $feature = $this->feature($this->reference, $this->label); + + $expected = [ + 'type' => $this->type, + 'label' => $this->prefix . $this->label, + $this->key => $this->reference, + ]; + + $this->assertSchema($expected, $feature); + } + + public function testSchemaReturnsCorrectSchemaWhenPassedSingleParameter(): void + { + $expected = $this->schema($this->reference); + + $this->assertSchema($expected, $this->feature($this->reference)); + } + + private function schema(string $label): array + { + return [ + 'type' => $this->type, + 'label' => $this->prefix . $label, + $this->key => $this->reference, + ]; + } + + private function assertSchema(array $expected, FeatureAbstract $feature): void + { + $this->assertSame( + $expected, + json_decode(json_encode($feature), true) + ); + + $this->assertSame( + $expected, + $feature->jsonSerialize() + ); + } + + private function feature(string $reference, string $label = null): FeatureAbstract + { + $namespace = $this->namespace; + + if (! is_null($label)) { + return new $namespace($reference, $label); + } + + return new $namespace($reference); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php index 9ae978f..1730e65 100644 --- a/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php +++ b/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php @@ -2,50 +2,14 @@ namespace Tests\Unit\Lexicons\App\Bsky\RichText; -use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\RichText\Link; -use Faker\Factory; -use Faker\Generator; -use ReflectionException; -use Tests\Supports\Reflection; class LinkTest extends FeatureAbstractTest { - use Reflection; + use FeatureTests; - private Generator $faker; - - public function setUp(): void - { - $this->faker = Factory::create(); - } - - public function testConstructorThrowsExceptionWhenPassedInvalidDataType() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Invalid URI: 123"); - - new Link(123); - } - - public function testLinkThrowsExceptionWhenPassedInvalidURL() - { - $invalidURL = $this->faker->word; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Invalid URI: $invalidURL"); - - new Link($invalidURL); - } - - /** - * @throws InvalidArgumentException|ReflectionException - */ - public function testLinkConstructorWorksCorrectly() - { - $url = $this->faker->url; - $link = new Link($url); - - $this->assertSame($url, $this->getPropertyValue('url', $link)); - } + private string $namespace = Link::class; + private string $type = 'link'; + private string $key = 'uri'; + private string $prefix = ''; } diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php index b07447d..ba3bd46 100644 --- a/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php +++ b/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php @@ -3,18 +3,13 @@ namespace Tests\Unit\Lexicons\App\Bsky\RichText; use Atproto\Lexicons\App\Bsky\RichText\Mention; -use ReflectionException; class MentionTest extends FeatureAbstractTest { - /** - * @throws ReflectionException - */ - public function testConstructorWorksCorrectly(): void - { - $expected = $this->faker->word; - $mention = new Mention($expected); + use FeatureTests; - $this->assertSame($expected, $this->getPropertyValue('did', $mention)); - } + private string $namespace = Mention::class; + private string $type = 'mention'; + private string $key = 'did'; + private string $prefix = '@'; } diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php index e0507de..4996fad 100644 --- a/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php +++ b/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php @@ -2,21 +2,14 @@ namespace Tests\Unit\Lexicons\App\Bsky\RichText; -use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\RichText\Tag; -use ReflectionException; class TagTest extends FeatureAbstractTest { - /** - * @throws ReflectionException - * @throws InvalidArgumentException - */ - public function testConstructorWorksCorrectly(): void - { - $expected = $this->faker->word; - $tag = new Tag($expected); + use FeatureTests; - $this->assertSame($expected, $this->getPropertyValue('tag', $tag)); - } + private string $namespace = Tag::class; + private string $type = 'tag'; + private string $key = 'tag'; + private string $prefix = '#'; } From 4c3a7c7f77a173c888430e6406a517ebfb0e9491 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Thu, 10 Oct 2024 01:43:43 +0400 Subject: [PATCH 08/59] WIP: Implement components of post builder, improve validation, and add tests - Added `Stringable` interface to `File`, `Caption`, and `Image` for improved string handling. - Updated `BlobHandler` to remove unnecessary `isValid` check, simplifying file handling logic. - Enhanced `Caption` class with stricter validation for file size and type, using `InvalidArgumentException`. - Improved constructor and method handling in `Caption`, ensuring proper setting of file and language. - Created comprehensive unit tests for `Caption`, covering file validation, language setting, and JSON serialization. - Introduced `FileMocking` trait for reusable mock file generation in tests. --- .../App/Bsky/Embed/ImageInterface.php | 3 +- src/Lexicons/App/Bsky/Embed/BlobHandler.php | 3 +- src/Lexicons/App/Bsky/Embed/Caption.php | 16 +- src/Lexicons/App/Bsky/Embed/File.php | 2 +- src/Lexicons/App/Bsky/Embed/Image.php | 7 +- src/Support/File.php | 2 +- tests/Lexicons/App/Bsky/Embed/CaptionTest.php | 110 ++++++++++++++ tests/Lexicons/App/Bsky/Embed/FileMocking.php | 42 +++++ tests/Lexicons/App/Bsky/Embed/FileTest.php | 98 ++++++++++++ tests/Lexicons/App/Bsky/Embed/ImageTest.php | 143 ++++++++++++++++++ 10 files changed, 415 insertions(+), 11 deletions(-) create mode 100644 tests/Lexicons/App/Bsky/Embed/CaptionTest.php create mode 100644 tests/Lexicons/App/Bsky/Embed/FileMocking.php create mode 100644 tests/Lexicons/App/Bsky/Embed/FileTest.php create mode 100644 tests/Lexicons/App/Bsky/Embed/ImageTest.php diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php index d4007ee..73d0a22 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php @@ -2,9 +2,10 @@ namespace Atproto\Contracts\Lexicons\App\Bsky\Embed; +use Atproto\Contracts\Stringable; use JsonSerializable; -interface ImageInterface extends JsonSerializable +interface ImageInterface extends JsonSerializable, Stringable { public function jsonSerialize(): array; public function alt(string $alt = null); diff --git a/src/Lexicons/App/Bsky/Embed/BlobHandler.php b/src/Lexicons/App/Bsky/Embed/BlobHandler.php index 060947d..0c8e243 100644 --- a/src/Lexicons/App/Bsky/Embed/BlobHandler.php +++ b/src/Lexicons/App/Bsky/Embed/BlobHandler.php @@ -24,7 +24,6 @@ private function handle(): void { $this->isFile(); $this->isReadable(); - $this->isValid(); } /** @@ -46,4 +45,4 @@ private function isReadable(): void throw new InvalidArgumentException("$this->path is not readable."); } } -} \ No newline at end of file +} diff --git a/src/Lexicons/App/Bsky/Embed/Caption.php b/src/Lexicons/App/Bsky/Embed/Caption.php index 9dd7fb6..4f205aa 100644 --- a/src/Lexicons/App/Bsky/Embed/Caption.php +++ b/src/Lexicons/App/Bsky/Embed/Caption.php @@ -2,10 +2,11 @@ namespace Atproto\Lexicons\App\Bsky\Embed; +use Atproto\Contracts\Stringable; use Atproto\Exceptions\InvalidArgumentException; use JsonSerializable; -class Caption implements JsonSerializable +class Caption implements JsonSerializable, Stringable { private const MAX_SIZE = 20000; @@ -21,7 +22,7 @@ public function __construct(string $lang, File $file) $this->file($file); } - public function lang(string $lang = null): string + public function lang(string $lang = null) { if (is_null($lang)) { return $this->lang; @@ -29,7 +30,7 @@ public function lang(string $lang = null): string $this->lang = $lang; - return $this->lang; + return $this; } /** @@ -42,11 +43,11 @@ public function file(File $file = null) } if ($file->size() > self::MAX_SIZE) { - throw new InvalidArgumentException($file->path().' is too large.'); + throw new InvalidArgumentException($file->path().' is too large. Max size: '.self::MAX_SIZE); } if ($file->type() !== 'text/vtt') { - throw new InvalidArgumentException($file->path().' is not a text vtt.'); + throw new InvalidArgumentException($file->path().' is not a text/vtt file.'); } $this->file = $file; @@ -64,4 +65,9 @@ public function jsonSerialize(): array 'file' => $this->file()->blob(), ]; } + + public function __toString(): string + { + return json_encode($this); + } } diff --git a/src/Lexicons/App/Bsky/Embed/File.php b/src/Lexicons/App/Bsky/Embed/File.php index 61d1b93..6c4baf0 100644 --- a/src/Lexicons/App/Bsky/Embed/File.php +++ b/src/Lexicons/App/Bsky/Embed/File.php @@ -4,7 +4,7 @@ use Atproto\Contracts\Stringable; -class File +class File implements Stringable { use BlobHandler; diff --git a/src/Lexicons/App/Bsky/Embed/Image.php b/src/Lexicons/App/Bsky/Embed/Image.php index 7036263..80d9d95 100644 --- a/src/Lexicons/App/Bsky/Embed/Image.php +++ b/src/Lexicons/App/Bsky/Embed/Image.php @@ -45,7 +45,7 @@ public function aspectRatio(int $width = null, int $height = null) } if ($width < 1 || $height < 1) { - throw new InvalidArgumentException("Width and height must be at least 1"); + throw new InvalidArgumentException("'\$width' and '\$height' must be greater than 0"); } $this->aspectRatio = [ @@ -67,4 +67,9 @@ public function jsonSerialize(): array 'aspectRatio' => $this->aspectRatio() ?: null, ]); } + + public function __toString(): string + { + return json_encode($this); + } } diff --git a/src/Support/File.php b/src/Support/File.php index ebe0415..641b6d7 100644 --- a/src/Support/File.php +++ b/src/Support/File.php @@ -10,7 +10,7 @@ class File { /** @var string $file_path The path to the file. */ - private $file_path; + private string $file_path; /** * Constructor. diff --git a/tests/Lexicons/App/Bsky/Embed/CaptionTest.php b/tests/Lexicons/App/Bsky/Embed/CaptionTest.php new file mode 100644 index 0000000..54c83ac --- /dev/null +++ b/tests/Lexicons/App/Bsky/Embed/CaptionTest.php @@ -0,0 +1,110 @@ + 'lang']; + + /** + * @throws InvalidArgumentException + */ + protected function setUp(): void + { + $file = $this->createMockFile(); + + $this->dependencies['file'] = $file; + + $this->caption = $this->createCaption(); + } + + /** + * @return Caption + * @throws InvalidArgumentException + */ + private function createCaption(): Caption + { + return new Caption(...array_values($this->dependencies)); + } + + /** + * @throws InvalidArgumentException + */ + public function testFile() + { + $actual = $this->caption->file(); + $this->assertSame($this->dependencies['file'], $actual); + } + + /** + * @throws InvalidArgumentException + */ + public function testSetFile(): void + { + $expected = clone $this->dependencies['file']; + + $this->assertFalse(spl_object_hash($expected) === spl_object_hash($this->caption->file())); + + $this->caption->file($expected); + $actual = $this->caption->file(); + + $this->assertSame($expected, $actual); + } + + public function testLang() + { + $actual = $this->caption->lang(); + $expected = $this->dependencies['lang']; + + $this->assertSame($expected, $actual); + } + + public function testSetLang(): void + { + $expected = "language"; + $this->caption->lang($expected); + $actual = $this->caption->lang(); + + $this->assertSame($expected, $actual); + } + + public function testJsonSerialize() + { + $expected = [ + 'file' => $this->blob, + 'lang' => $this->dependencies['lang'], + ]; + + $actual = json_decode($this->caption, true); + + $this->assertFalse(is_bool($actual)); + $this->assertEquals($expected, $actual); + } + + public function test__constructThrowsInvalidArgumentWhenPassedInvalidFileType(): void + { + $this->type = 'image/png'; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($this->dependencies['file']->path()." is not a text/vtt file."); + + $this->createCaption(); + } + + public function test__constructorThrowsExceptionWhePassedUnacceptableSizedFile(): void + { + $this->size = 20001; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($this->dependencies['file']->path()." is too large. Max size: 20000"); + + $this->createCaption(); + } +} diff --git a/tests/Lexicons/App/Bsky/Embed/FileMocking.php b/tests/Lexicons/App/Bsky/Embed/FileMocking.php new file mode 100644 index 0000000..7a08d6f --- /dev/null +++ b/tests/Lexicons/App/Bsky/Embed/FileMocking.php @@ -0,0 +1,42 @@ +getMockBuilder(File::class) + ->disableOriginalConstructor() + ->getMock(); + + $file->expects($this->any()) + ->method('path') + ->will($this->returnCallback(fn() => $this->path)); + + $file->expects($this->any()) + ->method('type') + ->will($this->returnCallback(fn() => $this->type)); + + $file->expects($this->any()) + ->method('size') + ->will($this->returnCallback(fn() => $this->size)); + + $file->expects($this->any()) + ->method('blob') + ->will($this->returnCallback(fn() => $this->blob)); + + return $file; + } +} \ No newline at end of file diff --git a/tests/Lexicons/App/Bsky/Embed/FileTest.php b/tests/Lexicons/App/Bsky/Embed/FileTest.php new file mode 100644 index 0000000..c8960ba --- /dev/null +++ b/tests/Lexicons/App/Bsky/Embed/FileTest.php @@ -0,0 +1,98 @@ +testFilePath = tempnam(sys_get_temp_dir(), 'testfile'); + file_put_contents($this->testFilePath, 'This is a test file.'); + $this->fileInstance = new File($this->testFilePath); + + $this->unreadableFilePath = tempnam(sys_get_temp_dir(), 'unreadable'); + file_put_contents($this->unreadableFilePath, 'This is an unreadable file.'); + chmod($this->unreadableFilePath, 0000); + + $this->nonFilePath = sys_get_temp_dir() . '/nonFile' . uniqid(); + mkdir($this->nonFilePath, 0777, true); + } + + protected function tearDown(): void + { + parent::tearDown(); + + if (file_exists($this->testFilePath)) { + unlink($this->testFilePath); + } + + if (file_exists($this->unreadableFilePath)) { + chmod($this->unreadableFilePath, 0644); + unlink($this->unreadableFilePath); + } + + if (is_dir($this->nonFilePath)) { + rmdir($this->nonFilePath); + } + } + + public function testFileSize(): void + { + $expectedSize = filesize($this->testFilePath); + $this->assertEquals($expectedSize, $this->fileInstance->size()); + } + + public function testMimeType(): void + { + $expectedType = mime_content_type($this->testFilePath); + $this->assertEquals($expectedType, $this->fileInstance->type()); + } + + public function testFileBlob(): void + { + $expectedContent = file_get_contents($this->testFilePath); + $this->assertEquals($expectedContent, $this->fileInstance->blob()); + } + + public function testToStringMethod(): void + { + $expectedContent = file_get_contents($this->testFilePath); + $this->assertEquals($expectedContent, (string) $this->fileInstance); + } + + public function testConstructorThrowsExceptionWhenPassedUnreadableFilePath(): void + { + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('Test skipped because it is running as root.'); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("$this->unreadableFilePath is not readable."); + + $this->assertFalse(is_readable($this->unreadableFilePath), 'File should not be readable.'); + + new File($this->unreadableFilePath); + } + + public function testConstructorThrowsExceptionWhenPassedNonFilePath(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("$this->nonFilePath is not a file."); + + new File($this->nonFilePath); + } +} diff --git a/tests/Lexicons/App/Bsky/Embed/ImageTest.php b/tests/Lexicons/App/Bsky/Embed/ImageTest.php new file mode 100644 index 0000000..87be7d4 --- /dev/null +++ b/tests/Lexicons/App/Bsky/Embed/ImageTest.php @@ -0,0 +1,143 @@ + 'alt']; + + /** + * @return array + */ + public function randAspectRatio(): array + { + return ['width' => rand(1, 50), 'height' => rand(1, 50)]; + } + + /** + * @return Image + * @throws InvalidArgumentException + */ + public function createImage(): Image + { + return new Image(...array_values($this->dependencies)); + } + + /** + * @throws InvalidArgumentException + */ + protected function setUp(): void + { + $this->type = 'image/png'; + + $this->dependencies = array_merge([ + 'file' => $this->createMockFile() + ], $this->dependencies); + + $this->image = $this->createImage(); + } + + /** + * @throws ReflectionException + */ + public function test__construct() + { + $this->assertSame($this->dependencies['file'], $this->getPropertyValue('file', $this->image)); + $this->assertSame($this->dependencies['alt'], $this->image->alt()); + } + + /** + * @throws InvalidArgumentException + */ + public function testAspectRatio() + { + $this->assertNull($this->image->aspectRatio()); + + $expected = $this->randAspectRatio(); + + $this->image->aspectRatio(...array_values($expected)); + + $this->assertSame($expected, $this->image->aspectRatio()); + } + + /** @dataProvider aspectRatioInvalidArguments */ + public function testAspectRatioThrowsInvalidArgumentException(...$arguments): void + { + $this->expectException(InvalidArgumentException::class); + + $this->image->aspectRatio(...$arguments); + } + + /** + * @return array + */ + public function aspectRatioInvalidArguments(): array + { + return [ + [0, 12], + [null, 0], + [12, null], + [0, 0], + [0, 54], + [76, 0], + [1] + ]; + } + + public function testAlt() + { + $expected = $this->dependencies['alt']; + + $this->assertSame($expected, $this->image->alt()); + + $new = uniqid(); + $this->image->alt($new); + + $this->assertSame($new, $this->image->alt()); + } + + /** + * @throws InvalidArgumentException + */ + public function testJsonSerialize() + { + $expected = [ + 'alt' => $this->dependencies['alt'], + 'image' => $this->dependencies['file']->blob(), + ]; + + $image = $this->createImage(); + + $this->assertSame($expected, json_decode($image, true)); + + $aspectRatio = $this->randAspectRatio(); + + $expected['aspectRatio'] = $aspectRatio; + + $image->aspectRatio(...array_values($aspectRatio)); + + $this->assertSame($expected, json_decode($image, true)); + } + + /** + * @throws InvalidArgumentException + */ + public function testMethodChaining() + { + $result = $this->image + ->alt('new alt') + ->aspectRatio(16, 9); + + $this->assertSame($this->image, $result); + } +} From 7c50c29f4bc5708a6878e557aa066fd50e77dcb9 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Thu, 10 Oct 2024 23:26:10 +0400 Subject: [PATCH 09/59] WIP: PostBuilder Implementation. Add video test and move tests to correct directory - Add new test for the Video class related to embeds - Update namespaces of lexicon unit test classes related to embeds to fix being under the wrong namespace --- src/Lexicons/App/Bsky/Embed/Video.php | 13 +- .../Lexicons/App/Bsky/Embed/CaptionTest.php | 2 +- .../Lexicons/App/Bsky/Embed/FileMocking.php | 2 +- .../Lexicons/App/Bsky/Embed/FileTest.php | 2 +- .../Lexicons/App/Bsky/Embed/ImageTest.php | 2 +- .../Lexicons/App/Bsky/Embed/VideoTest.php | 123 ++++++++++++++++++ 6 files changed, 138 insertions(+), 6 deletions(-) rename tests/{ => Unit}/Lexicons/App/Bsky/Embed/CaptionTest.php (98%) rename tests/{ => Unit}/Lexicons/App/Bsky/Embed/FileMocking.php (95%) rename tests/{ => Unit}/Lexicons/App/Bsky/Embed/FileTest.php (98%) rename tests/{ => Unit}/Lexicons/App/Bsky/Embed/ImageTest.php (98%) create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php index f4de269..9d3b905 100644 --- a/src/Lexicons/App/Bsky/Embed/Video.php +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -3,10 +3,11 @@ namespace Atproto\Lexicons\App\Bsky\Embed; use Atproto\Contracts\Lexicons\App\Bsky\Embed\VideoInterface; +use Atproto\Contracts\Stringable; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; -class Video implements VideoInterface +class Video implements VideoInterface, Stringable { private File $file; private ?string $alt = null; @@ -32,12 +33,14 @@ public function __construct(File $file) */ public function jsonSerialize(): array { - return array_filter([ + $result = array_filter([ 'alt' => $this->alt() ?: null, 'video' => $this->file->blob(), 'aspectRatio' => $this->aspectRatio() ?: null, 'captions' => $this->captions()->toArray() ?: null, ]); + + return $result; } public function alt(string $alt = null) @@ -82,4 +85,10 @@ public function captions(CaptionCollection $captions = null) return $this; } + + public function __toString(): string + { + $result = json_encode($this); + return $result; + } } diff --git a/tests/Lexicons/App/Bsky/Embed/CaptionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/CaptionTest.php similarity index 98% rename from tests/Lexicons/App/Bsky/Embed/CaptionTest.php rename to tests/Unit/Lexicons/App/Bsky/Embed/CaptionTest.php index 54c83ac..303adf9 100644 --- a/tests/Lexicons/App/Bsky/Embed/CaptionTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/CaptionTest.php @@ -1,6 +1,6 @@ type = 'video/mp4'; + + $this->file = $this->createMockFile(); + $this->video = new Video($this->file); + } + + public function testAlt(): void + { + $this->assertSame(null, $this->video->alt()); + + $expected = 'alt text'; + $this->video->alt($expected); + + $this->assertSame($expected, $this->video->alt()); + } + + /** @dataProvider invalidAspectRatioProvider */ + public function testAspectRatioThrowsInvalidArgumentExceptionWhenPassedInvalidArguments(...$args): void + { + $this->expectException(InvalidArgumentException::class); + + $this->video->aspectRatio(...array_values($args)); + } + + public function invalidAspectRatioProvider(): array + { + return [ + [-1, 0], + [0, -1], + [false, 1], + [true], + [1] + ]; + } + + public function testAspectRatio(): void + { + $this->assertSame([], $this->video->aspectRatio()); + + $expected = ['width' => 1, 'height' => 2]; + $this->video->aspectRatio(...array_values($expected)); + + $this->assertSame($expected, $this->video->aspectRatio()); + } + + public function testJsonSerializeReturnsCorrectSchema(): void + { + $this->assertSame($this->file->blob(), $this->blob); + + $expected = [ + 'video' => $this->file->blob(), + ]; + + $target = new Video($this->file); + + $this->assertSame($expected, json_decode($target, true)); + + $captions = $this->createCaptionsMock(); + $aspectRatio = $this->randAspectRatio(); + + $expected = [ + 'alt' => 'alt text', + 'video' => $expected['video'], + 'aspectRatio' => $aspectRatio, + 'captions' => $captions->toArray(), + ]; + + $target->captions($captions) + ->alt($expected['alt']) + ->aspectRatio(...array_values($expected['aspectRatio'])); + + $this->assertSame($expected, json_decode($target, true)); + } + + /** + * @return array + */ + private function randAspectRatio(): array + { + return ['width' => rand(1, 50), 'height' => rand(1, 50)]; + } + + /** + * @return (MockObject&CaptionCollection) + */ + private function createCaptionsMock(): CaptionCollection + { + $captions = $this->getMockBuilder(CaptionCollection::class) + ->disableOriginalConstructor() + ->onlyMethods(['toArray']) + ->getMock(); + + $captions->expects($this->any()) + ->method('toArray') + ->willReturn([ + ['lang' => 'lang', 'file' => $this->blob] + ]); + + return $captions; + } +} From 35e9abff4b9d12e3297b771e9ae8683d60a8175c Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Fri, 11 Oct 2024 00:07:52 +0400 Subject: [PATCH 10/59] Implement CaptionInterface for proper JSON serialization - Add CaptionInterface extending JsonSerializable and Stringable - Update Caption class to implement CaptionInterface - Modify CaptionCollection to use CaptionInterface - Adjust VideoTest to work with new interface structure Why: This change ensures proper serialization of Caption objects when json_encode is applied. By extending JsonSerializable and Stringable interfaces, CaptionInterface guarantees that Caption objects are converted to arrays during JSON serialization. --- .../App/Bsky/Embed/CaptionInterface.php | 13 +++++++++++++ src/Lexicons/App/Bsky/Embed/Caption.php | 4 ++-- .../Embed/Collections/CaptionCollection.php | 4 ++-- .../Unit/Lexicons/App/Bsky/Embed/VideoTest.php | 17 ++++++++++++++--- 4 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php new file mode 100644 index 0000000..fcd6144 --- /dev/null +++ b/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php @@ -0,0 +1,13 @@ + $item instanceof Caption, $collection); + parent::__construct(fn ($item) => $item instanceof CaptionInterface, $collection); } public function validate($value): bool diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php index 528f032..94a0306 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php @@ -2,7 +2,9 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionInterface; use Atproto\Exceptions\InvalidArgumentException; +use Atproto\Lexicons\App\Bsky\Embed\Caption; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; use Atproto\Lexicons\App\Bsky\Embed\File; use Atproto\Lexicons\App\Bsky\Embed\Video; @@ -107,6 +109,17 @@ private function randAspectRatio(): array */ private function createCaptionsMock(): CaptionCollection { + $caption = $this->getMockBuilder(CaptionInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $caption->expects($this->any()) + ->method('jsonSerialize') + ->willReturn([ + 'lang' => 'lang', + 'file' => $this->file->blob(), + ]); + $captions = $this->getMockBuilder(CaptionCollection::class) ->disableOriginalConstructor() ->onlyMethods(['toArray']) @@ -114,9 +127,7 @@ private function createCaptionsMock(): CaptionCollection $captions->expects($this->any()) ->method('toArray') - ->willReturn([ - ['lang' => 'lang', 'file' => $this->blob] - ]); + ->willReturn($caption->jsonSerialize()); return $captions; } From 641b63251694ebb04d79656109eaa5e7a74d3600 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 02:28:16 +0400 Subject: [PATCH 11/59] Add size() method to Image class and interface --- src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php | 1 + src/Lexicons/App/Bsky/Embed/Image.php | 5 +++++ tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php index 73d0a22..2935cfc 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php @@ -10,4 +10,5 @@ interface ImageInterface extends JsonSerializable, Stringable public function jsonSerialize(): array; public function alt(string $alt = null); public function aspectRatio(); + public function size(): int; } diff --git a/src/Lexicons/App/Bsky/Embed/Image.php b/src/Lexicons/App/Bsky/Embed/Image.php index 80d9d95..0846f1d 100644 --- a/src/Lexicons/App/Bsky/Embed/Image.php +++ b/src/Lexicons/App/Bsky/Embed/Image.php @@ -68,6 +68,11 @@ public function jsonSerialize(): array ]); } + public function size(): int + { + return $this->file->size(); + } + public function __toString(): string { return json_encode($this); diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php index 2b8e9a5..6f2a78c 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php @@ -140,4 +140,9 @@ public function testMethodChaining() $this->assertSame($this->image, $result); } + + public function testSize(): void + { + $this->assertSame($this->size, $this->image->size()); + } } From 14076f65504b66d2db0e4143c878976ab23fb626 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 02:31:27 +0400 Subject: [PATCH 12/59] Refactor collections by introducing `EmbedCollection` trait for code reuse. - Updated `CaptionCollection` and `ImageCollection` to use `EmbedCollection` for handling validation logic and ensuring consistent error handling. - Simplified the validation process by using closures in the `validator` method to ensure objects implement their respective interfaces. - Introduced custom exception handling for type errors to provide more meaningful feedback. - Ensured that collections adhere to size limits, with clear error messages for exceeding maximum size or incorrect object types. --- .../Embed/Collections/CaptionCollection.php | 35 +++------ .../Embed/Collections/EmbedCollection.php | 45 +++++++++++ .../Embed/Collections/ImageCollection.php | 52 ++++--------- .../Collections/CaptionCollectionTest.php | 16 ++++ .../Embed/Collections/EmbedCollectionTest.php | 78 +++++++++++++++++++ .../Embed/Collections/ImageCollectionTest.php | 17 ++++ 6 files changed, 178 insertions(+), 65 deletions(-) create mode 100644 src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/Collections/EmbedCollectionTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php diff --git a/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php index e8b5f34..6c22ad9 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php @@ -3,40 +3,23 @@ namespace Atproto\Lexicons\App\Bsky\Embed\Collections; use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionInterface; -use GenericCollection\Exceptions\InvalidArgumentException; +use Atproto\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; use JsonSerializable; class CaptionCollection extends GenericCollection implements JsonSerializable { + use EmbedCollection; private const MAX_SIZE = 20; - public function __construct(iterable $collection = []) + protected function validator(): \Closure { - parent::__construct(fn ($item) => $item instanceof CaptionInterface, $collection); - } - - public function validate($value): bool - { - return parent::validate($value) && $this->validateLength(); - } - - private function validateLength(): bool - { - return $this->count() <= self::MAX_SIZE; - } - - public function validateWithException($value): void - { - parent::validateWithException($value); + return function (CaptionInterface $caption) { + if ($this->count() > self::MAX_SIZE) { + throw new InvalidArgumentException(self::class.' collection exceeds maximum size: ' .self::MAX_SIZE); + } - if (! $this->validateLength()) { - throw new InvalidArgumentException("Caption length must be less than or equal " . self::MAX_SIZE); - } - } - - public function jsonSerialize() - { - return $this->toArray(); + return true; + }; } } diff --git a/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php new file mode 100644 index 0000000..8eb17d7 --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php @@ -0,0 +1,45 @@ +collection = $collection; + + try { + parent::__construct($this->validator(), $collection); + } catch (\TypeError|\GenericCollection\Exceptions\InvalidArgumentException $e) { + $this->throw($e); + } + } + + /** + * @throws InvalidArgumentException + */ + private function throw($exception): void + { + throw new InvalidArgumentException( + str_replace( + "::".__NAMESPACE__."\{closure}()", + "", + $exception->getMessage() + ), + 0, + $exception + ); + } + + abstract protected function validator(): \Closure; + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php index 6f3b3aa..9996bc6 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php @@ -9,49 +9,23 @@ class ImageCollection extends GenericCollection implements JsonSerializable { - private const MAX_ITEM = 4; - private const MAX_SIZE = 1000000; - - public function __construct(iterable $collection = []) - { - parent::__construct(fn ($item) => $item instanceof ImageInterface, $collection); - } - - private function validateLength(): bool - { - return ($this->count() <= self::MAX_ITEM); - } - - private function validateSize(ImageInterface $image): bool - { - return $image->size() <= self::MAX_SIZE; - } + use EmbedCollection; - public function validate($value): bool - { - return parent::validate($value) - && $this->validateSize($value) - && $this->validateLength(); - } + private const MAX_LENGTH = 4; + private const MAX_SIZE = 1000000; - /** - * @throws InvalidArgumentException - */ - public function validateWithException($value): void + protected function validator(): \Closure { - parent::validateWithException($value); - - if (! $this->validateLength()) { - throw new InvalidArgumentException("Image limit exceeded. Maximum allowed images: ".self::MAX_ITEM); - } + return function (ImageInterface $image) { + if ($this->count() > self::MAX_LENGTH) { + throw new InvalidArgumentException(self::class.' collection exceeds maximum size: ' .self::MAX_LENGTH); + } - if (! $this->validateSize($value)) { - throw new InvalidArgumentException("Image size exceeded. Maximum allowed size: ".self::MAX_SIZE); - } - } + if ($image->size() > self::MAX_SIZE) { + throw new InvalidArgumentException(self::class.' collection only accepts images with size less than '. self::MAX_LENGTH); + } - public function jsonSerialize(): array - { - return $this->toArray(); + return true; + }; } } diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php new file mode 100644 index 0000000..fbd248f --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php @@ -0,0 +1,16 @@ +getMockBuilder($this->dependency) + ->disableOriginalConstructor() + ->getMock(); + + $item->expects($this->any()) + ->method('jsonSerialize') + ->willReturn(['foo' => 'bar']); + + $items[] = $item; + } + + return $items; + } + + /** + * @throws InvalidArgumentException + */ + public function test__constructThatFillsCorrectlyCollection(): void + { + $expected = $this->items(); + $collection = new $this->target($expected); + + $this->assertSame($expected, $collection->toArray()); + } + + /** + * @throws InvalidArgumentException + */ + public function testValidateThrowsExceptionWhenPassedInvalidArgument(): void + { + $given = $this->createMock(Video::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + "must implement interface %s, instance of %s given", + $this->dependency, + get_class($given) + )); + + new $this->target([$given]); + } + + public function testValidateThrowsExceptionWhenLimitExceed(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("collection exceeds maximum size"); + + new $this->target($this->items(++$this->maxLength)); + } + + /** + * @throws InvalidArgumentException + */ + public function testJsonSerialize() + { + $items = new $this->target($this->items(2)); + + $expected = json_encode(array_map(fn () => ['foo' => 'bar'], $items->toArray())); + $actual = json_encode($items); + + $this->assertSame($expected, $actual); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php new file mode 100644 index 0000000..efb6411 --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php @@ -0,0 +1,17 @@ + Date: Sat, 12 Oct 2024 02:39:10 +0400 Subject: [PATCH 13/59] Fix typo in exception message for class and add unit test for size validation. --- .../Bsky/Embed/Collections/ImageCollection.php | 2 +- .../Embed/Collections/ImageCollectionTest.php | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php index 9996bc6..5ddea2f 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php @@ -22,7 +22,7 @@ protected function validator(): \Closure } if ($image->size() > self::MAX_SIZE) { - throw new InvalidArgumentException(self::class.' collection only accepts images with size less than '. self::MAX_LENGTH); + throw new InvalidArgumentException(self::class.' collection only accepts images with size less than '. self::MAX_SIZE); } return true; diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php index efb6411..548e198 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed\Collections; use Atproto\Contracts\Lexicons\App\Bsky\Embed\ImageInterface; +use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\ImageCollection; use PHPUnit\Framework\TestCase; @@ -14,4 +15,20 @@ class ImageCollectionTest extends TestCase private string $dependency = ImageInterface::class; private int $maxLength = 4; private int $maxSizeOfItem = 1000000; + + public function testValidateThrowsExceptionWherePassedThatSizeGreaterThanLimit(): void + { + $dependency = $this->getMockBuilder($this->dependency) + ->disableOriginalConstructor() + ->getMock(); + + $dependency->expects($this->once()) + ->method('size') + ->willReturn(++$this->maxSizeOfItem); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("collection only accepts images with size less than"); + + new ImageCollection([$dependency]); + } } From af507a8dc52cbe42315f803d7bcf1beb5cd31af8 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 20:59:49 +0400 Subject: [PATCH 14/59] Add External embed class and corresponding test file - Create src/Lexicons/App/Bsky/Embed/External.php for External embed implementation - Add tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php for unit tests On branch embeds Changes to be committed: renamed: src/Lexicons/App/Bsky/Embed/File.php -> src/Lexicons/App/Bsky/Embed/Blob.php --- .../App/Bsky/Embed/CaptionInterface.php | 4 +- .../App/Bsky/Embed/{File.php => Blob.php} | 2 +- src/Lexicons/App/Bsky/Embed/Caption.php | 6 +- src/Lexicons/App/Bsky/Embed/External.php | 90 +++++++++++++++ src/Lexicons/App/Bsky/Embed/Image.php | 4 +- src/Lexicons/App/Bsky/Embed/Video.php | 4 +- .../Lexicons/App/Bsky/Embed/ExternalTest.php | 104 ++++++++++++++++++ .../Lexicons/App/Bsky/Embed/FileMocking.php | 6 +- .../Unit/Lexicons/App/Bsky/Embed/FileTest.php | 10 +- .../Lexicons/App/Bsky/Embed/VideoTest.php | 4 +- 10 files changed, 214 insertions(+), 20 deletions(-) rename src/Lexicons/App/Bsky/Embed/{File.php => Blob.php} (94%) create mode 100644 src/Lexicons/App/Bsky/Embed/External.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php index fcd6144..ed45198 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php @@ -3,11 +3,11 @@ namespace Atproto\Contracts\Lexicons\App\Bsky\Embed; use Atproto\Contracts\Stringable; -use Atproto\Lexicons\App\Bsky\Embed\File; +use Atproto\Lexicons\App\Bsky\Embed\Blob; use JsonSerializable; interface CaptionInterface extends JsonSerializable, Stringable { public function lang(string $lang = null); - public function file(File $file = null); + public function file(Blob $file = null); } diff --git a/src/Lexicons/App/Bsky/Embed/File.php b/src/Lexicons/App/Bsky/Embed/Blob.php similarity index 94% rename from src/Lexicons/App/Bsky/Embed/File.php rename to src/Lexicons/App/Bsky/Embed/Blob.php index 6c4baf0..d6c4421 100644 --- a/src/Lexicons/App/Bsky/Embed/File.php +++ b/src/Lexicons/App/Bsky/Embed/Blob.php @@ -4,7 +4,7 @@ use Atproto\Contracts\Stringable; -class File implements Stringable +class Blob implements Stringable { use BlobHandler; diff --git a/src/Lexicons/App/Bsky/Embed/Caption.php b/src/Lexicons/App/Bsky/Embed/Caption.php index c7c548b..816ab91 100644 --- a/src/Lexicons/App/Bsky/Embed/Caption.php +++ b/src/Lexicons/App/Bsky/Embed/Caption.php @@ -11,12 +11,12 @@ class Caption implements CaptionInterface private const MAX_SIZE = 20000; private string $lang; - private File $file; + private Blob $file; /** * @throws InvalidArgumentException */ - public function __construct(string $lang, File $file) + public function __construct(string $lang, Blob $file) { $this->lang($lang); $this->file($file); @@ -36,7 +36,7 @@ public function lang(string $lang = null) /** * @throws InvalidArgumentException */ - public function file(File $file = null) + public function file(Blob $file = null) { if (is_null($file)) { return $this->file; diff --git a/src/Lexicons/App/Bsky/Embed/External.php b/src/Lexicons/App/Bsky/Embed/External.php new file mode 100644 index 0000000..20e37be --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/External.php @@ -0,0 +1,90 @@ +uri($uri) + ->title($title) + ->description($description); + } + + /** + * @throws InvalidArgumentException + */ + public function uri(string $uri = null) + { + if (is_null($uri)) { + return $this->uri; + } + + if (! filter_var($uri, FILTER_VALIDATE_URL)) { + throw new InvalidArgumentException("'$uri' is not a valid URL"); + } + + $this->uri = $uri; + + return $this; + } + + public function title(string $title = null) + { + if (is_null($title)) { + return $this->title; + } + + $this->title = $title; + + return $this; + } + + public function description(string $description = null) + { + if (is_null($description)) { + return $this->description; + } + + $this->description = $description; + + return $this; + } + + /** + * @throws InvalidArgumentException + */ + public function thumb(Blob $blob = null) + { + if (is_null($blob)) { + return $this->blob; + } + + if (! str_starts_with($blob->type(), 'image/*')) { + throw new InvalidArgumentException(sprintf( + "'%s' is not a valid image type: %s", + $blob->path(), + $blob->type() + )); + } + + if (1000000 < $blob->size()) { + throw new InvalidArgumentException(sprintf( + "'%s' size is too big than maximum allowed: %d", + $blob->path(), + $blob->size() + )); + } + + $this->blob = $blob; + + return $this; + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Image.php b/src/Lexicons/App/Bsky/Embed/Image.php index 0846f1d..894ba2c 100644 --- a/src/Lexicons/App/Bsky/Embed/Image.php +++ b/src/Lexicons/App/Bsky/Embed/Image.php @@ -7,14 +7,14 @@ class Image implements ImageInterface { - private File $file; + private Blob $file; private string $alt; private ?array $aspectRatio = null; /** * @throws InvalidArgumentException */ - public function __construct(File $file, string $alt) + public function __construct(Blob $file, string $alt) { if (true !== str_starts_with($file->type(), 'image/')) { throw new InvalidArgumentException($file->path()." is not a valid image file."); diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php index 9d3b905..10cff40 100644 --- a/src/Lexicons/App/Bsky/Embed/Video.php +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -9,7 +9,7 @@ class Video implements VideoInterface, Stringable { - private File $file; + private Blob $file; private ?string $alt = null; private CaptionCollection $captions; private array $aspectRatio = []; @@ -17,7 +17,7 @@ class Video implements VideoInterface, Stringable /** * @throws InvalidArgumentException */ - public function __construct(File $file) + public function __construct(Blob $file) { if ("video/mp4" !== $file->type()) { throw new InvalidArgumentException($file->path()." is not a valid video file."); diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php new file mode 100644 index 0000000..f7e8877 --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php @@ -0,0 +1,104 @@ +external = new External('https://shahmal1yev.dev', 'foo', 'bar'); + $this->blob = $this->getMockBuilder(Blob::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->blob->expects($this->any()) + ->method('size') + ->will($this->returnCallback(fn () => $this->maximumAllowedBlobSize)); + + $this->blob->expects($this->any()) + ->method('type') + ->will($this->returnCallback(fn () => $this->allowedMimes)); + } + + public function testDescription() + { + $this->assertSame('bar', $this->external->description()); + $this->external->description('foo'); + $this->assertSame('foo', $this->external->description()); + } + + /** + * @throws InvalidArgumentException + */ + public function testThumb() + { + $this->external->thumb($this->blob); + $this->assertSame($this->blob, $this->external->thumb()); + } + + /** + * @throws InvalidArgumentException + */ + public function testThumbReturnsNull(): void + { + $this->assertNull($this->external->thumb()); + } + + public function testThumbThrowsExceptionWhenPassedBlobWithInvalidMimeType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is not a valid image type: invalid/*'); + + $this->allowedMimes = 'invalid/*'; + + $this->external->thumb($this->blob); + } + + public function testThumbThrowsExceptionWhenPassedBlobExceedsMaximumAllowedSize(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('size is too big than maximum allowed:'); + + $this->maximumAllowedBlobSize = ++$this->maximumAllowedBlobSize; + + $this->external->thumb($this->blob); + } + + /** + * @throws InvalidArgumentException + */ + public function testUri() + { + $this->assertSame('https://shahmal1yev.dev', $this->external->uri()); + $this->external->uri('https://google.com'); + $this->assertSame('https://google.com', $this->external->uri()); + } + + public function testUriThrowsExceptionWhenPassedInvalidUrl(): void + { + $trigger = 'invalid url'; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("'$trigger' is not a valid URL"); + + $this->external->uri($trigger); + } + + public function testTitle() + { + $this->assertSame('foo', $this->external->title()); + $this->external->title('bar'); + $this->assertSame('bar', $this->external->title()); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php b/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php index bb5f3ab..9e43b22 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php @@ -2,7 +2,7 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed; -use Atproto\Lexicons\App\Bsky\Embed\File; +use Atproto\Lexicons\App\Bsky\Embed\Blob; use PHPUnit\Framework\MockObject\MockObject; trait FileMocking @@ -13,11 +13,11 @@ trait FileMocking private string $type = 'text/vtt'; /** - * @return (File&MockObject) + * @return (Blob&MockObject) */ private function createMockFile() { - $file = $this->getMockBuilder(File::class) + $file = $this->getMockBuilder(Blob::class) ->disableOriginalConstructor() ->getMock(); diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/FileTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/FileTest.php index 454762d..b95b503 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/FileTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/FileTest.php @@ -3,7 +3,7 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed; use Atproto\Exceptions\InvalidArgumentException; -use Atproto\Lexicons\App\Bsky\Embed\File; +use Atproto\Lexicons\App\Bsky\Embed\Blob; use PHPUnit\Framework\TestCase; class FileTest extends TestCase @@ -11,7 +11,7 @@ class FileTest extends TestCase private string $testFilePath; private string $unreadableFilePath; private string $nonFilePath; - private File $fileInstance; + private Blob $fileInstance; /** * @throws InvalidArgumentException @@ -22,7 +22,7 @@ protected function setUp(): void $this->testFilePath = tempnam(sys_get_temp_dir(), 'testfile'); file_put_contents($this->testFilePath, 'This is a test file.'); - $this->fileInstance = new File($this->testFilePath); + $this->fileInstance = new Blob($this->testFilePath); $this->unreadableFilePath = tempnam(sys_get_temp_dir(), 'unreadable'); file_put_contents($this->unreadableFilePath, 'This is an unreadable file.'); @@ -85,7 +85,7 @@ public function testConstructorThrowsExceptionWhenPassedUnreadableFilePath(): vo $this->assertFalse(is_readable($this->unreadableFilePath), 'File should not be readable.'); - new File($this->unreadableFilePath); + new Blob($this->unreadableFilePath); } public function testConstructorThrowsExceptionWhenPassedNonFilePath(): void @@ -93,6 +93,6 @@ public function testConstructorThrowsExceptionWhenPassedNonFilePath(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage("$this->nonFilePath is not a file."); - new File($this->nonFilePath); + new Blob($this->nonFilePath); } } diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php index 94a0306..e69051c 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php @@ -6,7 +6,7 @@ use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Caption; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; -use Atproto\Lexicons\App\Bsky\Embed\File; +use Atproto\Lexicons\App\Bsky\Embed\Blob; use Atproto\Lexicons\App\Bsky\Embed\Video; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -16,7 +16,7 @@ class VideoTest extends TestCase use FileMocking; private Video $video; - private File $file; + private Blob $file; protected function setUp(): void { From 871a9670d4af70cd8f3307e6d7dc4608e815a510 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 21:16:16 +0400 Subject: [PATCH 15/59] Implement JsonSerializable and Stringable for External class --- src/Lexicons/App/Bsky/Embed/External.php | 19 ++++++++++- .../Lexicons/App/Bsky/Embed/ExternalTest.php | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/Lexicons/App/Bsky/Embed/External.php b/src/Lexicons/App/Bsky/Embed/External.php index 20e37be..6d9363c 100644 --- a/src/Lexicons/App/Bsky/Embed/External.php +++ b/src/Lexicons/App/Bsky/Embed/External.php @@ -2,9 +2,11 @@ namespace Atproto\Lexicons\App\Bsky\Embed; +use Atproto\Contracts\Stringable; use Atproto\Exceptions\InvalidArgumentException; +use JsonSerializable; -class External +class External implements JsonSerializable, Stringable { private string $uri; private string $title; @@ -87,4 +89,19 @@ public function thumb(Blob $blob = null) return $this; } + + public function jsonSerialize(): array + { + return array_filter([ + 'uri' => $this->uri, + 'title' => $this->title, + 'description' => $this->description, + 'blob' => ($b = $this->blob) ? $b->blob() : null, + ]); + } + + public function __toString(): string + { + return json_encode($this); + } } diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php index f7e8877..0939e22 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php @@ -18,6 +18,7 @@ class ExternalTest extends TestCase public function setUp(): void { $this->external = new External('https://shahmal1yev.dev', 'foo', 'bar'); + $this->blob = $this->getMockBuilder(Blob::class) ->disableOriginalConstructor() ->getMock(); @@ -29,6 +30,10 @@ public function setUp(): void $this->blob->expects($this->any()) ->method('type') ->will($this->returnCallback(fn () => $this->allowedMimes)); + + $this->blob->expects($this->any()) + ->method('blob') + ->willReturn('blob'); } public function testDescription() @@ -101,4 +106,32 @@ public function testTitle() $this->external->title('bar'); $this->assertSame('bar', $this->external->title()); } + + public function testJsonSerializeWithoutSetBlob(): void + { + $expected = [ + 'uri' => 'https://shahmal1yev.dev', + 'title' => 'foo', + 'description' => 'bar', + ]; + + $this->assertSame($expected, json_decode($this->external, true)); + } + + /** + * @throws InvalidArgumentException + */ + public function testJsonSerializeWithSetBlob(): void + { + $this->external->thumb($this->blob); + + $expected = [ + 'uri' => 'https://shahmal1yev.dev', + 'title' => 'foo', + 'description' => 'bar', + 'blob' => 'blob', + ]; + + $this->assertSame($expected, json_decode($this->external, true)); + } } From 8d4ecc5b983c8c81a4c8ddfe41d18a42d526bdfd Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 21:31:18 +0400 Subject: [PATCH 16/59] Implement Record: Add Record and StrongRef classes with tests - Add StrongRef class for com.atproto.repo.strongRef - Implement JsonSerializable and Stringable interfaces for both classes - Add unit tests for Record and StrongRef classes - Include getter/setter methods and JSON serialization --- src/Lexicons/App/Bsky/Embed/Record.php | 29 ++++++++++ src/Lexicons/Com/Atproto/Repo/StrongRef.php | 53 +++++++++++++++++++ .../Com/Atproto/Repo/StrongRefTest.php | 39 ++++++++++++++ .../Lexicons/App/Bsky/Embed/RecordTest.php | 36 +++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 src/Lexicons/App/Bsky/Embed/Record.php create mode 100644 src/Lexicons/Com/Atproto/Repo/StrongRef.php create mode 100644 tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/RecordTest.php diff --git a/src/Lexicons/App/Bsky/Embed/Record.php b/src/Lexicons/App/Bsky/Embed/Record.php new file mode 100644 index 0000000..dd873bb --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Record.php @@ -0,0 +1,29 @@ +ref = $ref; + } + + public function __toString(): string + { + return json_encode($this); + } + + public function jsonSerialize(): array + { + return [ + 'record' => $this->ref, + ]; + } +} diff --git a/src/Lexicons/Com/Atproto/Repo/StrongRef.php b/src/Lexicons/Com/Atproto/Repo/StrongRef.php new file mode 100644 index 0000000..037a584 --- /dev/null +++ b/src/Lexicons/Com/Atproto/Repo/StrongRef.php @@ -0,0 +1,53 @@ +uri($uri) + ->cid($cid); + } + + public function uri(string $uri = null) + { + if (is_null($uri)) { + return $this->uri; + } + + $this->uri = $uri; + + return $this; + } + + public function cid(string $cid = null) + { + if (is_null($cid)) { + return $this->cid; + } + + $this->cid = $cid; + + return $this; + } + + public function jsonSerialize(): array + { + return [ + 'uri' => $this->uri, + 'cid' => $this->cid, + ]; + } + + public function __toString(): string + { + return json_encode($this); + } +} diff --git a/tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php b/tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php new file mode 100644 index 0000000..b98c359 --- /dev/null +++ b/tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php @@ -0,0 +1,39 @@ +strongRef = new StrongRef('foo', 'bar'); + } + + public function testUri() + { + $this->assertSame('foo', $this->strongRef->uri()); + $this->strongRef->uri('bar'); + $this->assertSame('bar', $this->strongRef->uri()); + } + + public function testCid() + { + $this->assertSame('bar', $this->strongRef->cid()); + $this->strongRef->cid('foo'); + $this->assertSame('foo', $this->strongRef->cid()); + } + + public function testJsonSerialize(): void + { + $expected = [ + 'uri' => 'foo', + 'cid' => 'bar', + ]; + + $this->assertSame($expected, $this->strongRef->jsonSerialize()); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/RecordTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/RecordTest.php new file mode 100644 index 0000000..65423a9 --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/Embed/RecordTest.php @@ -0,0 +1,36 @@ + [ + 'uri' => 'foo', + 'cid' => 'bar' + ] + + ]; + + public function setUp(): void + { + $this->record = new Record(new StrongRef('foo', 'bar')); + } + + public function test__toStringWorksWithJsonDecodeDirectly(): void + { + $this->assertSame($this->expected, json_decode($this->record, true)); + } + + public function test__toStringWorksWithJsonEncodeDirectly(): void + { + $this->assertSame(json_encode($this->expected), json_encode($this->record)); + } +} From 4b0bda196df704c5a2ebd0bf83335c3989b4cbc6 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 21:45:32 +0400 Subject: [PATCH 17/59] Implement RecordWithMedia and MediaInterface - Add MediaInterface for unifying different media types - Implement MediaInterface in ImageCollection, External, and Video classes - Create RecordWithMedia class to combine Record with media content - Add unit tests for RecordWithMedia - Update existing classes to implement new MediaInterface --- .../App/Bsky/Embed/MediaInterface.php | 8 +++++ .../Embed/Collections/ImageCollection.php | 3 +- src/Lexicons/App/Bsky/Embed/External.php | 3 +- .../App/Bsky/Embed/RecordWithMedia.php | 35 ++++++++++++++++++ src/Lexicons/App/Bsky/Embed/Video.php | 3 +- .../App/Bsky/Embed/RecordWithMediaTest.php | 36 +++++++++++++++++++ 6 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 src/Contracts/Lexicons/App/Bsky/Embed/MediaInterface.php create mode 100644 src/Lexicons/App/Bsky/Embed/RecordWithMedia.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/MediaInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/MediaInterface.php new file mode 100644 index 0000000..6fd2101 --- /dev/null +++ b/src/Contracts/Lexicons/App/Bsky/Embed/MediaInterface.php @@ -0,0 +1,8 @@ +media($media); + } + + public function media(MediaInterface $media = null) + { + if (is_null($media)) { + return $this->media; + } + + $this->media = $media; + + return $this; + } + + public function jsonSerialize(): array + { + return array_merge(parent::jsonSerialize(), [ + 'media' => $this->media, + ]); + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php index 10cff40..bc97a39 100644 --- a/src/Lexicons/App/Bsky/Embed/Video.php +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -2,12 +2,13 @@ namespace Atproto\Lexicons\App\Bsky\Embed; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\VideoInterface; use Atproto\Contracts\Stringable; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; -class Video implements VideoInterface, Stringable +class Video implements MediaInterface, VideoInterface, Stringable { private Blob $file; private ?string $alt = null; diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php new file mode 100644 index 0000000..870b73b --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php @@ -0,0 +1,36 @@ +media = $this->createMock(MediaInterface::class); + $this->recordWithMedia = new RecordWithMedia($this->createMock(StrongRef::class), $this->media); + } + + public function testMedia() + { + $this->assertSame($this->media, $this->recordWithMedia->media()); + $this->recordWithMedia->media($expected = $this->createMock(MediaInterface::class)); + $this->assertSame($expected, $this->recordWithMedia->media()); + } + + public function testJsonSerialize() + { + $target = json_decode($this->recordWithMedia, true); + + $this->assertArrayHasKey('record', $target); + $this->assertArrayHasKey('media', $target); + $this->assertIsArray($target['media']); + } +} From e2ed997576880cd770eddc364dc772ab2c0fab58 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 22:45:16 +0400 Subject: [PATCH 18/59] Implemented `embed` method on PostBuilder API and refactored embeds - EmbedInterface was introduced. All embeds were refactored to implement EmbedInterface. This interface extends two essential interfaces - JsonSerializable and Stringable, which are mandatory for Lexicon implementations. The reason is that when building a post with the PostBuilder API, each entity that can be used as an embed is a lexicon, and a lexicon should be serializable to an object or array type according to SDK design policy when necessary. The Lexicon interface to be introduced in the future will also extend these two interfaces, and all necessary entities will be refactored. - An embed method was added to the PostBuilder API, related tests were refactored/written. --- .../App/Bsky/Embed/EmbedInterface.php | 2 +- .../App/Bsky/Embed/VideoInterface.php | 5 +-- .../App/Bsky/Feed/PostBuilderContract.php | 6 ++-- .../Embed/Collections/EmbedCollection.php | 5 +++ .../Embed/Collections/ImageCollection.php | 4 +-- src/Lexicons/App/Bsky/Embed/External.php | 5 ++- src/Lexicons/App/Bsky/Embed/Record.php | 5 ++- src/Lexicons/App/Bsky/Embed/Video.php | 3 +- src/Lexicons/App/Bsky/Feed/Post.php | 14 ++++++--- .../Unit/Lexicons/App/Bsky/Feed/PostTest.php | 31 ++++++++++++++++++- 10 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php index 0f20293..4667dd7 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php @@ -4,6 +4,6 @@ use Atproto\Contracts\Stringable; -interface EmbedInterface extends Stringable +interface EmbedInterface extends \JsonSerializable, Stringable { } diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/VideoInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/VideoInterface.php index 4e46a35..69ac85b 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/VideoInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/VideoInterface.php @@ -2,13 +2,10 @@ namespace Atproto\Contracts\Lexicons\App\Bsky\Embed; -use Atproto\Contracts\Stringable; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; -use JsonSerializable; -interface VideoInterface extends JsonSerializable +interface VideoInterface extends EmbedInterface { - public function jsonSerialize(): array; public function captions(CaptionCollection $captions = null); public function alt(string $alt = null); public function aspectRatio(); diff --git a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php index 601a088..0db79b1 100644 --- a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php +++ b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php @@ -3,10 +3,8 @@ namespace Atproto\Contracts\Lexicons\App\Bsky\Feed; use Atproto\Contracts\LexiconBuilder; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Stringable; -use Atproto\Lexicons\App\Bsky\RichText\Link; -use Atproto\Lexicons\App\Bsky\RichText\Mention; -use Atproto\Lexicons\App\Bsky\RichText\Tag; use DateTimeImmutable; interface PostBuilderContract extends LexiconBuilder, Stringable @@ -21,5 +19,5 @@ public function mention(string $reference, string $label = null): PostBuilderCon public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract; - public function embed(...$embeds): PostBuilderContract; + public function embed(EmbedInterface $embed): PostBuilderContract; } diff --git a/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php index 8eb17d7..e8c9e4c 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php @@ -42,4 +42,9 @@ public function jsonSerialize(): array { return $this->toArray(); } + + public function __toString(): string + { + return json_encode($this); + } } diff --git a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php index 23c678f..57c641e 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php @@ -2,13 +2,13 @@ namespace Atproto\Lexicons\App\Bsky\Embed\Collections; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\ImageInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaInterface; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; -use JsonSerializable; -class ImageCollection extends GenericCollection implements MediaInterface, JsonSerializable +class ImageCollection extends GenericCollection implements EmbedInterface, MediaInterface { use EmbedCollection; diff --git a/src/Lexicons/App/Bsky/Embed/External.php b/src/Lexicons/App/Bsky/Embed/External.php index 8cbba67..8c380ed 100644 --- a/src/Lexicons/App/Bsky/Embed/External.php +++ b/src/Lexicons/App/Bsky/Embed/External.php @@ -2,12 +2,11 @@ namespace Atproto\Lexicons\App\Bsky\Embed; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaInterface; -use Atproto\Contracts\Stringable; use Atproto\Exceptions\InvalidArgumentException; -use JsonSerializable; -class External implements MediaInterface, JsonSerializable, Stringable +class External implements EmbedInterface, MediaInterface { private string $uri; private string $title; diff --git a/src/Lexicons/App/Bsky/Embed/Record.php b/src/Lexicons/App/Bsky/Embed/Record.php index dd873bb..eabe245 100644 --- a/src/Lexicons/App/Bsky/Embed/Record.php +++ b/src/Lexicons/App/Bsky/Embed/Record.php @@ -2,11 +2,10 @@ namespace Atproto\Lexicons\App\Bsky\Embed; -use Atproto\Contracts\Stringable; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; -use JsonSerializable; -class Record implements JsonSerializable, Stringable +class Record implements EmbedInterface { private StrongRef $ref; diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php index bc97a39..2b799c8 100644 --- a/src/Lexicons/App/Bsky/Embed/Video.php +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -4,11 +4,10 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\VideoInterface; -use Atproto\Contracts\Stringable; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; -class Video implements MediaInterface, VideoInterface, Stringable +class Video implements VideoInterface, MediaInterface { private Blob $file; private ?string $alt = null; diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php index 560b1af..644a810 100644 --- a/src/Lexicons/App/Bsky/Feed/Post.php +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -4,6 +4,7 @@ use Atproto\Collections\FacetCollection; use Atproto\Collections\FeatureCollection; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Lexicons\App\Bsky\Feed\PostBuilderContract; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\RichText\ByteSlice; @@ -21,6 +22,7 @@ class Post implements PostBuilderContract private string $text = ''; private ?DateTimeImmutable $createdAt = null; private FacetCollection $facets; + private ?EmbedInterface $embed = null; public function __construct() { @@ -70,8 +72,10 @@ public function mention(string $reference, string $label = null): PostBuilderCon return $this->addFeatureItem('mention', $reference, $label); } - public function embed(...$embeds): PostBuilderContract + public function embed(EmbedInterface $embed = null): PostBuilderContract { + $this->embed = $embed; + return $this; } @@ -84,12 +88,13 @@ public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract public function jsonSerialize(): array { - return [ + return array_filter([ '$type' => self::TYPE_NAME, 'createdAt' => $this->getFormattedCreatedAt(), 'text' => $this->text, 'facets' => $this->facets->toArray(), - ]; + 'embed' => ($e = $this->embed) ? $e : null, + ]); } public function __toString(): string @@ -181,11 +186,10 @@ private function addFeature(FeatureAbstract $feature): void $this->text .= $label; try { - $facet = new Facet( + $this->facets[] = new Facet( new FeatureCollection([$feature]), new ByteSlice($this->text, $label) ); - $this->facets[] = $facet; } catch (\GenericCollection\Exceptions\InvalidArgumentException $e) { throw new InvalidArgumentException( sprintf('Feature must be an instance of %s.', FeatureAbstract::class) diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php index fb65b3f..9582c94 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php @@ -2,7 +2,11 @@ namespace Tests\Unit\Lexicons\App\Bsky\Feed; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\VideoInterface; use Atproto\Exceptions\InvalidArgumentException; +use Atproto\Lexicons\App\Bsky\Embed\Blob; +use Atproto\Lexicons\App\Bsky\Embed\Video; use Atproto\Lexicons\App\Bsky\Feed\Post; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use Atproto\Lexicons\App\Bsky\RichText\Mention; @@ -148,15 +152,40 @@ public function testCreatedAtField() $this->assertNotNull($result['createdAt']); } + public function testEmbed(): void + { + $video = $this->getMockBuilder(VideoInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $video->expects($this->once()) + ->method('jsonSerialize') + ->willReturn(['foo' => 'bar']); + + $this->post->embed($video); + + $expected = ['foo' => 'bar']; + $actual = json_decode($this->post, true); + + $this->assertArrayHasKey('embed', $actual); + $this->assertSame($expected, $actual['embed']); + } + + /** + * @throws InvalidArgumentException + */ public function testJsonSerialize() { - $this->post->text('Test post'); + $this->post->text('Test post: ', new Mention('reference', 'label')); + $this->post->embed($embed = $this->createMock(EmbedInterface::class)); + $result = $this->post->jsonSerialize(); $this->assertArrayHasKey('$type', $result); $this->assertEquals('app.bsky.feed.post', $result['$type']); $this->assertArrayHasKey('text', $result); $this->assertArrayHasKey('createdAt', $result); $this->assertArrayHasKey('facets', $result); + $this->assertArrayHasKey('embed', $result); } /** From f03e978d7f715ad1951ea35359a25286ae8db152 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 22:55:51 +0400 Subject: [PATCH 19/59] Code cleanup and test refinements --- src/Lexicons/App/Bsky/RichText/ByteSlice.php | 2 +- src/Lexicons/App/Bsky/RichText/FeatureFactory.php | 1 - tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php | 10 +++++----- tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php | 4 +--- .../Lexicons/Com/Atproto/Repo/StrongRefTest.php | 3 ++- 5 files changed, 9 insertions(+), 11 deletions(-) rename tests/{ => Unit}/Lexicons/Com/Atproto/Repo/StrongRefTest.php (89%) diff --git a/src/Lexicons/App/Bsky/RichText/ByteSlice.php b/src/Lexicons/App/Bsky/RichText/ByteSlice.php index 9f5ab96..75884ec 100644 --- a/src/Lexicons/App/Bsky/RichText/ByteSlice.php +++ b/src/Lexicons/App/Bsky/RichText/ByteSlice.php @@ -32,4 +32,4 @@ public function jsonSerialize(): array 'byteEnd' => $this->end(), ]; } -} \ No newline at end of file +} diff --git a/src/Lexicons/App/Bsky/RichText/FeatureFactory.php b/src/Lexicons/App/Bsky/RichText/FeatureFactory.php index 01a9682..3000d05 100644 --- a/src/Lexicons/App/Bsky/RichText/FeatureFactory.php +++ b/src/Lexicons/App/Bsky/RichText/FeatureFactory.php @@ -4,7 +4,6 @@ class FeatureFactory { - public static function link(string $reference, string $label = null): Link { return new Link($reference, $label); diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php b/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php index 9e43b22..b1ba4cf 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php @@ -23,20 +23,20 @@ private function createMockFile() $file->expects($this->any()) ->method('path') - ->will($this->returnCallback(fn() => $this->path)); + ->will($this->returnCallback(fn () => $this->path)); $file->expects($this->any()) ->method('type') - ->will($this->returnCallback(fn() => $this->type)); + ->will($this->returnCallback(fn () => $this->type)); $file->expects($this->any()) ->method('size') - ->will($this->returnCallback(fn() => $this->size)); + ->will($this->returnCallback(fn () => $this->size)); $file->expects($this->any()) ->method('blob') - ->will($this->returnCallback(fn() => $this->blob)); + ->will($this->returnCallback(fn () => $this->blob)); return $file; } -} \ No newline at end of file +} diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php index 9582c94..b2d6296 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php @@ -5,8 +5,6 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\VideoInterface; use Atproto\Exceptions\InvalidArgumentException; -use Atproto\Lexicons\App\Bsky\Embed\Blob; -use Atproto\Lexicons\App\Bsky\Embed\Video; use Atproto\Lexicons\App\Bsky\Feed\Post; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use Atproto\Lexicons\App\Bsky\RichText\Mention; @@ -142,7 +140,7 @@ public function testTextThrowsExceptionWhenPassedInvalidArgument(): void "Argument at index 4 is invalid: must be a string or an instance of " . FeatureAbstract::class ); - $this->post->text(1, true, 'string', new Post); + $this->post->text(1, true, 'string', new Post()); } public function testCreatedAtField() diff --git a/tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php b/tests/Unit/Lexicons/Com/Atproto/Repo/StrongRefTest.php similarity index 89% rename from tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php rename to tests/Unit/Lexicons/Com/Atproto/Repo/StrongRefTest.php index b98c359..6a9165a 100644 --- a/tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php +++ b/tests/Unit/Lexicons/Com/Atproto/Repo/StrongRefTest.php @@ -1,7 +1,8 @@ Date: Sat, 12 Oct 2024 23:43:32 +0400 Subject: [PATCH 20/59] Implement 'reply' method in PostBuilder API and fix text length validation --- .../App/Bsky/Feed/PostBuilderContract.php | 3 + src/Lexicons/App/Bsky/Feed/Post.php | 17 +- .../Unit/Lexicons/App/Bsky/Feed/PostTest.php | 146 ++++++++---------- 3 files changed, 85 insertions(+), 81 deletions(-) diff --git a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php index 0db79b1..ecc7517 100644 --- a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php +++ b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php @@ -5,6 +5,7 @@ use Atproto\Contracts\LexiconBuilder; use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Stringable; +use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use DateTimeImmutable; interface PostBuilderContract extends LexiconBuilder, Stringable @@ -20,4 +21,6 @@ public function mention(string $reference, string $label = null): PostBuilderCon public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract; public function embed(EmbedInterface $embed): PostBuilderContract; + + public function reply(StrongRef $root, StrongRef $parent): PostBuilderContract; } diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php index 644a810..454f0a6 100644 --- a/src/Lexicons/App/Bsky/Feed/Post.php +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -11,6 +11,7 @@ use Atproto\Lexicons\App\Bsky\RichText\Facet; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use Atproto\Lexicons\App\Bsky\RichText\FeatureFactory; +use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use Carbon\Carbon; use DateTimeImmutable; @@ -23,6 +24,7 @@ class Post implements PostBuilderContract private ?DateTimeImmutable $createdAt = null; private FacetCollection $facets; private ?EmbedInterface $embed = null; + private ?array $reply = null; public function __construct() { @@ -79,6 +81,16 @@ public function embed(EmbedInterface $embed = null): PostBuilderContract return $this; } + public function reply(StrongRef $root, StrongRef $parent): PostBuilderContract + { + $this->reply = [ + 'root' => $root, + 'parent' => $parent + ]; + + return $this; + } + public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract { $this->createdAt = $dateTime; @@ -93,7 +105,8 @@ public function jsonSerialize(): array 'createdAt' => $this->getFormattedCreatedAt(), 'text' => $this->text, 'facets' => $this->facets->toArray(), - 'embed' => ($e = $this->embed) ? $e : null, + 'embed' => $this->embed, + 'replyRef' => $this->reply, ]); } @@ -129,7 +142,7 @@ private function validate($item, int $index = 0): void ); } - if ($this->isString($item) && mb_strlen($item) > self::TEXT_LIMIT) { + if ($this->isString($item) && (mb_strlen($item) + mb_strlen($this->text)) > self::TEXT_LIMIT) { throw new InvalidArgumentException( sprintf( 'Text must be less than or equal to %d characters.', diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php index b2d6296..3bcac96 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php @@ -3,11 +3,11 @@ namespace Tests\Unit\Lexicons\App\Bsky\Feed; use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; -use Atproto\Contracts\Lexicons\App\Bsky\Embed\VideoInterface; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Feed\Post; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use Atproto\Lexicons\App\Bsky\RichText\Mention; +use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use Carbon\Carbon; use DateTimeImmutable; use PHPUnit\Framework\TestCase; @@ -16,7 +16,7 @@ class PostTest extends TestCase { private Post $post; - public function setUp(): void + protected function setUp(): void { parent::setUp(); $this->post = new Post(); @@ -28,20 +28,18 @@ public function setUp(): void public function testTextMethodWithBasicUsage() { $this->post->text('Hello, world!'); - $result = json_decode($this->post, true); + $result = json_decode((string) $this->post, true); $this->assertEquals('Hello, world!', $result['text']); } /** * @throws InvalidArgumentException */ - public function testTextMethod() + public function testTextMethodWithMultipleItems() { $this->post->text('Hello', ', ', new Mention('example:did:123', 'user'), "! It's ", 5, " o'clock now."); - $result = json_decode(json_encode($this->post), true); + $result = json_decode((string) $this->post, true); $this->assertEquals("Hello, @user! It's 5 o'clock now.", $result['text']); - - $this->post->text('This ', new Mention('example:did:123', 'user'), '!'); } /** @@ -50,7 +48,7 @@ public function testTextMethod() public function testTagMethod() { $this->post->tag('example', 'test'); - $result = json_decode($this->post, true); + $result = json_decode((string) $this->post, true); $this->assertEquals('#test', $result['text']); $this->assertCount(1, $result['facets']); } @@ -60,8 +58,8 @@ public function testTagMethod() */ public function testLinkMethod() { - $this->post->link('https://example.com', 'Example'); - $result = json_decode($this->post, true); + $this->post->link('https://shahmal1yev.dev', 'Example'); + $result = json_decode((string) $this->post, true); $this->assertEquals('Example', $result['text']); $this->assertCount(1, $result['facets']); } @@ -72,51 +70,55 @@ public function testLinkMethod() public function testMentionMethod() { $this->post->mention('did:example:123', 'user'); - $result = json_decode($this->post, true); + $result = json_decode((string) $this->post, true); $this->assertEquals('@user', $result['text']); $this->assertCount(1, $result['facets']); } public function testCombinationOfMethods() { - $this->post = $this->post(); + $post = $this->createSamplePost(); - $result = json_decode($this->post, true); + $result = json_decode((string) $post, true); $this->assertEquals('Hello @user! Check out this link #example_tag', $result['text']); $this->assertCount(3, $result['facets']); } - public function testCoordinatesOfFacets(): void + public function testCoordinatesOfFacets() { - $this->post = $this->post(); + $post = $this->createSamplePost(); - $result = json_decode($this->post, true); + $result = json_decode((string) $post, true); $text = $result['text']; $facets = $result['facets']; - $this->assertSame($facets[0]['index'], $this->bytes($text, "@user")); - $this->assertSame($facets[1]['index'], $this->bytes($text, "this link")); - $this->assertSame($facets[2]['index'], $this->bytes($text, "#example_tag")); + $this->assertSame($this->calculateByteSlice($text, '@user'), $facets[0]['index']); + $this->assertSame($this->calculateByteSlice($text, 'this link'), $facets[1]['index']); + $this->assertSame($this->calculateByteSlice($text, '#example_tag'), $facets[2]['index']); } - private function bytes(string $haystack, string $needle): array + private function calculateByteSlice(string $text, string $substring): array { - $pos = mb_strpos($haystack, $needle); - $len = $pos + mb_strlen($needle); + $byteStart = mb_strpos($text, $substring); + $byteEnd = $byteStart + mb_strlen($substring); return [ - 'byteStart' => $pos, - 'byteEnd' => $len, + 'byteStart' => $byteStart, + 'byteEnd' => $byteEnd, ]; } - private function post(): Post + /** + * @throws InvalidArgumentException + */ + private function createSamplePost(): Post { - return $this->post->text('Hello ') + return (new Post()) + ->text('Hello ') ->mention('did:example:123', 'user') ->text('! Check out ') - ->link('https://example.com', 'this link') + ->link('https://shahmal1yev.dev', 'this link') ->text(' ') ->tag('example_tag'); } @@ -129,44 +131,52 @@ public function testTextThrowsExceptionWhenLimitIsExceeded() $this->post->text(str_repeat('a', 3000)); $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Text must be less than or equal to 3000 characters.'); - $this->post->text(str_repeat('a', 3001)); + // This should cause the total text length to exceed the limit + $this->post->text('a'); } - public function testTextThrowsExceptionWhenPassedInvalidArgument(): void + public function testTextThrowsExceptionWhenPassedInvalidArgument() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - "Argument at index 4 is invalid: must be a string or an instance of " . FeatureAbstract::class - ); + $this->expectExceptionMessageMatches('/Argument at index \d+ is invalid/'); $this->post->text(1, true, 'string', new Post()); } - public function testCreatedAtField() + public function testCreatedAtFieldIsSetByDefault() { - $result = json_decode($this->post, true); + $this->post->text('Test post'); + $result = json_decode((string) $this->post, true); + $this->assertArrayHasKey('createdAt', $result); - $this->assertNotNull($result['createdAt']); + + $createdAt = Carbon::parse($result['createdAt']); + $now = Carbon::now(); + + $this->assertTrue($createdAt->diffInSeconds($now) < 5, 'createdAt should be within the last 5 seconds'); } - public function testEmbed(): void + public function testCreatedAtReturnsAssignedTime() { - $video = $this->getMockBuilder(VideoInterface::class) - ->disableOriginalConstructor() - ->getMock(); + $futureTime = Carbon::now()->addHour(); + $this->post->createdAt($futureTime->toDateTimeImmutable()); - $video->expects($this->once()) - ->method('jsonSerialize') - ->willReturn(['foo' => 'bar']); + $result = json_decode((string) $this->post, true); + $this->assertEquals($futureTime->toIso8601String(), $result['createdAt']); + } - $this->post->embed($video); + public function testEmbedMethod() + { + $embed = $this->createMock(EmbedInterface::class); + $embed->method('jsonSerialize')->willReturn(['embedData' => 'value']); - $expected = ['foo' => 'bar']; - $actual = json_decode($this->post, true); + $this->post->embed($embed); - $this->assertArrayHasKey('embed', $actual); - $this->assertSame($expected, $actual['embed']); + $result = json_decode((string) $this->post, true); + $this->assertArrayHasKey('embed', $result); + $this->assertEquals(['embedData' => 'value'], $result['embed']); } /** @@ -175,7 +185,11 @@ public function testEmbed(): void public function testJsonSerialize() { $this->post->text('Test post: ', new Mention('reference', 'label')); - $this->post->embed($embed = $this->createMock(EmbedInterface::class)); + $embed = $this->createMock(EmbedInterface::class); + $embed->method('jsonSerialize')->willReturn(['embedKey' => 'embedValue']); + $this->post->embed($embed); + $sRef = new StrongRef('foo', 'bar'); + $this->post->reply($sRef, clone $sRef); $result = $this->post->jsonSerialize(); $this->assertArrayHasKey('$type', $result); @@ -184,39 +198,13 @@ public function testJsonSerialize() $this->assertArrayHasKey('createdAt', $result); $this->assertArrayHasKey('facets', $result); $this->assertArrayHasKey('embed', $result); + $this->assertArrayHasKey('replyRef', $result); } - /** - * @throws InvalidArgumentException - */ - public function testCreatedAtAssignedByDefault(): void - { - $post = $this->post->text('Test post'); - $result = json_decode($post, true); - - $this->assertSame($result['createdAt'], Carbon::now()->toIso8601String()); - } - - public function testCreatedAtReturnsAssignedTime(): void + public function testConstructorWorksCorrectlyOnDirectBuild() { - $timestamp = time() + 3600; // Get the current timestamp - $this->post->createdAt(new DateTimeImmutable("@$timestamp")); - - $actual = json_decode($this->post, false)->createdAt; - $expected = Carbon::now()->modify("+1 hour")->toIso8601String(); - - $this->assertSame( - $actual, - $expected, - ); - } - - public function testConstructorWorksCorrectlyOnDirectBuild(): void - { - $array = json_decode($this->post, true); - $json = json_encode($array); - - $this->assertTrue(is_array($array)); - $this->assertTrue(json_encode($array) === $json); + $result = json_decode((string) $this->post, true); + $this->assertIsArray($result); + $this->assertEquals($result, json_decode(json_encode($result), true)); } } From 612b6a583c964fbe5061712f84990248d99b1395 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 13 Oct 2024 00:23:22 +0400 Subject: [PATCH 21/59] Add language support to PostBuilderContract and Post class - Added `langs` method to PostBuilderContract interface to handle language settings for a post. - Implemented `langs` method in the Post class to set and validate language codes with a limit of 3 languages. - Added validation for language codes using a regex to ensure conformity with the standard language tag format. --- .../App/Bsky/Feed/PostBuilderContract.php | 2 + src/Lexicons/App/Bsky/Feed/Post.php | 58 +++++++++++++++++++ .../Unit/Lexicons/App/Bsky/Feed/PostTest.php | 37 ++++++++++++ 3 files changed, 97 insertions(+) diff --git a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php index ecc7517..b90afc6 100644 --- a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php +++ b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php @@ -23,4 +23,6 @@ public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract; public function embed(EmbedInterface $embed): PostBuilderContract; public function reply(StrongRef $root, StrongRef $parent): PostBuilderContract; + + public function langs(array $languages): PostBuilderContract; } diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php index 454f0a6..6a1d450 100644 --- a/src/Lexicons/App/Bsky/Feed/Post.php +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -25,6 +25,7 @@ class Post implements PostBuilderContract private FacetCollection $facets; private ?EmbedInterface $embed = null; private ?array $reply = null; + private ?array $languages = null; public function __construct() { @@ -91,6 +92,62 @@ public function reply(StrongRef $root, StrongRef $parent): PostBuilderContract return $this; } + /** + * Sets the languages for the post. + * + * @param array $languages + * @return PostBuilderContract + * @throws InvalidArgumentException + */ + public function langs(array $languages): PostBuilderContract + { + if (count($languages) > 3) { + throw new InvalidArgumentException('A maximum of 3 language codes is allowed.'); + } + + foreach($languages as $lang) { + if (! $this->isValidLanguageCode($lang)) { + throw new InvalidArgumentException(sprintf('Invalid language code: %s', $lang)); + } + } + + if (empty($languages)) { + $languages = null; + } + + $this->languages = $languages; + + return $this; + } + + /** + * Validates the format of a language code. + * + * @param string $lang + * @return bool + */ + private function isValidLanguageCode(string $lang): bool + { + $regular = '(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang)'; + $irregular = '(en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)'; + $grandfathered = '(' . $irregular . '|' . $regular . ')'; + $privateUse = '(x(-[A-Za-z0-9]{1,8})+)'; + $privateUse2 = '(x(-[A-Za-z0-9]{1,8})+)'; + $singleton = '[0-9A-WY-Za-wy-z]'; + $extension = '(' . $singleton . '(-[A-Za-z0-9]{2,8})+)'; + $variant = '([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3})'; + $region = '([A-Za-z]{2}|[0-9]{3})'; + $script = '([A-Za-z]{4})'; + $extlang = '([A-Za-z]{3}(-[A-Za-z]{3}){0,2})'; + $language = '(([A-Za-z]{2,3}(-' . $extlang . ')?)|[A-Za-z]{4}|[A-Za-z]{5,8})'; + $langtag = '(' . $language . '(-' . $script . ')?(-' . $region . ')?(-' . $variant . ')*(-' . $extension . ')*(-' . $privateUse . ')?)'; + $languageTag = '(' . $grandfathered . '|' . $langtag . '|' . $privateUse2 . ')'; + + $regex = '/^' . $languageTag . '$/'; + + return preg_match($regex, $lang); + } + public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract { $this->createdAt = $dateTime; @@ -107,6 +164,7 @@ public function jsonSerialize(): array 'facets' => $this->facets->toArray(), 'embed' => $this->embed, 'replyRef' => $this->reply, + 'langs' => $this->languages, ]); } diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php index 3bcac96..2f8b657 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php @@ -179,6 +179,41 @@ public function testEmbedMethod() $this->assertEquals(['embedData' => 'value'], $result['embed']); } + /** + * @throws InvalidArgumentException + */ + public function testLangsMethodWithValidInput() + { + $this->post->langs(['en', 'fr', 'es']); + $result = json_decode($this->post, true); + + $this->assertArrayHasKey('langs', $result); + $this->assertEquals(['en', 'fr', 'es'], $result['langs']); + } + + /** + * @throws InvalidArgumentException + */ + public function testLangsMethodThrowsExceptionOnTooManyLanguages() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A maximum of 3 language codes is allowed.'); + + $this->post->langs(['en', 'fr', 'es', 'de']); + } + + /** + * @throws InvalidArgumentException + */ + public function testLangsMethodThrowsExceptionOnInvalidLanguageCode() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid language code: d'); + + $this->post->langs(['en', 'd']); + } + + /** * @throws InvalidArgumentException */ @@ -190,6 +225,7 @@ public function testJsonSerialize() $this->post->embed($embed); $sRef = new StrongRef('foo', 'bar'); $this->post->reply($sRef, clone $sRef); + $this->post->langs(['en', 'fr', 'es']); $result = $this->post->jsonSerialize(); $this->assertArrayHasKey('$type', $result); @@ -199,6 +235,7 @@ public function testJsonSerialize() $this->assertArrayHasKey('facets', $result); $this->assertArrayHasKey('embed', $result); $this->assertArrayHasKey('replyRef', $result); + $this->assertArrayHasKey('langs', $result); } public function testConstructorWorksCorrectlyOnDirectBuild() From b249eb9a7ce5c12d3a675f826000e05f40768b75 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 13 Oct 2024 22:04:45 +0400 Subject: [PATCH 22/59] SelfLabels lexicon and corresponding unit tests --- src/Lexicons/Com/Atproto/Label/SelfLabels.php | 74 +++++++++++++++++++ .../Com/Atproto/Label/SelfLabelsTest.php | 73 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 src/Lexicons/Com/Atproto/Label/SelfLabels.php create mode 100644 tests/Unit/Lexicons/Com/Atproto/Label/SelfLabelsTest.php diff --git a/src/Lexicons/Com/Atproto/Label/SelfLabels.php b/src/Lexicons/Com/Atproto/Label/SelfLabels.php new file mode 100644 index 0000000..f2a0fd0 --- /dev/null +++ b/src/Lexicons/Com/Atproto/Label/SelfLabels.php @@ -0,0 +1,74 @@ +collection = $collection; + + try { + parent::__construct($this->validator(), $collection); + } catch (\TypeError|\GenericCollection\Exceptions\InvalidArgumentException $e) { + $this->throw($e); + } + } + + private function validator(): \Closure + { + return function (string $val): bool { + if ($this->count() + 1 > self::MAXLENGTH) { + throw new InvalidArgumentException("Maximum allowed length is " . self::MAXLENGTH); + } + + if (strlen($val) > self::MAXLENGTH_BY_ITEM) { + throw new InvalidArgumentException("Length exceeded for $val. Max ".self::MAXLENGTH_BY_ITEM." characters allowed."); + } + + return true; + }; + } + + /** + * @throws InvalidArgumentException + */ + private function throw($exception): void + { + throw new InvalidArgumentException( + str_replace( + "::".__NAMESPACE__."\{closure}()", + "", + $exception->getMessage() + ), + 0, + $exception + ); + } + + public function __toString(): string + { + return json_encode($this); + } + + public function jsonSerialize() + { + return $this->toArray(); + } + + public function toArray(): array + { + return array_map(fn (string $val) => ['val' => $val], $this->collection); + } +} diff --git a/tests/Unit/Lexicons/Com/Atproto/Label/SelfLabelsTest.php b/tests/Unit/Lexicons/Com/Atproto/Label/SelfLabelsTest.php new file mode 100644 index 0000000..b5291be --- /dev/null +++ b/tests/Unit/Lexicons/Com/Atproto/Label/SelfLabelsTest.php @@ -0,0 +1,73 @@ +selfLabels = new SelfLabels(); + } + + /** + * @throws InvalidArgumentException + */ + public function test__constructFillsDataCorrectly(): void + { + $expected = array_map(fn (string $val) => ['val' => $val], ['val 1', 'val 2', 'val 3']); + + $this->selfLabels = new SelfLabels(array_column( + $expected, + 'val' + )); + + $this->assertEquals($expected, $this->selfLabels->toArray()); + } + + public function test__constructorThrowsInvalidArgumentExceptionWhenPassedInvalidArgument(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("must be of the type string"); + + $this->selfLabels = new SelfLabels(['val 1', new stdClass()]); + } + + public function test__constructThrowsInvalidArgumentExceptionWhenLimitExceeded(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Maximum allowed length is ".$this->maxlength); + + $trigger = str_split(str_pad('', ++$this->maxlength, 'a')); + $this->selfLabels = new SelfLabels($trigger); + } + + public function test__constructorThrowsExceptionWhenPassedArgumentThatExceedsLimit(): void + { + $trigger = [str_pad('', ++$this->maxlengthByItem, 'a')]; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Length exceeded for " . current($trigger)); + + $this->selfLabels = new SelfLabels($trigger); + } + + public function testAddThrowsExceptionWhenPassedArgumentExceedsMaxLengthLimit(): void + { + $trigger = str_split(str_pad('', ++$this->maxlength, 'a')); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Maximum allowed length is ".--$this->maxlength); + + foreach($trigger as $item) { + $this->selfLabels[] = $item; + } + } +} From b21eb1367da8366ea1c3fc2c8dcca0c5721e7bea Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 13 Oct 2024 22:15:05 +0400 Subject: [PATCH 23/59] Add support for labels in PostBuilderContract and Post class --- .../App/Bsky/Feed/PostBuilderContract.php | 3 +++ src/Lexicons/App/Bsky/Feed/Post.php | 11 +++++++++ .../Unit/Lexicons/App/Bsky/Feed/PostTest.php | 23 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php index b90afc6..9058dc6 100644 --- a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php +++ b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php @@ -5,6 +5,7 @@ use Atproto\Contracts\LexiconBuilder; use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Stringable; +use Atproto\Lexicons\Com\Atproto\Label\SelfLabels; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use DateTimeImmutable; @@ -25,4 +26,6 @@ public function embed(EmbedInterface $embed): PostBuilderContract; public function reply(StrongRef $root, StrongRef $parent): PostBuilderContract; public function langs(array $languages): PostBuilderContract; + + public function labels(SelfLabels $labels): PostBuilderContract; } diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php index 6a1d450..b028d64 100644 --- a/src/Lexicons/App/Bsky/Feed/Post.php +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -11,6 +11,7 @@ use Atproto\Lexicons\App\Bsky\RichText\Facet; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use Atproto\Lexicons\App\Bsky\RichText\FeatureFactory; +use Atproto\Lexicons\Com\Atproto\Label\SelfLabels; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use Carbon\Carbon; use DateTimeImmutable; @@ -26,6 +27,8 @@ class Post implements PostBuilderContract private ?EmbedInterface $embed = null; private ?array $reply = null; private ?array $languages = null; + private ?SelfLabels $labels = null; + public function __construct() { @@ -120,6 +123,13 @@ public function langs(array $languages): PostBuilderContract return $this; } + public function labels(SelfLabels $labels): PostBuilderContract + { + $this->labels = $labels; + + return $this; + } + /** * Validates the format of a language code. * @@ -165,6 +175,7 @@ public function jsonSerialize(): array 'embed' => $this->embed, 'replyRef' => $this->reply, 'langs' => $this->languages, + 'labels' => $this->labels ]); } diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php index 2f8b657..8678a23 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php @@ -7,6 +7,7 @@ use Atproto\Lexicons\App\Bsky\Feed\Post; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use Atproto\Lexicons\App\Bsky\RichText\Mention; +use Atproto\Lexicons\Com\Atproto\Label\SelfLabels; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use Carbon\Carbon; use DateTimeImmutable; @@ -213,6 +214,23 @@ public function testLangsMethodThrowsExceptionOnInvalidLanguageCode() $this->post->langs(['en', 'd']); } + /** + * @throws InvalidArgumentException + */ + public function testLabelsMethod(): void + { + $labels = new SelfLabels(['v 1', 'v 2', 'v 3']); + + $this->post->labels($labels); + + $result = json_decode($this->post, true); + $this->assertArrayHasKey('labels', $result); + $this->assertEquals([ + ['val' => 'v 1'], + ['val' => 'v 2'], + ['val' => 'v 3'], + ], $result['labels']); + } /** * @throws InvalidArgumentException @@ -226,6 +244,10 @@ public function testJsonSerialize() $sRef = new StrongRef('foo', 'bar'); $this->post->reply($sRef, clone $sRef); $this->post->langs(['en', 'fr', 'es']); + $this->post->labels(new SelfLabels([ + 'val 1', + 'val 2' + ])); $result = $this->post->jsonSerialize(); $this->assertArrayHasKey('$type', $result); @@ -236,6 +258,7 @@ public function testJsonSerialize() $this->assertArrayHasKey('embed', $result); $this->assertArrayHasKey('replyRef', $result); $this->assertArrayHasKey('langs', $result); + $this->assertArrayHasKey('labels', $result); } public function testConstructorWorksCorrectlyOnDirectBuild() From c2ef9b7813769cb7e6f020ded7901a184e9d9cba Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 13 Oct 2024 22:30:16 +0400 Subject: [PATCH 24/59] Add tags support to PostBuilder API --- .../App/Bsky/Feed/PostBuilderContract.php | 2 + src/Lexicons/App/Bsky/Feed/Post.php | 35 +++++++++++++- .../Unit/Lexicons/App/Bsky/Feed/PostTest.php | 46 ++++++++++++++++--- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php index 9058dc6..835447b 100644 --- a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php +++ b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php @@ -28,4 +28,6 @@ public function reply(StrongRef $root, StrongRef $parent): PostBuilderContract; public function langs(array $languages): PostBuilderContract; public function labels(SelfLabels $labels): PostBuilderContract; + + public function tags(array $tags): PostBuilderContract; } diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php index b028d64..9737904 100644 --- a/src/Lexicons/App/Bsky/Feed/Post.php +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -28,6 +28,7 @@ class Post implements PostBuilderContract private ?array $reply = null; private ?array $languages = null; private ?SelfLabels $labels = null; + private ?array $tags = null; public function __construct() @@ -130,6 +131,37 @@ public function labels(SelfLabels $labels): PostBuilderContract return $this; } + /** + * @throws InvalidArgumentException + */ + public function tags(array $tags): PostBuilderContract + { + $maxLength = 8; + $maxLengthByTag = 640; + + if (count($tags) > $maxLength) { + throw new InvalidArgumentException('A maximum of 8 tags is allowed.'); + } + + $invalid = array_filter($tags, function ($tag) { + if (mb_strlen($tag) > 640) { + return true; + } + }); + + if (! empty($invalid)) { + throw new InvalidArgumentException(sprintf( + "Invalid tags: %s. A tag maximum of %s characters is allowed.", + implode(', ', $invalid), + $maxLengthByTag + )); + } + + $this->tags = $tags; + + return $this; + } + /** * Validates the format of a language code. * @@ -175,7 +207,8 @@ public function jsonSerialize(): array 'embed' => $this->embed, 'replyRef' => $this->reply, 'langs' => $this->languages, - 'labels' => $this->labels + 'labels' => $this->labels, + 'tags' => $this->tags, ]); } diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php index 8678a23..5e40030 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php @@ -232,6 +232,41 @@ public function testLabelsMethod(): void ], $result['labels']); } + public function testTagsThrowsExceptionWhenPassedTagExceedsAllowedLength(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid tags: "); + + $trigger = [str_pad('', 641, 'a')]; + $this->post->tags($trigger); + } + + public function testTagsMethodThrowsExceptionWhenPassedArrayExceedsAllowedLength(): void + { + $max = 8; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("A maximum of $max tags is allowed."); + + $trigger = str_split(str_pad('', ++$max, 'a')); + + $this->post->tags($trigger); + } + + /** + * @throws InvalidArgumentException + */ + public function testTags(): void + { + $tags = [str_pad('', 640, 'a')]; + $this->post->tags($tags); + + $result = json_decode($this->post, true); + + $this->assertArrayHasKey('tags', $result); + $this->assertEquals($tags, $result['tags']); + } + /** * @throws InvalidArgumentException */ @@ -241,15 +276,13 @@ public function testJsonSerialize() $embed = $this->createMock(EmbedInterface::class); $embed->method('jsonSerialize')->willReturn(['embedKey' => 'embedValue']); $this->post->embed($embed); - $sRef = new StrongRef('foo', 'bar'); - $this->post->reply($sRef, clone $sRef); + $this->post->reply($sRef = new StrongRef('foo', 'bar'), clone $sRef); $this->post->langs(['en', 'fr', 'es']); - $this->post->labels(new SelfLabels([ - 'val 1', - 'val 2' - ])); + $this->post->labels(new SelfLabels(str_split(str_pad('', 2, 'val')))); + $this->post->tags(str_split(str_pad('', 2, 'tag'))); $result = $this->post->jsonSerialize(); + $this->assertArrayHasKey('$type', $result); $this->assertEquals('app.bsky.feed.post', $result['$type']); $this->assertArrayHasKey('text', $result); @@ -259,6 +292,7 @@ public function testJsonSerialize() $this->assertArrayHasKey('replyRef', $result); $this->assertArrayHasKey('langs', $result); $this->assertArrayHasKey('labels', $result); + $this->assertArrayHasKey('tags', $result); } public function testConstructorWorksCorrectlyOnDirectBuild() From 06487f5935416cd86feb35797e40d37609ac5eec Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 13 Oct 2024 22:54:17 +0400 Subject: [PATCH 25/59] Fix PHPStan-reported issues --- src/Lexicons/Com/Atproto/Label/SelfLabels.php | 2 +- src/Resources/Assets/ProfilesAsset.php | 3 +-- src/Support/Media.php | 10 ---------- src/Traits/Authentication.php | 6 +++--- 4 files changed, 5 insertions(+), 16 deletions(-) delete mode 100644 src/Support/Media.php diff --git a/src/Lexicons/Com/Atproto/Label/SelfLabels.php b/src/Lexicons/Com/Atproto/Label/SelfLabels.php index f2a0fd0..60489a2 100644 --- a/src/Lexicons/Com/Atproto/Label/SelfLabels.php +++ b/src/Lexicons/Com/Atproto/Label/SelfLabels.php @@ -62,7 +62,7 @@ public function __toString(): string return json_encode($this); } - public function jsonSerialize() + public function jsonSerialize(): array { return $this->toArray(); } diff --git a/src/Resources/Assets/ProfilesAsset.php b/src/Resources/Assets/ProfilesAsset.php index d856d53..defb8c0 100644 --- a/src/Resources/Assets/ProfilesAsset.php +++ b/src/Resources/Assets/ProfilesAsset.php @@ -2,9 +2,8 @@ namespace Atproto\Resources\Assets; +use Atproto\Collections\Types\NonPrimitive\ProfileAssetType; use Atproto\Contracts\HTTP\Resources\AssetContract; -use Atproto\GenericCollection\Types\NonPrimitive\FollowerAssetType; -use Atproto\GenericCollection\Types\NonPrimitive\ProfileAssetType; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; use GenericCollection\Interfaces\TypeInterface; diff --git a/src/Support/Media.php b/src/Support/Media.php deleted file mode 100644 index 066e3d6..0000000 --- a/src/Support/Media.php +++ /dev/null @@ -1,10 +0,0 @@ -authenticated; } - public function attach(SplObserver $observer) + public function attach(SplObserver $observer): void { $this->observers->attach($observer); } - public function detach(SplObserver $observer) + public function detach(SplObserver $observer): void { $this->observers->detach($observer); } - public function notify() + public function notify(): void { foreach ($this->observers as $observer) { $observer->update($this); From 72a3b054aee73bf2d442046301c7dd9476416c2b Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Fri, 18 Oct 2024 19:00:58 +0400 Subject: [PATCH 26/59] Keep lexicons in one place --- src/{HTTP/API => Lexicons}/APIRequest.php | 3 +-- .../Requests => Lexicons}/App/Bsky/Actor/GetProfile.php | 7 +++---- .../Requests => Lexicons}/App/Bsky/Actor/GetProfiles.php | 6 +++--- .../Requests => Lexicons}/App/Bsky/Graph/GetFollowers.php | 8 ++++---- .../Com/Atproto/Repo/CreateRecord.php | 4 ++-- .../Requests => Lexicons}/Com/Atproto/Repo/UploadBlob.php | 4 ++-- .../Com/Atproto/Server/CreateSession.php | 4 ++-- src/{HTTP => Lexicons}/Request.php | 6 +++--- src/{HTTP => Lexicons}/Traits/Authentication.php | 4 ++-- src/{HTTP => Lexicons}/Traits/RequestBuilder.php | 2 +- src/{HTTP => Lexicons}/Traits/RequestHandler.php | 2 +- src/Traits/Smith.php | 2 +- .../HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php | 2 +- tests/Unit/ClientTest.php | 8 ++++---- tests/Unit/HTTP/API/APIRequestTest.php | 6 +++--- .../HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php | 6 +++--- .../HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php | 2 +- .../HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php | 2 +- .../API/Requests/Com/Atproto/Repo/CreateRecordTest.php | 2 +- .../HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php | 2 +- tests/Unit/HTTP/RequestTest.php | 2 +- 21 files changed, 41 insertions(+), 43 deletions(-) rename src/{HTTP/API => Lexicons}/APIRequest.php (95%) rename src/{HTTP/API/Requests => Lexicons}/App/Bsky/Actor/GetProfile.php (90%) rename src/{HTTP/API/Requests => Lexicons}/App/Bsky/Actor/GetProfiles.php (93%) rename src/{HTTP/API/Requests => Lexicons}/App/Bsky/Graph/GetFollowers.php (93%) rename src/{HTTP/API/Requests => Lexicons}/Com/Atproto/Repo/CreateRecord.php (96%) rename src/{HTTP/API/Requests => Lexicons}/Com/Atproto/Repo/UploadBlob.php (93%) rename src/{HTTP/API/Requests => Lexicons}/Com/Atproto/Server/CreateSession.php (89%) rename src/{HTTP => Lexicons}/Request.php (54%) rename src/{HTTP => Lexicons}/Traits/Authentication.php (92%) rename src/{HTTP => Lexicons}/Traits/RequestBuilder.php (98%) rename src/{HTTP => Lexicons}/Traits/RequestHandler.php (98%) diff --git a/src/HTTP/API/APIRequest.php b/src/Lexicons/APIRequest.php similarity index 95% rename from src/HTTP/API/APIRequest.php rename to src/Lexicons/APIRequest.php index aba1b35..6a91b38 100644 --- a/src/HTTP/API/APIRequest.php +++ b/src/Lexicons/APIRequest.php @@ -1,11 +1,10 @@ invoke($this->client); - $expectedNamespace = 'Atproto\\HTTP\\API\\Requests\\App\\Bsky\\Actor'; + $expectedNamespace = 'Atproto\\Lexicons\\App\\Bsky\\Actor'; $this->assertSame($expectedNamespace, $namespace); } @@ -57,7 +57,7 @@ public function testForgeThrowsRequestNotFoundException(): void $this->client->nonExistentMethod(); $this->expectException(RequestNotFoundException::class); - $this->expectExceptionMessage("Atproto\\HTTP\\API\\Requests\\NonExistentMethod class does not exist."); + $this->expectExceptionMessage("Atproto\\Lexicons\\NonExistentMethod class does not exist."); $this->client->forge(); } diff --git a/tests/Unit/HTTP/API/APIRequestTest.php b/tests/Unit/HTTP/API/APIRequestTest.php index f92ef04..6674eee 100644 --- a/tests/Unit/HTTP/API/APIRequestTest.php +++ b/tests/Unit/HTTP/API/APIRequestTest.php @@ -3,8 +3,8 @@ namespace Tests\Unit\HTTP\API; use Atproto\Client; -use Atproto\HTTP\API\APIRequest; -use Atproto\HTTP\API\Requests\Com\Atproto\Server\CreateSession; +use Atproto\Lexicons\APIRequest; +use Atproto\Lexicons\Com\Atproto\Server\CreateSession; use Faker\Factory; use PHPUnit\Framework\TestCase; use Tests\Supports\Reflection; @@ -23,7 +23,7 @@ public function setUp(): void $clientMock = $this->createMock(Client::class); $clientMock->method('path')->willReturn(str_replace( - 'Atproto\\HTTP\\API\\Requests\\', + 'Atproto\\Lexicons\\', '', CreateSession::class )); diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php index 9bbce9a..3f4ed5b 100644 --- a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php @@ -6,9 +6,9 @@ use Atproto\Contracts\HTTP\APIRequestContract; use Atproto\Contracts\RequestContract; use Atproto\Exceptions\Http\MissingFieldProvidedException; -use Atproto\HTTP\API\APIRequest; -use Atproto\HTTP\API\Requests\App\Bsky\Actor\GetProfile; -use Atproto\HTTP\Request; +use Atproto\Lexicons\APIRequest; +use Atproto\Lexicons\App\Bsky\Actor\GetProfile; +use Atproto\Lexicons\Request; use Faker\Factory; use Faker\Generator; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php index 6bc3a4e..5e5e909 100644 --- a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php @@ -6,7 +6,7 @@ use Atproto\Exceptions\Auth\AuthRequired; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Exceptions\InvalidArgumentException; -use Atproto\HTTP\API\Requests\App\Bsky\Actor\GetProfiles; +use Atproto\Lexicons\App\Bsky\Actor\GetProfiles; use Atproto\Resources\App\Bsky\Actor\GetProfilesResource; use GenericCollection\GenericCollection; use GenericCollection\Types\Primitive\StringType; diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php index 508bf92..a12c961 100644 --- a/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php @@ -7,7 +7,7 @@ use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Exceptions\Http\Response\AuthMissingException; use Atproto\Exceptions\InvalidArgumentException; -use Atproto\HTTP\API\Requests\App\Bsky\Graph\GetFollowers; +use Atproto\Lexicons\App\Bsky\Graph\GetFollowers; use PHPUnit\Framework\TestCase; class GetFollowersTest extends TestCase diff --git a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php index 64cd712..8a1da0c 100644 --- a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php +++ b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php @@ -4,7 +4,7 @@ use Atproto\Client; use Atproto\Exceptions\Http\MissingFieldProvidedException; -use Atproto\HTTP\API\Requests\Com\Atproto\Repo\CreateRecord; +use Atproto\Lexicons\Com\Atproto\Repo\CreateRecord; use Faker\Factory; use Faker\Generator; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php index 99adb56..f7eef05 100644 --- a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php +++ b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php @@ -4,7 +4,7 @@ use Atproto\Client; use Atproto\Exceptions\Http\MissingFieldProvidedException; -use Atproto\HTTP\API\Requests\Com\Atproto\Repo\UploadBlob; +use Atproto\Lexicons\Com\Atproto\Repo\UploadBlob; use Faker\Factory; use Faker\Generator; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/HTTP/RequestTest.php b/tests/Unit/HTTP/RequestTest.php index 542d07f..a734373 100644 --- a/tests/Unit/HTTP/RequestTest.php +++ b/tests/Unit/HTTP/RequestTest.php @@ -4,7 +4,7 @@ use ArgumentCountError; use Atproto\Contracts\RequestContract; -use Atproto\HTTP\Request; +use Atproto\Lexicons\Request; use Faker\Factory; use Faker\Generator; use PHPUnit\Framework\TestCase; From 7532e687d20186d305c13e084189d6cefff4bf39 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Fri, 18 Oct 2024 19:25:10 +0400 Subject: [PATCH 27/59] Add a feature test for record creation --- src/Contracts/LexiconContract.php | 7 +++ .../Com/Atproto/Repo/CreateRecord.php | 19 ++++++- src/Traits/Smith.php | 7 ++- .../Com/Atproto/Repo/CreateRecordTest.php | 55 +++++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 src/Contracts/LexiconContract.php create mode 100644 tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php diff --git a/src/Contracts/LexiconContract.php b/src/Contracts/LexiconContract.php new file mode 100644 index 0000000..00fa28f --- /dev/null +++ b/src/Contracts/LexiconContract.php @@ -0,0 +1,7 @@ + $this->repo(), + 'collection' => $this->collection(), + 'record' => $this->record(), + 'swapCommit' => $this->swapCommit(), + 'validate' => $this->validate(), + ]); + } } diff --git a/src/Traits/Smith.php b/src/Traits/Smith.php index b458f32..21ebde5 100644 --- a/src/Traits/Smith.php +++ b/src/Traits/Smith.php @@ -23,7 +23,7 @@ public function __call(string $name, array $arguments): Client /** * @throws RequestNotFoundException */ - public function forge(...$arguments): RequestContract + public function forge(...$arguments) { $arguments = array_merge([$this], array_values($arguments)); @@ -36,7 +36,10 @@ public function forge(...$arguments): RequestContract /** @var APIRequestContract $request */ $request = new $request(...$arguments); - $this->attach($request); + if ($request instanceof \SplObserver) { + $this->attach($request); + } + $this->refresh(); return $request; diff --git a/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php new file mode 100644 index 0000000..2311698 --- /dev/null +++ b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php @@ -0,0 +1,55 @@ +authenticate( + getenv('BLUESKY_IDENTIFIER'), + getenv('BLUESKY_PASSWORD') + ); + } + + /** + * @throws InvalidArgumentException + */ + public function testBuildCreateRecordRequestWithPost(): void + { + $client = static::$client; + + $post = $client->app()->bsky()->feed()->post()->forge()->text( + "Hello World! ", + "This post was sent from a feature test of the BlueSky PHP SDK ", + ); + + $this->assertInstanceOf(PostBuilderContract::class, $post); + + $createRecord = $client->com()->atproto()->repo()->createRecord()->forge()->record($post); + + $this->assertInstanceOf(CreateRecord::class, $createRecord); + + $serializedPost = json_decode($post, true); + $actualPost = Arr::get(json_decode($createRecord, true), 'record'); + + $this->assertSame($serializedPost, $actualPost); + + $response = $client->send(); + + $this->assertIsString($response->uri()); + + echo $response->uri(); + } +} From ef1dcc74b67039ea6b5cbc64eea8feed38d896b6 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Fri, 25 Oct 2024 03:01:15 +0400 Subject: [PATCH 28/59] Add new helpers: encode_varint, decode_varint --- src/helpers.php | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/helpers.php b/src/helpers.php index 8ae5523..d20aadd 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -41,3 +41,40 @@ function trait_uses_recursive($trait): array return $traits; } } + +if (! function_exists('encode_varint')) { + function encode_varint(int $int): string + { + $encoded = ''; + + while ($int >= 0x80) { + $encoded .= chr(($int & 0x7F) | 0x80); + $int >>= 7; + } + + $encoded .= chr($int); + + return $encoded; + } +} + +if (! function_exists('decode_varint')) { + function decode_varint(string $data): int + { + $number = 0; + $shift = 0; + + foreach (str_split($data) as $char) { + $byte = ord($char); + $number |= ($byte & 0x7F) << $shift; + + if (($byte & 0x80) === 0) { + break; + } + + $shift += 7; + } + + return $number; + } +} From 68c12ed27671cb55a6fafcef2be3fafd1ed84af4 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 27 Oct 2024 01:19:16 +0400 Subject: [PATCH 29/59] refactor: standardize naming conventions and imports - Rename interfaces to Contract suffix - Rename support classes for clarity - Clean up test dependencies and imports - Update composer dependencies --- composer.json | 3 ++- .../{CaptionInterface.php => CaptionContract.php} | 4 ++-- .../{MediaInterface.php => MediaContract.php} | 5 ++--- src/Support/{File.php => FileSupport.php} | 7 ++++++- .../Bsky/Embed/{FileMocking.php => FileMock.php} | 15 +++------------ 5 files changed, 15 insertions(+), 19 deletions(-) rename src/Contracts/Lexicons/App/Bsky/Embed/{CaptionInterface.php => CaptionContract.php} (66%) rename src/Contracts/Lexicons/App/Bsky/Embed/{MediaInterface.php => MediaContract.php} (70%) rename src/Support/{File.php => FileSupport.php} (96%) rename tests/Unit/Lexicons/App/Bsky/Embed/{FileMocking.php => FileMock.php} (63%) diff --git a/composer.json b/composer.json index 1223b0b..8abe24a 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,8 @@ "require-dev": { "phpunit/phpunit": "9.6.20", "fakerphp/faker": "^1.23", - "phpstan/phpstan": "^1.12" + "phpstan/phpstan": "^1.12", + "ext-posix": "*" }, "require": { "ext-json": "*", diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/CaptionContract.php similarity index 66% rename from src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php rename to src/Contracts/Lexicons/App/Bsky/Embed/CaptionContract.php index ed45198..27e806a 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/CaptionContract.php @@ -3,10 +3,10 @@ namespace Atproto\Contracts\Lexicons\App\Bsky\Embed; use Atproto\Contracts\Stringable; -use Atproto\Lexicons\App\Bsky\Embed\Blob; +use Atproto\DataModel\Blob\Blob; use JsonSerializable; -interface CaptionInterface extends JsonSerializable, Stringable +interface CaptionContract extends JsonSerializable, Stringable { public function lang(string $lang = null); public function file(Blob $file = null); diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/MediaInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/MediaContract.php similarity index 70% rename from src/Contracts/Lexicons/App/Bsky/Embed/MediaInterface.php rename to src/Contracts/Lexicons/App/Bsky/Embed/MediaContract.php index 6fd2101..98496b9 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/MediaInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/MediaContract.php @@ -2,7 +2,6 @@ namespace Atproto\Contracts\Lexicons\App\Bsky\Embed; -interface MediaInterface +interface MediaContract { - -} \ No newline at end of file +} diff --git a/src/Support/File.php b/src/Support/FileSupport.php similarity index 96% rename from src/Support/File.php rename to src/Support/FileSupport.php index 641b6d7..c0bfff3 100644 --- a/src/Support/File.php +++ b/src/Support/FileSupport.php @@ -7,7 +7,7 @@ * * Helper class for file-related operations. */ -class File +class FileSupport { /** @var string $file_path The path to the file. */ private string $file_path; @@ -94,6 +94,11 @@ public function isFile() return is_file($this->file_path); } + public function isReadable(): bool + { + return is_readable($this->file_path); + } + /** * Check if the path is a directory. * diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php b/tests/Unit/Lexicons/App/Bsky/Embed/FileMock.php similarity index 63% rename from tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php rename to tests/Unit/Lexicons/App/Bsky/Embed/FileMock.php index b1ba4cf..850d87f 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/FileMock.php @@ -2,14 +2,13 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed; -use Atproto\Lexicons\App\Bsky\Embed\Blob; +use Atproto\DataModel\Blob\Blob; use PHPUnit\Framework\MockObject\MockObject; -trait FileMocking +trait FileMock { private int $size = 2000; private string $path = 'path'; - private string $blob = 'blob'; private string $type = 'text/vtt'; /** @@ -22,21 +21,13 @@ private function createMockFile() ->getMock(); $file->expects($this->any()) - ->method('path') - ->will($this->returnCallback(fn () => $this->path)); - - $file->expects($this->any()) - ->method('type') + ->method('mimeType') ->will($this->returnCallback(fn () => $this->type)); $file->expects($this->any()) ->method('size') ->will($this->returnCallback(fn () => $this->size)); - $file->expects($this->any()) - ->method('blob') - ->will($this->returnCallback(fn () => $this->blob)); - return $file; } } From 872ee32b72eb6164955b9c4ebf55ab8dc8e94dc5 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 27 Oct 2024 01:20:47 +0400 Subject: [PATCH 30/59] feat: implement IPFS content identifier system - Add CID (Content Identifier) implementation - Add MultiCodec and MultiHash support - Add enum support for type safe constants --- src/IPFS/CID/CID.php | 58 +++++++++++++++++++++++++++++++++ src/IPFS/CID/CIDVersion.php | 15 +++++++++ src/IPFS/CID/Versions/CIDV1.php | 44 +++++++++++++++++++++++++ src/MultiFormats/MultiCodec.php | 17 ++++++++++ src/MultiFormats/MultiHash.php | 26 +++++++++++++++ src/Support/Enum.php | 47 ++++++++++++++++++++++++++ 6 files changed, 207 insertions(+) create mode 100644 src/IPFS/CID/CID.php create mode 100644 src/IPFS/CID/CIDVersion.php create mode 100644 src/IPFS/CID/Versions/CIDV1.php create mode 100644 src/MultiFormats/MultiCodec.php create mode 100644 src/MultiFormats/MultiHash.php create mode 100644 src/Support/Enum.php diff --git a/src/IPFS/CID/CID.php b/src/IPFS/CID/CID.php new file mode 100644 index 0000000..398223e --- /dev/null +++ b/src/IPFS/CID/CID.php @@ -0,0 +1,58 @@ +encoder = $encoderMultiBase->value; + $this->typeMultiCodec = $typeMultiCodec; + $this->target = $target; + $this->version = new CIDV1(); + $this->version->setCid($this); + } + + public function target(): string + { + return $this->target; + } + + public function typeMultiCodec(): MultiCodec + { + return $this->typeMultiCodec; + } + + public function version(CIDVersion $version = null) + { + if (is_null($version)) { + return $this->version; + } + + $this->version = $version; + $this->version->setCid($this); + + return $this; + } + + public function generate(): string + { + return $this->version->generate(); + } + + public function __toString(): string + { + return $this->encoder->encode($this->generate()); + } +} diff --git a/src/IPFS/CID/CIDVersion.php b/src/IPFS/CID/CIDVersion.php new file mode 100644 index 0000000..194842c --- /dev/null +++ b/src/IPFS/CID/CIDVersion.php @@ -0,0 +1,15 @@ +cid = $cid; + } + + abstract public function generate(): string; +} diff --git a/src/IPFS/CID/Versions/CIDV1.php b/src/IPFS/CID/Versions/CIDV1.php new file mode 100644 index 0000000..ff11db5 --- /dev/null +++ b/src/IPFS/CID/Versions/CIDV1.php @@ -0,0 +1,44 @@ +versionMultiCodecBin(); + $type = $this->typeMultiCodecBin(); + $contentMultiHash = $this->contentMultiHash(); + + return sprintf( + "%s%s%s", + $version, + $type, + $contentMultiHash + ); + } + + private function versionMultiCodecBin(): string + { + return hex2bin($this->versionMultiCodec()->value); + } + + private function typeMultiCodecBin(): string + { + return hex2bin($this->cid->typeMultiCodec()->value); + } + + private function contentMultiHash(): string + { + return MultiHash::generate('sha2-256', $this->cid->target()); + } + + private function versionMultiCodec(): MultiCodec + { + return MultiCodec::get('cidv1'); + } +} diff --git a/src/MultiFormats/MultiCodec.php b/src/MultiFormats/MultiCodec.php new file mode 100644 index 0000000..ae68c02 --- /dev/null +++ b/src/MultiFormats/MultiCodec.php @@ -0,0 +1,17 @@ + '01', + 'sha2-256' => '12', + 'raw' => '55', + ]; +} diff --git a/src/MultiFormats/MultiHash.php b/src/MultiFormats/MultiHash.php new file mode 100644 index 0000000..1fd68be --- /dev/null +++ b/src/MultiFormats/MultiHash.php @@ -0,0 +1,26 @@ + 'sha256', + ]; + + public static function generate(string $hashMulticodecName, string $content): string + { + $hash = hash( + self::MULTICODEC_NAME__HASH_ALGO[MultiCodec::get($hashMulticodecName)->name], + $content, + true + ); + + return sprintf( + "%s%s%s", + encode_varint(intval(MultiCodec::get($hashMulticodecName)->value, 16)), + encode_varint(strlen($hash)), + $hash + ); + } +} diff --git a/src/Support/Enum.php b/src/Support/Enum.php new file mode 100644 index 0000000..d9deec1 --- /dev/null +++ b/src/Support/Enum.php @@ -0,0 +1,47 @@ +name = $name; + $this->value = $value; + } + + private static function instance($name, $value): self + { + $static = static::class; + $instance = null; + + eval("\$instance = new class(\$name, \$value) extends $static {};"); + + return $instance; + } + + /** + * @throws InvalidArgumentException + */ + public static function get(string $name): self + { + self::validate($name); + + return self::instance($name, self::CONSTANTS[$name]); + } + + /** + * @throws InvalidArgumentException + */ + private static function validate(string $name): void + { + if (! isset(self::CONSTANTS[$name])) { + throw new InvalidArgumentException("'$name' is not implemented"); + } + } +} From 885ab9f52775ad1239fb04872df13acc92f09c6b Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 27 Oct 2024 01:21:36 +0400 Subject: [PATCH 31/59] feat: add base32 encoding system - Implement base32 encoding/decoding - Add encoder contract and support traits - Update helper functions --- src/Contracts/EncoderContract.php | 10 +++ .../MultiBase/Encoders/Base32Encoder.php | 65 +++++++++++++++++++ .../MultiBase/Encoders/MultiBaseSupport.php | 22 +++++++ src/MultiFormats/MultiBase/MultiBase.php | 29 +++++++++ src/helpers.php | 21 ------ 5 files changed, 126 insertions(+), 21 deletions(-) create mode 100644 src/Contracts/EncoderContract.php create mode 100644 src/MultiFormats/MultiBase/Encoders/Base32Encoder.php create mode 100644 src/MultiFormats/MultiBase/Encoders/MultiBaseSupport.php create mode 100644 src/MultiFormats/MultiBase/MultiBase.php diff --git a/src/Contracts/EncoderContract.php b/src/Contracts/EncoderContract.php new file mode 100644 index 0000000..8a78003 --- /dev/null +++ b/src/Contracts/EncoderContract.php @@ -0,0 +1,10 @@ +prefixed($encoded); + } + + /** + * @throws InvalidArgumentException + */ + public function decode($data): string + { + $data = $this->unprefixed($data); + + $alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; + $binaryString = ''; + $decoded = ''; + + $data = rtrim($data, '='); + + foreach (str_split($data) as $char) { + $position = strpos($alphabet, $char); + if ($position === false) { + throw new InvalidArgumentException("Invalid character found in Base32 string."); + } + $binaryString .= str_pad(decbin($position), 5, '0', STR_PAD_LEFT); + } + + $chunks = str_split($binaryString, 8); + foreach ($chunks as $chunk) { + if (strlen($chunk) === 8) { + $decoded .= chr(bindec($chunk)); + } + } + + return $decoded; + } + + public function prefix(): string + { + return 'b'; + } +} diff --git a/src/MultiFormats/MultiBase/Encoders/MultiBaseSupport.php b/src/MultiFormats/MultiBase/Encoders/MultiBaseSupport.php new file mode 100644 index 0000000..c61b2a9 --- /dev/null +++ b/src/MultiFormats/MultiBase/Encoders/MultiBaseSupport.php @@ -0,0 +1,22 @@ +prefix())) { + $offset = $pos + strlen($this->prefix()); + + return substr($data, $offset); + } + + return $data; + } + + private function prefixed(string $data): string + { + return $this->prefix() . $data; + } +} diff --git a/src/MultiFormats/MultiBase/MultiBase.php b/src/MultiFormats/MultiBase/MultiBase.php new file mode 100644 index 0000000..41f6ef8 --- /dev/null +++ b/src/MultiFormats/MultiBase/MultiBase.php @@ -0,0 +1,29 @@ + Base32Encoder::class, + ]; + + public static function get(string $name) + { + self::validate($name); + + $encoder = self::CONSTANTS[$name]; + $instance = new $encoder(); + + return self::instance($name, $instance); + } +} diff --git a/src/helpers.php b/src/helpers.php index d20aadd..21fec38 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -57,24 +57,3 @@ function encode_varint(int $int): string return $encoded; } } - -if (! function_exists('decode_varint')) { - function decode_varint(string $data): int - { - $number = 0; - $shift = 0; - - foreach (str_split($data) as $char) { - $byte = ord($char); - $number |= ($byte & 0x7F) << $shift; - - if (($byte & 0x80) === 0) { - break; - } - - $shift += 7; - } - - return $number; - } -} From 7c8a0dbf09957287070fb40686f79fe8e6b74efc Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 27 Oct 2024 01:22:27 +0400 Subject: [PATCH 32/59] refactor blob implemention by atproto specification - Add blob contracts and handlers - Implement binary and file handlers - Revert old blob implemention --- src/Contracts/DataModel/BlobContract.php | 16 ++++++ src/Contracts/DataModel/BlobHandler.php | 10 ++++ src/DataModel/Blob/BinaryBlobHandler.php | 44 +++++++++++++++ src/DataModel/Blob/Blob.php | 68 ++++++++++++++++++++++++ src/DataModel/Blob/FileBlobHandler.php | 44 +++++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 src/Contracts/DataModel/BlobContract.php create mode 100644 src/Contracts/DataModel/BlobHandler.php create mode 100644 src/DataModel/Blob/BinaryBlobHandler.php create mode 100644 src/DataModel/Blob/Blob.php create mode 100644 src/DataModel/Blob/FileBlobHandler.php diff --git a/src/Contracts/DataModel/BlobContract.php b/src/Contracts/DataModel/BlobContract.php new file mode 100644 index 0000000..e0f9afe --- /dev/null +++ b/src/Contracts/DataModel/BlobContract.php @@ -0,0 +1,16 @@ +isBinary($binary)) { + throw new InvalidArgumentException('$binary must be a binary'); + } + + $this->binary = $binary; + } + + private function isBinary(string $binary): bool + { + return ! mb_check_encoding($binary, 'UTF-8'); + } + + public function size(): int + { + return strlen($this->binary); + } + + public function mimeType(): string + { + return (new finfo(FILEINFO_MIME_TYPE))->buffer($this->binary); + } + + public function content(): string + { + return $this->binary; + } +} diff --git a/src/DataModel/Blob/Blob.php b/src/DataModel/Blob/Blob.php new file mode 100644 index 0000000..26fca09 --- /dev/null +++ b/src/DataModel/Blob/Blob.php @@ -0,0 +1,68 @@ +handler = $handler; + $this->cid = new CID( + MultiCodec::get('raw'), + MultiBase::get('base32'), + $handler->content() + ); + } + + public static function viaBinary(string $binary): BlobContract + { + return new self(new BinaryBlobHandler($binary)); + } + + public static function viaFile(FileSupport $file): BlobContract + { + return new self(new FileBlobHandler($file)); + } + + public function size(): int + { + return $this->handler->size(); + } + + public function mimeType(): string + { + return $this->handler->mimeType(); + } + + public function __toString(): string + { + return json_encode($this); + } + + public function link(): string + { + return $this->cid->__toString(); + } + + public function jsonSerialize(): array + { + return [ + '$type' => 'blob', + 'ref' => [ + '$link' => $this->link() + ], + 'mimeType' => $this->mimeType(), + 'size' => $this->size(), + ]; + } +} diff --git a/src/DataModel/Blob/FileBlobHandler.php b/src/DataModel/Blob/FileBlobHandler.php new file mode 100644 index 0000000..da04d2d --- /dev/null +++ b/src/DataModel/Blob/FileBlobHandler.php @@ -0,0 +1,44 @@ +exists()) { + throw new InvalidArgumentException('$file is not exists'); + } + + if (! $file->isFile()) { + throw new InvalidArgumentException('$file is not a file'); + } + + if (! $file->isReadable()) { + throw new InvalidArgumentException('$file is not readable'); + } + + $this->file = $file; + } + + public function size(): int + { + return $this->file->getFileSize(); + } + + public function mimeType(): string + { + return $this->file->getMimeType(); + } + + public function content(): string + { + return $this->file->getBlob(); + } +} From de63116a2149da88269a4d5f9ee9c8e3f8e75ab2 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 27 Oct 2024 01:25:39 +0400 Subject: [PATCH 33/59] refactor: update embed system for new blob implementation - Migrate to new blob system - Improve error messages - Fix type hints and serialization --- src/Lexicons/App/Bsky/Embed/Blob.php | 35 ------------------- src/Lexicons/App/Bsky/Embed/Caption.php | 14 ++++---- .../Embed/Collections/CaptionCollection.php | 4 +-- .../Embed/Collections/ImageCollection.php | 4 +-- src/Lexicons/App/Bsky/Embed/External.php | 17 +++++---- src/Lexicons/App/Bsky/Embed/Image.php | 5 +-- .../App/Bsky/Embed/RecordWithMedia.php | 8 ++--- src/Lexicons/App/Bsky/Embed/Video.php | 9 ++--- 8 files changed, 31 insertions(+), 65 deletions(-) delete mode 100644 src/Lexicons/App/Bsky/Embed/Blob.php diff --git a/src/Lexicons/App/Bsky/Embed/Blob.php b/src/Lexicons/App/Bsky/Embed/Blob.php deleted file mode 100644 index d6c4421..0000000 --- a/src/Lexicons/App/Bsky/Embed/Blob.php +++ /dev/null @@ -1,35 +0,0 @@ -path); - } - - public function type(): string - { - return mime_content_type($this->path); - } - - public function blob(): string - { - return file_get_contents($this->path); - } - - public function path(): string - { - return $this->path; - } - - public function __toString(): string - { - return $this->blob(); - } -} diff --git a/src/Lexicons/App/Bsky/Embed/Caption.php b/src/Lexicons/App/Bsky/Embed/Caption.php index 816ab91..b67fc19 100644 --- a/src/Lexicons/App/Bsky/Embed/Caption.php +++ b/src/Lexicons/App/Bsky/Embed/Caption.php @@ -2,11 +2,11 @@ namespace Atproto\Lexicons\App\Bsky\Embed; -use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionInterface; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionContract; +use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; -use JsonSerializable; -class Caption implements CaptionInterface +class Caption implements CaptionContract { private const MAX_SIZE = 20000; @@ -43,11 +43,11 @@ public function file(Blob $file = null) } if ($file->size() > self::MAX_SIZE) { - throw new InvalidArgumentException($file->path().' is too large. Max size: '.self::MAX_SIZE); + throw new InvalidArgumentException('$file is too large. Max size: '.self::MAX_SIZE); } - if ($file->type() !== 'text/vtt') { - throw new InvalidArgumentException($file->path().' is not a text/vtt file.'); + if ($file->mimeType() !== 'text/vtt') { + throw new InvalidArgumentException('$file is not a text/vtt file.'); } $this->file = $file; @@ -62,7 +62,7 @@ public function jsonSerialize(): array { return [ 'lang' => $this->lang(), - 'file' => $this->file()->blob(), + 'file' => $this->file(), ]; } diff --git a/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php index 6c22ad9..68487a5 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php @@ -2,7 +2,7 @@ namespace Atproto\Lexicons\App\Bsky\Embed\Collections; -use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionInterface; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionContract; use Atproto\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; use JsonSerializable; @@ -14,7 +14,7 @@ class CaptionCollection extends GenericCollection implements JsonSerializable protected function validator(): \Closure { - return function (CaptionInterface $caption) { + return function (CaptionContract $caption) { if ($this->count() > self::MAX_SIZE) { throw new InvalidArgumentException(self::class.' collection exceeds maximum size: ' .self::MAX_SIZE); } diff --git a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php index 57c641e..c55fdd3 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php @@ -4,11 +4,11 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\ImageInterface; -use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaInterface; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaContract; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; -class ImageCollection extends GenericCollection implements EmbedInterface, MediaInterface +class ImageCollection extends GenericCollection implements EmbedInterface, MediaContract { use EmbedCollection; diff --git a/src/Lexicons/App/Bsky/Embed/External.php b/src/Lexicons/App/Bsky/Embed/External.php index 8c380ed..2272f8c 100644 --- a/src/Lexicons/App/Bsky/Embed/External.php +++ b/src/Lexicons/App/Bsky/Embed/External.php @@ -3,10 +3,11 @@ namespace Atproto\Lexicons\App\Bsky\Embed; use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; -use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaInterface; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaContract; +use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; -class External implements EmbedInterface, MediaInterface +class External implements EmbedInterface, MediaContract { private string $uri; private string $title; @@ -69,18 +70,16 @@ public function thumb(Blob $blob = null) return $this->blob; } - if (! str_starts_with($blob->type(), 'image/*')) { + if (! str_starts_with($blob->mimeType(), 'image/*')) { throw new InvalidArgumentException(sprintf( - "'%s' is not a valid image type: %s", - $blob->path(), - $blob->type() + "\$blob is not a valid image type: %s", + $blob->mimeType() )); } if (1000000 < $blob->size()) { throw new InvalidArgumentException(sprintf( - "'%s' size is too big than maximum allowed: %d", - $blob->path(), + "\$blob size is too big than maximum allowed: %d", $blob->size() )); } @@ -96,7 +95,7 @@ public function jsonSerialize(): array 'uri' => $this->uri, 'title' => $this->title, 'description' => $this->description, - 'blob' => ($b = $this->blob) ? $b->blob() : null, + 'blob' => ($b = $this->blob) ? $b : null, ]); } diff --git a/src/Lexicons/App/Bsky/Embed/Image.php b/src/Lexicons/App/Bsky/Embed/Image.php index 894ba2c..c2c1ae8 100644 --- a/src/Lexicons/App/Bsky/Embed/Image.php +++ b/src/Lexicons/App/Bsky/Embed/Image.php @@ -3,6 +3,7 @@ namespace Atproto\Lexicons\App\Bsky\Embed; use Atproto\Contracts\Lexicons\App\Bsky\Embed\ImageInterface; +use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; class Image implements ImageInterface @@ -16,7 +17,7 @@ class Image implements ImageInterface */ public function __construct(Blob $file, string $alt) { - if (true !== str_starts_with($file->type(), 'image/')) { + if (true !== str_starts_with($file->mimeType(), 'image/')) { throw new InvalidArgumentException($file->path()." is not a valid image file."); } @@ -63,7 +64,7 @@ public function jsonSerialize(): array { return array_filter([ 'alt' => $this->alt(), - 'image' => $this->file->blob(), + 'image' => $this->file, 'aspectRatio' => $this->aspectRatio() ?: null, ]); } diff --git a/src/Lexicons/App/Bsky/Embed/RecordWithMedia.php b/src/Lexicons/App/Bsky/Embed/RecordWithMedia.php index d45ad86..61e260d 100644 --- a/src/Lexicons/App/Bsky/Embed/RecordWithMedia.php +++ b/src/Lexicons/App/Bsky/Embed/RecordWithMedia.php @@ -2,20 +2,20 @@ namespace Atproto\Lexicons\App\Bsky\Embed; -use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaInterface; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaContract; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; class RecordWithMedia extends Record { - private MediaInterface $media; + private MediaContract $media; - public function __construct(StrongRef $ref, MediaInterface $media) + public function __construct(StrongRef $ref, MediaContract $media) { parent::__construct($ref); $this->media($media); } - public function media(MediaInterface $media = null) + public function media(MediaContract $media = null) { if (is_null($media)) { return $this->media; diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php index 2b799c8..066a5b2 100644 --- a/src/Lexicons/App/Bsky/Embed/Video.php +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -2,12 +2,13 @@ namespace Atproto\Lexicons\App\Bsky\Embed; -use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaInterface; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaContract; use Atproto\Contracts\Lexicons\App\Bsky\Embed\VideoInterface; +use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; -class Video implements VideoInterface, MediaInterface +class Video implements VideoInterface, MediaContract { private Blob $file; private ?string $alt = null; @@ -19,7 +20,7 @@ class Video implements VideoInterface, MediaInterface */ public function __construct(Blob $file) { - if ("video/mp4" !== $file->type()) { + if ("video/mp4" !== $file->mimeType()) { throw new InvalidArgumentException($file->path()." is not a valid video file."); } @@ -35,7 +36,7 @@ public function jsonSerialize(): array { $result = array_filter([ 'alt' => $this->alt() ?: null, - 'video' => $this->file->blob(), + 'video' => $this->file, 'aspectRatio' => $this->aspectRatio() ?: null, 'captions' => $this->captions()->toArray() ?: null, ]); From de6e0897ae42fb0c41a48bac8a04198292d7535c Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 27 Oct 2024 01:27:25 +0400 Subject: [PATCH 34/59] test: comprehensive test coverage update - Add tests for new blob and CID systems - Update embed system tests - Clean up test organization and assertions - Improve mock implementations --- src/API/Com/Atrproto/Repo/UploadBlob.php | 8 +- .../Com/Atproto/Repo/CreateRecord.php | 2 +- tests/Unit/DataModel/Blob/BlobTest.php | 143 ++++++++++++++++++ tests/Unit/IPFS/CID/CIDTest.php | 57 +++++++ .../Lexicons/App/Bsky/Embed/CaptionTest.php | 8 +- .../Collections/CaptionCollectionTest.php | 4 +- .../Lexicons/App/Bsky/Embed/ExternalTest.php | 10 +- .../Unit/Lexicons/App/Bsky/Embed/FileTest.php | 94 +++++------- .../Lexicons/App/Bsky/Embed/ImageTest.php | 8 +- .../App/Bsky/Embed/RecordWithMediaTest.php | 8 +- .../Lexicons/App/Bsky/Embed/VideoTest.php | 19 +-- .../Unit/Lexicons/App/Bsky/Feed/PostTest.php | 2 - .../App/Bsky/Actor/GetProfileResourceTest.php | 8 +- .../Resources/Assets/AssociatedAssetTest.php | 2 +- tests/Unit/Resources/Assets/BaseAssetTest.php | 2 +- tests/Unit/Resources/Assets/ChatAssetTest.php | 1 - .../Resources/Assets/CreatorAssetTest.php | 2 - tests/Unit/Resources/Assets/UserAssetTest.php | 4 +- .../Unit/Resources/Assets/ViewerAssetTest.php | 1 - 19 files changed, 274 insertions(+), 109 deletions(-) create mode 100644 tests/Unit/DataModel/Blob/BlobTest.php create mode 100644 tests/Unit/IPFS/CID/CIDTest.php diff --git a/src/API/Com/Atrproto/Repo/UploadBlob.php b/src/API/Com/Atrproto/Repo/UploadBlob.php index a81d934..4c8095f 100644 --- a/src/API/Com/Atrproto/Repo/UploadBlob.php +++ b/src/API/Com/Atrproto/Repo/UploadBlob.php @@ -4,7 +4,7 @@ use Atproto\Contracts\HTTP\RequestContract; use Atproto\Exceptions\Http\Request\RequestBodyHasMissingRequiredFields; -use Atproto\Support\File; +use Atproto\Support\FileSupport; use Atproto\Resources\Com\Atproto\Repo\UploadBlobResource; use InvalidArgumentException; @@ -20,7 +20,7 @@ class UploadBlob implements RequestContract /** @var object $body The request body */ private $body; - /** @var File The blob content. */ + /** @var FileSupport The blob content. */ private $blob; /** @var array The headers for the request. */ @@ -53,7 +53,7 @@ public function __construct() */ public function setBlob($filePath) { - $file = new File($filePath); + $file = new FileSupport($filePath); if (! $file->exists()) { throw new InvalidArgumentException("File '$filePath' does not exist"); @@ -76,7 +76,7 @@ public function setBlob($filePath) /** * Get the blob content. * - * @return ?File The blob content. + * @return ?FileSupport The blob content. */ public function getBlob() { diff --git a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php index 076a686..6dfd364 100644 --- a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php +++ b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php @@ -111,7 +111,7 @@ public function __toString(): string return json_encode($this); } - public function jsonSerialize() + public function jsonSerialize(): array { return array_filter([ 'repo' => $this->repo(), diff --git a/tests/Unit/DataModel/Blob/BlobTest.php b/tests/Unit/DataModel/Blob/BlobTest.php new file mode 100644 index 0000000..22a4656 --- /dev/null +++ b/tests/Unit/DataModel/Blob/BlobTest.php @@ -0,0 +1,143 @@ +markTestSkipped("Skip checking temporary files: tests are running by root"); + } + + $this->assertTrue(is_dir(self::$tmpDirPath)); + $this->assertTrue(is_file(self::$tmpFilePath)); + $this->assertIsReadable(self::$tmpFilePath); + } + + public static function tearDownAfterClass(): void + { + rmdir(self::$tmpDirPath); + + foreach(scandir(sys_get_temp_dir()) as $file) { + if (strpos($file, self::FILE_PREFIX) === 0) { + unlink(sys_get_temp_dir().DIRECTORY_SEPARATOR.$file); + } + } + } + + public function testBlobConstructorThrowsExceptionWhenPassedInvalidBinary(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$binary must be a binary'); + + Blob::viaBinary('invalid binary'); + } + + public function testSizeReturnsSizeOfBinary(): void + { + $binary = random_bytes(1024); + + $blob = Blob::viaBinary($binary); + + $actual = $blob->size(); + $expected = 1024; + + $this->assertSame($expected, $actual); + } + + public function testMimeTypeReturnsMimeTypeOfBinary(): void + { + $binary = random_bytes(1024); + + $blob = Blob::viaBinary($binary); + + $actual = $blob->mimeType(); + $expected = (new finfo(FILEINFO_MIME_TYPE))->buffer($binary); + + $this->assertSame($expected, $actual); + } + + public function testViaFileConstructorWorkingCorrectly(): void + { + $file = new FileSupport(self::$tmpFilePath); + $blob = Blob::viaFile($file); + + $this->assertSame($blob->size(), $file->getFileSize()); + $this->assertSame($blob->mimeType(), $file->getMimeType()); + $this->assertSame( + $blob->link(), + (new CID(MultiCodec::get('raw'), MultiBase::get('base32'), $file->getBlob()))->__toString() + ); + } + + /** @dataProvider expectedSerializations */ + public function testJsonSerialize(BlobContract $blob, array $expectedSerialization): void + { + $this->assertSame($expectedSerialization, json_decode($blob, true)); + } + + public function expectedSerializations(): array + { + $content = 'content'; + + $file = tempnam(sys_get_temp_dir(), self::FILE_PREFIX); + + $this->assertIsReadable($file); + file_put_contents($file, $content); + + $file = new FileSupport($file); + $viaFile = Blob::viaFile($file); + + $binary = random_bytes(strlen($content)); + $viaBinary = Blob::viaBinary($binary); + + return [ + [$viaFile, $this->createSchema($viaFile->link(), $file->getMimeType())], + [$viaBinary, $this->createSchema($viaBinary->link(), (new finfo(FILEINFO_MIME_TYPE))->buffer($binary))], + ]; + } + + public function createSchema(string $link, string $mimeType = null): array + { + return [ + '$type' => 'blob', + 'ref' => [ + '$link' => $link, + ], + 'mimeType' => $mimeType ?: 'application/octet-stream', + 'size' => 7, + ]; + } +} diff --git a/tests/Unit/IPFS/CID/CIDTest.php b/tests/Unit/IPFS/CID/CIDTest.php new file mode 100644 index 0000000..8f92f06 --- /dev/null +++ b/tests/Unit/IPFS/CID/CIDTest.php @@ -0,0 +1,57 @@ +generate(); + $expectedBinary = hex2bin($expectedHex); + + $actualHex = bin2hex($actualBinary); + + $this->assertSame($expectedHex, $actualHex); + + $actualLen = strlen($actualHex); + $expectedLen = strlen($expectedHex); + + $this->assertSame($expectedLen, $actualLen); + + $actualBase32 = substr($cid->__toString(), 1); + $expectedBase32 = substr((new Base32Encoder())->encode($expectedBinary), 1); + + $this->assertSame($expectedBase32, $actualBase32); + } + + public function testVersionChanging(): void + { + $mockVersion = new class () extends CIDVersion { + public function generate(): string + { + return ''; + } + }; + + $cid = new CID(MultiCodec::get('raw'), MultiBase::get('base32'), ''); + + $cid->version($mockVersion); + + $this->assertSame($mockVersion, $cid->version()); + $this->assertSame($cid, $this->getPropertyValue('cid', $mockVersion)); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/CaptionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/CaptionTest.php index 303adf9..8566e0e 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/CaptionTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/CaptionTest.php @@ -8,7 +8,7 @@ class CaptionTest extends TestCase { - use FileMocking; + use FileMock; private Caption $caption; private array $dependencies = ['lang' => 'lang']; @@ -78,7 +78,7 @@ public function testSetLang(): void public function testJsonSerialize() { $expected = [ - 'file' => $this->blob, + 'file' => $this->createMockFile()->jsonSerialize(), 'lang' => $this->dependencies['lang'], ]; @@ -93,7 +93,7 @@ public function test__constructThrowsInvalidArgumentWhenPassedInvalidFileType(): $this->type = 'image/png'; $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($this->dependencies['file']->path()." is not a text/vtt file."); + $this->expectExceptionMessage('$file is not a text/vtt file.'); $this->createCaption(); } @@ -103,7 +103,7 @@ public function test__constructorThrowsExceptionWhePassedUnacceptableSizedFile() $this->size = 20001; $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($this->dependencies['file']->path()." is too large. Max size: 20000"); + $this->expectExceptionMessage('$file is too large. Max size: 20000'); $this->createCaption(); } diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php index fbd248f..ba836dd 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php @@ -2,7 +2,7 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed\Collections; -use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionInterface; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionContract; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; use PHPUnit\Framework\TestCase; @@ -11,6 +11,6 @@ class CaptionCollectionTest extends TestCase use EmbedCollectionTest; private string $target = CaptionCollection::class; - private string $dependency = CaptionInterface::class; + private string $dependency = CaptionContract::class; private int $maxLength = 20; } diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php index 0939e22..3574916 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php @@ -2,8 +2,8 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed; +use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; -use Atproto\Lexicons\App\Bsky\Embed\Blob; use Atproto\Lexicons\App\Bsky\Embed\External; use PHPUnit\Framework\TestCase; @@ -28,12 +28,8 @@ public function setUp(): void ->will($this->returnCallback(fn () => $this->maximumAllowedBlobSize)); $this->blob->expects($this->any()) - ->method('type') + ->method('mimeType') ->will($this->returnCallback(fn () => $this->allowedMimes)); - - $this->blob->expects($this->any()) - ->method('blob') - ->willReturn('blob'); } public function testDescription() @@ -129,7 +125,7 @@ public function testJsonSerializeWithSetBlob(): void 'uri' => 'https://shahmal1yev.dev', 'title' => 'foo', 'description' => 'bar', - 'blob' => 'blob', + 'blob' => $this->blob->jsonSerialize(), ]; $this->assertSame($expected, json_decode($this->external, true)); diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/FileTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/FileTest.php index b95b503..e835e1a 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/FileTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/FileTest.php @@ -2,97 +2,81 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed; +use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; -use Atproto\Lexicons\App\Bsky\Embed\Blob; +use Atproto\Support\FileSupport; use PHPUnit\Framework\TestCase; class FileTest extends TestCase { private string $testFilePath; - private string $unreadableFilePath; - private string $nonFilePath; + private string $testDirPath; private Blob $fileInstance; + private const FILE_PREFIX = '__FileTest__'; - /** - * @throws InvalidArgumentException - */ protected function setUp(): void { - parent::setUp(); + // Create a temporary test file + $this->testFilePath = tempnam(sys_get_temp_dir(), self::FILE_PREFIX); + file_put_contents($this->testFilePath, 'test content'); - $this->testFilePath = tempnam(sys_get_temp_dir(), 'testfile'); - file_put_contents($this->testFilePath, 'This is a test file.'); - $this->fileInstance = new Blob($this->testFilePath); + // Create a temporary test directory + $this->testDirPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . self::FILE_PREFIX . uniqid(); + mkdir($this->testDirPath); - $this->unreadableFilePath = tempnam(sys_get_temp_dir(), 'unreadable'); - file_put_contents($this->unreadableFilePath, 'This is an unreadable file.'); - chmod($this->unreadableFilePath, 0000); - - $this->nonFilePath = sys_get_temp_dir() . '/nonFile' . uniqid(); - mkdir($this->nonFilePath, 0777, true); + // Create file instance using the factory method + $fileSupport = new FileSupport($this->testFilePath); + $this->fileInstance = Blob::viaFile($fileSupport); } protected function tearDown(): void { - parent::tearDown(); - + // Clean up test files if (file_exists($this->testFilePath)) { unlink($this->testFilePath); } - - if (file_exists($this->unreadableFilePath)) { - chmod($this->unreadableFilePath, 0644); - unlink($this->unreadableFilePath); - } - - if (is_dir($this->nonFilePath)) { - rmdir($this->nonFilePath); + if (is_dir($this->testDirPath)) { + rmdir($this->testDirPath); } } - public function testFileSize(): void + public function testConstructorThrowsExceptionForNonExistentFile(): void { - $expectedSize = filesize($this->testFilePath); - $this->assertEquals($expectedSize, $this->fileInstance->size()); - } + $this->expectException(InvalidArgumentException::class); - public function testMimeType(): void - { - $expectedType = mime_content_type($this->testFilePath); - $this->assertEquals($expectedType, $this->fileInstance->type()); + $nonExistentFile = new FileSupport('/path/to/nonexistent/file'); + Blob::viaFile($nonExistentFile); } - public function testFileBlob(): void + public function testConstructorThrowsExceptionForDirectory(): void { - $expectedContent = file_get_contents($this->testFilePath); - $this->assertEquals($expectedContent, $this->fileInstance->blob()); + $this->expectException(InvalidArgumentException::class); + + $directory = new FileSupport($this->testDirPath); + Blob::viaFile($directory); } - public function testToStringMethod(): void + public function testSize(): void { - $expectedContent = file_get_contents($this->testFilePath); - $this->assertEquals($expectedContent, (string) $this->fileInstance); + $expectedSize = filesize($this->testFilePath); + $this->assertEquals($expectedSize, $this->fileInstance->size()); } - public function testConstructorThrowsExceptionWhenPassedUnreadableFilePath(): void + public function testMimeType(): void { - if (function_exists('posix_geteuid') && posix_geteuid() === 0) { - $this->markTestSkipped('Test skipped because it is running as root.'); - } - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("$this->unreadableFilePath is not readable."); - - $this->assertFalse(is_readable($this->unreadableFilePath), 'File should not be readable.'); - - new Blob($this->unreadableFilePath); + $expectedType = mime_content_type($this->testFilePath); + $this->assertEquals($expectedType, $this->fileInstance->mimeType()); } - public function testConstructorThrowsExceptionWhenPassedNonFilePath(): void + public function testJsonSerialize(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("$this->nonFilePath is not a file."); + $serialized = json_decode(json_encode($this->fileInstance), true); + + $this->assertArrayHasKey('$type', $serialized); + $this->assertArrayHasKey('ref', $serialized); + $this->assertArrayHasKey('mimeType', $serialized); + $this->assertArrayHasKey('size', $serialized); - new Blob($this->nonFilePath); + $this->assertEquals('blob', $serialized['$type']); } } diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php index 6f2a78c..2eb816d 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php @@ -10,7 +10,7 @@ class ImageTest extends TestCase { - use FileMocking; + use FileMock; use Reflection; private Image $image; @@ -113,12 +113,12 @@ public function testJsonSerialize() { $expected = [ 'alt' => $this->dependencies['alt'], - 'image' => $this->dependencies['file']->blob(), + 'image' => $this->dependencies['file'], ]; $image = $this->createImage(); - $this->assertSame($expected, json_decode($image, true)); + $this->assertSame($expected, $image->jsonSerialize()); $aspectRatio = $this->randAspectRatio(); @@ -126,7 +126,7 @@ public function testJsonSerialize() $image->aspectRatio(...array_values($aspectRatio)); - $this->assertSame($expected, json_decode($image, true)); + $this->assertSame($expected, $image->jsonSerialize()); } /** diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php index 870b73b..c422d40 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php @@ -2,26 +2,26 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed; -use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaInterface; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaContract; use Atproto\Lexicons\App\Bsky\Embed\RecordWithMedia; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use PHPUnit\Framework\TestCase; class RecordWithMediaTest extends TestCase { - private MediaInterface $media; + private MediaContract $media; private RecordWithMedia $recordWithMedia; protected function setUp(): void { - $this->media = $this->createMock(MediaInterface::class); + $this->media = $this->createMock(MediaContract::class); $this->recordWithMedia = new RecordWithMedia($this->createMock(StrongRef::class), $this->media); } public function testMedia() { $this->assertSame($this->media, $this->recordWithMedia->media()); - $this->recordWithMedia->media($expected = $this->createMock(MediaInterface::class)); + $this->recordWithMedia->media($expected = $this->createMock(MediaContract::class)); $this->assertSame($expected, $this->recordWithMedia->media()); } diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php index e69051c..d5336c9 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php @@ -2,18 +2,17 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed; -use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionInterface; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionContract; +use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; -use Atproto\Lexicons\App\Bsky\Embed\Caption; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; -use Atproto\Lexicons\App\Bsky\Embed\Blob; use Atproto\Lexicons\App\Bsky\Embed\Video; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class VideoTest extends TestCase { - use FileMocking; + use FileMock; private Video $video; private Blob $file; @@ -69,15 +68,13 @@ public function testAspectRatio(): void public function testJsonSerializeReturnsCorrectSchema(): void { - $this->assertSame($this->file->blob(), $this->blob); - $expected = [ - 'video' => $this->file->blob(), + 'video' => $this->file, ]; $target = new Video($this->file); - $this->assertSame($expected, json_decode($target, true)); + $this->assertSame($expected, $target->jsonSerialize()); $captions = $this->createCaptionsMock(); $aspectRatio = $this->randAspectRatio(); @@ -93,7 +90,7 @@ public function testJsonSerializeReturnsCorrectSchema(): void ->alt($expected['alt']) ->aspectRatio(...array_values($expected['aspectRatio'])); - $this->assertSame($expected, json_decode($target, true)); + $this->assertSame($expected, $target->jsonSerialize()); } /** @@ -109,7 +106,7 @@ private function randAspectRatio(): array */ private function createCaptionsMock(): CaptionCollection { - $caption = $this->getMockBuilder(CaptionInterface::class) + $caption = $this->getMockBuilder(CaptionContract::class) ->disableOriginalConstructor() ->getMock(); @@ -117,7 +114,7 @@ private function createCaptionsMock(): CaptionCollection ->method('jsonSerialize') ->willReturn([ 'lang' => 'lang', - 'file' => $this->file->blob(), + 'file' => $this->file, ]); $captions = $this->getMockBuilder(CaptionCollection::class) diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php index 5e40030..e430399 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php @@ -5,12 +5,10 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Feed\Post; -use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use Atproto\Lexicons\App\Bsky\RichText\Mention; use Atproto\Lexicons\Com\Atproto\Label\SelfLabels; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use Carbon\Carbon; -use DateTimeImmutable; use PHPUnit\Framework\TestCase; class PostTest extends TestCase diff --git a/tests/Unit/Resources/App/Bsky/Actor/GetProfileResourceTest.php b/tests/Unit/Resources/App/Bsky/Actor/GetProfileResourceTest.php index c56a1f7..e4054b2 100644 --- a/tests/Unit/Resources/App/Bsky/Actor/GetProfileResourceTest.php +++ b/tests/Unit/Resources/App/Bsky/Actor/GetProfileResourceTest.php @@ -2,23 +2,17 @@ namespace Tests\Unit\Resources\App\Bsky\Actor; -use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Contracts\HTTP\Resources\ResourceContract; -use Atproto\Exceptions\Resource\BadAssetCallException; use Atproto\Resources\App\Bsky\Actor\GetProfileResource; use Atproto\Resources\Assets\AssociatedAsset; use Atproto\Resources\Assets\DatetimeAsset; use Atproto\Resources\Assets\JoinedViaStarterPackAsset; -use Atproto\Resources\Assets\LabelAsset; use Atproto\Resources\Assets\ViewerAsset; -use Carbon\Carbon; -use Carbon\Exceptions\InvalidFormatException; -use GenericCollection\GenericCollection; use GenericCollection\Interfaces\GenericCollectionInterface; use PHPUnit\Framework\TestCase; -use Tests\Supports\PrimitiveAssetTest; use Tests\Supports\DateAssetTest; use Tests\Supports\NonPrimitiveAssetTest; +use Tests\Supports\PrimitiveAssetTest; class GetProfileResourceTest extends TestCase { diff --git a/tests/Unit/Resources/Assets/AssociatedAssetTest.php b/tests/Unit/Resources/Assets/AssociatedAssetTest.php index a7adcf3..c85dc7f 100644 --- a/tests/Unit/Resources/Assets/AssociatedAssetTest.php +++ b/tests/Unit/Resources/Assets/AssociatedAssetTest.php @@ -6,8 +6,8 @@ use Atproto\Resources\Assets\ChatAsset; use GenericCollection\Exceptions\InvalidArgumentException; use PHPUnit\Framework\TestCase; -use Tests\Supports\PrimitiveAssetTest; use Tests\Supports\NonPrimitiveAssetTest; +use Tests\Supports\PrimitiveAssetTest; class AssociatedAssetTest extends TestCase { diff --git a/tests/Unit/Resources/Assets/BaseAssetTest.php b/tests/Unit/Resources/Assets/BaseAssetTest.php index d0c4d22..83c05b4 100644 --- a/tests/Unit/Resources/Assets/BaseAssetTest.php +++ b/tests/Unit/Resources/Assets/BaseAssetTest.php @@ -2,10 +2,10 @@ namespace Tests\Unit\Resources\Assets; +use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Resources\Assets\BaseAsset; use GenericCollection\Exceptions\InvalidArgumentException; use PHPUnit\Framework\TestCase; -use Atproto\Contracts\HTTP\Resources\AssetContract; use Tests\Supports\AssetTest; class BaseAssetTest extends TestCase diff --git a/tests/Unit/Resources/Assets/ChatAssetTest.php b/tests/Unit/Resources/Assets/ChatAssetTest.php index 2b97e97..a5904d2 100644 --- a/tests/Unit/Resources/Assets/ChatAssetTest.php +++ b/tests/Unit/Resources/Assets/ChatAssetTest.php @@ -2,7 +2,6 @@ namespace Tests\Unit\Resources\Assets; -use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Resources\Assets\ChatAsset; use PHPUnit\Framework\TestCase; use Tests\Supports\PrimitiveAssetTest; diff --git a/tests/Unit/Resources/Assets/CreatorAssetTest.php b/tests/Unit/Resources/Assets/CreatorAssetTest.php index 27aedb2..cb937ad 100644 --- a/tests/Unit/Resources/Assets/CreatorAssetTest.php +++ b/tests/Unit/Resources/Assets/CreatorAssetTest.php @@ -2,9 +2,7 @@ namespace Tests\Unit\Resources\Assets; -use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Resources\Assets\CreatorAsset; -use Atproto\Resources\Assets\UserAsset; use PHPUnit\Framework\TestCase; use Tests\Supports\UserAssetTest; diff --git a/tests/Unit/Resources/Assets/UserAssetTest.php b/tests/Unit/Resources/Assets/UserAssetTest.php index b00f82e..5993c53 100644 --- a/tests/Unit/Resources/Assets/UserAssetTest.php +++ b/tests/Unit/Resources/Assets/UserAssetTest.php @@ -2,12 +2,12 @@ namespace Tests\Unit\Resources\Assets; -use Atproto\Resources\Assets\UserAsset; use Atproto\Resources\Assets\AssociatedAsset; use Atproto\Resources\Assets\DatetimeAsset; use Atproto\Resources\Assets\JoinedViaStarterPackAsset; -use Atproto\Resources\Assets\ViewerAsset; use Atproto\Resources\Assets\LabelsAsset; +use Atproto\Resources\Assets\UserAsset; +use Atproto\Resources\Assets\ViewerAsset; use Carbon\Carbon; use GenericCollection\Exceptions\InvalidArgumentException; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Resources/Assets/ViewerAssetTest.php b/tests/Unit/Resources/Assets/ViewerAssetTest.php index 9d5ea9a..255e52e 100644 --- a/tests/Unit/Resources/Assets/ViewerAssetTest.php +++ b/tests/Unit/Resources/Assets/ViewerAssetTest.php @@ -3,7 +3,6 @@ namespace Tests\Unit\Resources\Assets\Resources\Assets; use Atproto\Resources\Assets\BlockingByListAsset; -use Atproto\Resources\Assets\FollowersAsset; use Atproto\Resources\Assets\KnownFollowersAsset; use Atproto\Resources\Assets\LabelsAsset; use Atproto\Resources\Assets\MutedByListAsset; From d518dfac62399d5953c50a0bf16deeb9adeb54f6 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 27 Oct 2024 01:30:08 +0400 Subject: [PATCH 35/59] update dependencies --- composer.lock | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/composer.lock b/composer.lock index 44e0bc8..c69364b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a9611ba6aeb11d62b419926b38c9a7d9", + "content-hash": "1702f11dcf6e5e2c11a6758cd92fce0a", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -864,16 +864,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.3.0", + "version": "v5.3.1", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "3abf7425cd284141dc5d8d14a9ee444de3345d1a" + "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3abf7425cd284141dc5d8d14a9ee444de3345d1a", - "reference": "3abf7425cd284141dc5d8d14a9ee444de3345d1a", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", + "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", "shasum": "" }, "require": { @@ -916,9 +916,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" }, - "time": "2024-09-29T13:56:26+00:00" + "time": "2024-10-08T18:51:32+00:00" }, { "name": "phar-io/manifest", @@ -1040,16 +1040,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.5", + "version": "1.12.7", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17" + "reference": "dc2b9976bd8b0f84ec9b0e50cc35378551de7af0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", - "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc2b9976bd8b0f84ec9b0e50cc35378551de7af0", + "reference": "dc2b9976bd8b0f84ec9b0e50cc35378551de7af0", "shasum": "" }, "require": { @@ -1094,7 +1094,7 @@ "type": "github" } ], - "time": "2024-09-26T12:45:22+00:00" + "time": "2024-10-18T11:12:07+00:00" }, { "name": "phpunit/php-code-coverage", @@ -2596,6 +2596,8 @@ "ext-fileinfo": "*", "php": ">=7.4" }, - "platform-dev": [], + "platform-dev": { + "ext-posix": "*" + }, "plugin-api-version": "2.6.0" } From 38c86c5235f6d095b0c7e75d699e44bb912a69cb Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 27 Oct 2024 01:44:26 +0400 Subject: [PATCH 36/59] test: update exception message assertions for compatibility --- .../App/Bsky/Embed/Collections/EmbedCollectionTest.php | 3 +-- tests/Unit/Lexicons/Com/Atproto/Label/SelfLabelsTest.php | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/EmbedCollectionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/EmbedCollectionTest.php index f7b9e26..69b1d44 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/EmbedCollectionTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/EmbedCollectionTest.php @@ -47,8 +47,7 @@ public function testValidateThrowsExceptionWhenPassedInvalidArgument(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage(sprintf( - "must implement interface %s, instance of %s given", - $this->dependency, + "%s given", get_class($given) )); diff --git a/tests/Unit/Lexicons/Com/Atproto/Label/SelfLabelsTest.php b/tests/Unit/Lexicons/Com/Atproto/Label/SelfLabelsTest.php index b5291be..208c750 100644 --- a/tests/Unit/Lexicons/Com/Atproto/Label/SelfLabelsTest.php +++ b/tests/Unit/Lexicons/Com/Atproto/Label/SelfLabelsTest.php @@ -35,8 +35,10 @@ public function test__constructFillsDataCorrectly(): void public function test__constructorThrowsInvalidArgumentExceptionWhenPassedInvalidArgument(): void { + $givenLabel = (PHP_VERSION_ID < 80000) ? 'object' : 'stdClass'; + $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("must be of the type string"); + $this->expectExceptionMessage("$givenLabel given"); $this->selfLabels = new SelfLabels(['val 1', new stdClass()]); } From 329a83f5e929d119d930211ce35af531a4da79e6 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 27 Oct 2024 19:42:32 +0400 Subject: [PATCH 37/59] refactor: update embed interface implementation for autofill $type field --- .../Lexicons/App/Bsky/Embed/EmbedInterface.php | 1 + .../App/Bsky/Embed/Collections/ImageCollection.php | 13 +++++++++++++ src/Lexicons/App/Bsky/Embed/External.php | 6 ++++++ src/Lexicons/App/Bsky/Embed/Record.php | 6 ++++++ src/Lexicons/App/Bsky/Embed/RecordWithMedia.php | 6 ++++++ src/Lexicons/App/Bsky/Embed/Video.php | 6 ++++++ 6 files changed, 38 insertions(+) diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php index 4667dd7..589ca6f 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php @@ -6,4 +6,5 @@ interface EmbedInterface extends \JsonSerializable, Stringable { + public function type(): string; } diff --git a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php index c55fdd3..5302355 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php @@ -29,4 +29,17 @@ protected function validator(): \Closure return true; }; } + + public function type(): string + { + return 'app.bsky.embed.images'; + } + + public function jsonSerialize() + { + return [ + 'images' => $this->toArray(), + '$type' => $this->type(), + ]; + } } diff --git a/src/Lexicons/App/Bsky/Embed/External.php b/src/Lexicons/App/Bsky/Embed/External.php index 2272f8c..90b7d78 100644 --- a/src/Lexicons/App/Bsky/Embed/External.php +++ b/src/Lexicons/App/Bsky/Embed/External.php @@ -92,6 +92,7 @@ public function thumb(Blob $blob = null) public function jsonSerialize(): array { return array_filter([ + '$type' => $this->type(), 'uri' => $this->uri, 'title' => $this->title, 'description' => $this->description, @@ -103,4 +104,9 @@ public function __toString(): string { return json_encode($this); } + + public function type(): string + { + return 'app.bsky.embed.external'; + } } diff --git a/src/Lexicons/App/Bsky/Embed/Record.php b/src/Lexicons/App/Bsky/Embed/Record.php index eabe245..f1cf13b 100644 --- a/src/Lexicons/App/Bsky/Embed/Record.php +++ b/src/Lexicons/App/Bsky/Embed/Record.php @@ -22,7 +22,13 @@ public function __toString(): string public function jsonSerialize(): array { return [ + '$type' => $this->type(), 'record' => $this->ref, ]; } + + public function type(): string + { + return 'app.bsky.embed.record'; + } } diff --git a/src/Lexicons/App/Bsky/Embed/RecordWithMedia.php b/src/Lexicons/App/Bsky/Embed/RecordWithMedia.php index 61e260d..509abf0 100644 --- a/src/Lexicons/App/Bsky/Embed/RecordWithMedia.php +++ b/src/Lexicons/App/Bsky/Embed/RecordWithMedia.php @@ -29,7 +29,13 @@ public function media(MediaContract $media = null) public function jsonSerialize(): array { return array_merge(parent::jsonSerialize(), [ + '$type' => $this->type(), 'media' => $this->media, ]); } + + public function type(): string + { + return 'app.bsky.embed.recordWithMedia'; + } } diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php index 066a5b2..a7f96b9 100644 --- a/src/Lexicons/App/Bsky/Embed/Video.php +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -35,6 +35,7 @@ public function __construct(Blob $file) public function jsonSerialize(): array { $result = array_filter([ + '$type' => $this->type(), 'alt' => $this->alt() ?: null, 'video' => $this->file, 'aspectRatio' => $this->aspectRatio() ?: null, @@ -92,4 +93,9 @@ public function __toString(): string $result = json_encode($this); return $result; } + + public function type(): string + { + return 'app.bsky.embed.video'; + } } From bd36a66e36334e11168af544b118c95f75dde68e Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 27 Oct 2024 19:43:39 +0400 Subject: [PATCH 38/59] refactor: update uploadBlob lexicon for fixing issues --- src/Lexicons/Com/Atproto/Repo/UploadBlob.php | 35 ++++++++++++-------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php index ec1130d..31a8bc5 100644 --- a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php +++ b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php @@ -4,22 +4,32 @@ use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\Contracts\RequestContract; +use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Lexicons\APIRequest; use Atproto\Resources\Com\Atproto\Repo\UploadBlobResource; +use Atproto\Support\FileSupport; class UploadBlob extends APIRequest { - protected ?string $blob = null; - protected ?string $token = null; + protected function initialize(): void + { + parent::initialize(); + $this->method('POST') + ->header('Content-Type', '*/*'); + } public function blob(string $blob = null) { if (is_null($blob)) { - return $this->blob; + return $this->parameter('blob'); } - $this->blob = $blob; + $blob = (! mb_check_encoding($blob, 'UTF-8')) + ? $blob + : (new FileSupport($blob))->getBlob(); + + $this->parameter('blob', bin2hex($blob)); return $this; } @@ -27,12 +37,10 @@ public function blob(string $blob = null) public function token(string $token = null) { if (is_null($token)) { - return $this->token; + return $this->header('Authorization'); } - $this->token = $token; - - $this->header('Authorization', "Bearer $this->token"); + $this->header('Authorization', "Bearer $token"); return $this; } @@ -43,13 +51,12 @@ public function token(string $token = null) public function build(): RequestContract { $missing = array_filter( - [$this->token => 'token', $this->blob => 'blob'], - fn ($key, $value) => ! $value, - ARRAY_FILTER_USE_BOTH + ['token' => $this->header('Authorization'), 'blob' => $this->parameter('blob')], + fn ($value) => is_null($value), ); if (count($missing)) { - throw new MissingFieldProvidedException(implode(", ", $missing)); + throw new MissingFieldProvidedException(implode(", ", array_keys($missing))); } return $this; @@ -57,6 +64,8 @@ public function build(): RequestContract public function resource(array $data): ResourceContract { - return new UploadBlobResource($data); + return new UploadBlobResource([ + 'blob' => Blob::viaBinary(hex2bin($this->parameter('blob'))) + ]); } } From ce2a8ba2e3c20b4a09d0c83354136660589d0833 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 27 Oct 2024 19:49:00 +0400 Subject: [PATCH 39/59] enhance: update createRecord implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - implement Authentication trait for tracking session changes - fix HTTP method to POST for proper API request - add feature tests for blob reuse in posts Previously uploaded blobs can now be reused in post creation without re-uploading ๐Ÿš€ --- .../Com/Atproto/Repo/CreateRecord.php | 10 ++ .../Com/Atproto/Repo/CreateRecordTest.php | 93 ++++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php index 6dfd364..405aef3 100644 --- a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php +++ b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php @@ -7,16 +7,26 @@ use Atproto\Contracts\RequestContract; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Lexicons\APIRequest; +use Atproto\Lexicons\Traits\Authentication; use Atproto\Resources\Com\Atproto\Repo\CreateRecordResource; class CreateRecord extends APIRequest implements LexiconContract { + use Authentication; + protected array $required = [ 'repo', 'collection', 'record' ]; + protected function initialize(): void + { + parent::initialize(); + + $this->method('POST'); + } + public function repo(string $repo = null) { if (is_null($repo)) { diff --git a/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php index 2311698..0fd2434 100644 --- a/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php +++ b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php @@ -4,9 +4,14 @@ use Atproto\Client; use Atproto\Contracts\Lexicons\App\Bsky\Feed\PostBuilderContract; +use Atproto\DataModel\Blob\Blob; +use Atproto\Exceptions\BlueskyException; use Atproto\Exceptions\InvalidArgumentException; +use Atproto\Lexicons\App\Bsky\Embed\Collections\ImageCollection; +use Atproto\Lexicons\App\Bsky\Embed\Image; use Atproto\Lexicons\Com\Atproto\Repo\CreateRecord; use Atproto\Support\Arr; +use Atproto\Support\FileSupport; use PHPUnit\Framework\TestCase; class CreateRecordTest extends TestCase @@ -37,7 +42,10 @@ public function testBuildCreateRecordRequestWithPost(): void $this->assertInstanceOf(PostBuilderContract::class, $post); - $createRecord = $client->com()->atproto()->repo()->createRecord()->forge()->record($post); + $createRecord = $client->com()->atproto()->repo()->createRecord()->forge() + ->record($post) + ->repo($client->authenticated()->did()) + ->collection('app.bsky.feed.post'); $this->assertInstanceOf(CreateRecord::class, $createRecord); @@ -46,10 +54,91 @@ public function testBuildCreateRecordRequestWithPost(): void $this->assertSame($serializedPost, $actualPost); - $response = $client->send(); + $response = $createRecord->send(); $this->assertIsString($response->uri()); echo $response->uri(); } + + /** + * @throws BlueskyException + */ + public function testSendPostWithBlobUsingPostBuilderAPI(): void + { + $client = static::$client; + + /** @var Blob $uploadBlob */ + $uploadedBlob = $client->com() + ->atproto() + ->repo() + ->uploadBlob() + ->forge() // Atproto\Lexicons\Com\Atproto\Repo\UploadBlob + ->token($client->authenticated()->accessJwt()) + ->blob(__DIR__.'/../../../../../../art/file.png') + ->build() + ->send() // Atproto\Resources\Com\Atproto\Repo\UploadBlobResource + ->blob(); + + $this->assertInstanceOf(Blob::class, $uploadedBlob); + + $post = $client->app() + ->bsky() + ->feed() + ->post() + ->forge() + ->text("Hello World!") + ->embed(new ImageCollection([ + new Image($uploadedBlob, "Alt text") + ])); + + $this->assertInstanceOf(PostBuilderContract::class, $post); + + $createdRecord = $client->com() + ->atproto() + ->repo() + ->createRecord() + ->forge() + ->record($post) + ->repo($client->authenticated()->did()) + ->collection('app.bsky.feed.post') + ->build() + ->send(); + + $this->assertIsString($createdRecord->uri()); + } + + /** + * @throws InvalidArgumentException + */ + public function testPostCreationWithoutBlobUploading(): void + { + $client = static::$client; + + $post = $client->app() + ->bsky() + ->feed() + ->post() + ->forge() + ->text("Testing post creation without a blob uploading") + ->embed(new ImageCollection([ + new Image( + Blob::viaFile(new FileSupport(__DIR__.'/../../../../../../art/file.png')), + 'This blob not uploaded during this post creation' + ), + ])); + + $createdRecord = $client->com() + ->atproto() + ->repo() + ->createRecord() + ->forge() + ->record($post) + ->repo($client->authenticated()->did()) + ->collection('app.bsky.feed.post') + ->build() + ->send(); + + $this->assertIsString($createdRecord->uri()); + } } From 19583965e60fe18e0244693b407983a5c331540c Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Mon, 28 Oct 2024 01:34:11 +0400 Subject: [PATCH 40/59] Refactor tests for compatibility with recent changes and resolve test-reported issues - Update tests to ensure compatibility with recent refactoring, including modified methods and type hints. - Fix failing tests by addressing issues reported in test results, such as data handling in `jsonSerialize()` and validation exceptions. --- .../App/Bsky/Embed/Collections/ImageCollection.php | 2 +- src/Lexicons/Com/Atproto/Repo/UploadBlob.php | 9 +++++++-- src/Support/FileSupport.php | 9 ++++++++- .../Requests/Com/Atproto/Repo/UploadBlobTest.php | 6 +++--- .../Embed/Collections/CaptionCollectionTest.php | 14 ++++++++++++++ .../Bsky/Embed/Collections/EmbedCollectionTest.php | 13 ------------- .../Bsky/Embed/Collections/ImageCollectionTest.php | 13 +++++++++++++ .../Unit/Lexicons/App/Bsky/Embed/ExternalTest.php | 2 ++ tests/Unit/Lexicons/App/Bsky/Embed/RecordTest.php | 2 +- tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php | 2 ++ 10 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php index 5302355..15800bc 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php @@ -35,7 +35,7 @@ public function type(): string return 'app.bsky.embed.images'; } - public function jsonSerialize() + public function jsonSerialize(): array { return [ 'images' => $this->toArray(), diff --git a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php index 31a8bc5..6d1d8c5 100644 --- a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php +++ b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php @@ -6,6 +6,7 @@ use Atproto\Contracts\RequestContract; use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\Http\MissingFieldProvidedException; +use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\APIRequest; use Atproto\Resources\Com\Atproto\Repo\UploadBlobResource; use Atproto\Support\FileSupport; @@ -19,10 +20,13 @@ protected function initialize(): void ->header('Content-Type', '*/*'); } + /** + * @throws InvalidArgumentException + */ public function blob(string $blob = null) { if (is_null($blob)) { - return $this->parameter('blob'); + return hex2bin($this->parameter('blob')); } $blob = (! mb_check_encoding($blob, 'UTF-8')) @@ -37,7 +41,8 @@ public function blob(string $blob = null) public function token(string $token = null) { if (is_null($token)) { - return $this->header('Authorization'); + $token = $this->header('Authorization'); + return trim(substr($token, strrpos($token, ' '))) ?: null; } $this->header('Authorization', "Bearer $token"); diff --git a/src/Support/FileSupport.php b/src/Support/FileSupport.php index c0bfff3..0801b13 100644 --- a/src/Support/FileSupport.php +++ b/src/Support/FileSupport.php @@ -2,6 +2,8 @@ namespace Atproto\Support; +use Atproto\Exceptions\InvalidArgumentException; + /** * Class File * @@ -15,11 +17,16 @@ class FileSupport /** * Constructor. * - * @param string $file_path The path to the file. + * @param string $file_path The path to the file. + * @throws InvalidArgumentException */ public function __construct($file_path) { $this->file_path = $file_path; + + if (! $this->isFile() && ! $this->isDirectory()) { + throw new InvalidArgumentException("File path '$file_path' does not exist."); + } } /** diff --git a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php index f7eef05..9d32c59 100644 --- a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php +++ b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php @@ -26,7 +26,7 @@ protected function setUp(): void public function testBlobMethodSetsAndReturnsValue(): void { - $blobData = $this->faker->word; + $blobData = random_bytes(1024); $result = $this->uploadBlob->blob($blobData); @@ -69,7 +69,7 @@ public function testBuildThrowsExceptionWhenTokenIsMissing(): void $this->expectException(MissingFieldProvidedException::class); $this->expectExceptionMessage('token'); - $this->uploadBlob->blob($this->faker->word)->build(); + $this->uploadBlob->blob(random_bytes(1024))->build(); } /** @@ -78,7 +78,7 @@ public function testBuildThrowsExceptionWhenTokenIsMissing(): void public function testBuildReturnsInstanceWhenAllFieldsAreSet(): void { $result = $this->uploadBlob - ->blob($this->faker->word) + ->blob(random_bytes(1024)) ->token($this->faker->word) ->build(); diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php index ba836dd..f99c41e 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed\Collections; use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionContract; +use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; use PHPUnit\Framework\TestCase; @@ -13,4 +14,17 @@ class CaptionCollectionTest extends TestCase private string $target = CaptionCollection::class; private string $dependency = CaptionContract::class; private int $maxLength = 20; + + /** + * @throws InvalidArgumentException + */ + public function testJsonSerialize() + { + $items = new $this->target($this->items(2)); + + $expected = json_encode(array_map(fn () => ['foo' => 'bar'], $items->toArray())); + $actual = json_encode($items); + + $this->assertSame($expected, $actual); + } } diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/EmbedCollectionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/EmbedCollectionTest.php index 69b1d44..e6287f1 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/EmbedCollectionTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/EmbedCollectionTest.php @@ -61,17 +61,4 @@ public function testValidateThrowsExceptionWhenLimitExceed(): void new $this->target($this->items(++$this->maxLength)); } - - /** - * @throws InvalidArgumentException - */ - public function testJsonSerialize() - { - $items = new $this->target($this->items(2)); - - $expected = json_encode(array_map(fn () => ['foo' => 'bar'], $items->toArray())); - $actual = json_encode($items); - - $this->assertSame($expected, $actual); - } } diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php index 548e198..1d9b430 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php @@ -31,4 +31,17 @@ public function testValidateThrowsExceptionWherePassedThatSizeGreaterThanLimit() new ImageCollection([$dependency]); } + + /** + * @throws InvalidArgumentException + */ + public function testJsonSerialize() + { + $items = new $this->target($this->items(2)); + + $expected = json_encode(['images' => array_map(fn () => ['foo' => 'bar'], $items->toArray()), '$type' => 'app.bsky.embed.images']); + $actual = json_encode($items); + + $this->assertSame($expected, $actual); + } } diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php index 3574916..383022a 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php @@ -106,6 +106,7 @@ public function testTitle() public function testJsonSerializeWithoutSetBlob(): void { $expected = [ + '$type' => 'app.bsky.embed.external', 'uri' => 'https://shahmal1yev.dev', 'title' => 'foo', 'description' => 'bar', @@ -122,6 +123,7 @@ public function testJsonSerializeWithSetBlob(): void $this->external->thumb($this->blob); $expected = [ + '$type' => 'app.bsky.embed.external', 'uri' => 'https://shahmal1yev.dev', 'title' => 'foo', 'description' => 'bar', diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/RecordTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/RecordTest.php index 65423a9..579215e 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/RecordTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/RecordTest.php @@ -11,7 +11,7 @@ class RecordTest extends TestCase private Record $record; private array $expected = [ - + '$type' => 'app.bsky.embed.record', 'record' => [ 'uri' => 'foo', 'cid' => 'bar' diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php index d5336c9..d945982 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php @@ -69,6 +69,7 @@ public function testAspectRatio(): void public function testJsonSerializeReturnsCorrectSchema(): void { $expected = [ + '$type' => 'app.bsky.embed.video', 'video' => $this->file, ]; @@ -80,6 +81,7 @@ public function testJsonSerializeReturnsCorrectSchema(): void $aspectRatio = $this->randAspectRatio(); $expected = [ + '$type' => 'app.bsky.embed.video', 'alt' => 'alt text', 'video' => $expected['video'], 'aspectRatio' => $aspectRatio, From 02d212afb2ac473f523b7859880414fa0e3348fd Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Mon, 28 Oct 2024 01:38:35 +0400 Subject: [PATCH 41/59] Optimize imports and apply PSR 12 using PHP CS Fixer --- src/API/Com/Atrproto/Repo/CreateRecord.php | 1 - src/API/Com/Atrproto/Repo/UploadBlob.php | 1 - src/Collections/FacetCollection.php | 1 - src/Contracts/AuthStrategyContract.php | 2 -- src/Contracts/HTTP/RequestContract.php | 2 -- src/Contracts/RecordBuilderContract.php | 1 - src/DataModel/Blob/BinaryBlobHandler.php | 2 +- src/DataModel/Blob/Blob.php | 4 ++-- src/DataModel/Blob/FileBlobHandler.php | 2 +- src/Exceptions/InvalidArgumentException.php | 2 -- src/Lexicons/App/Bsky/RichText/Link.php | 3 --- src/MultiFormats/MultiCodec.php | 1 - src/Resources/Assets/FollowersAsset.php | 2 +- src/Resources/Assets/LabelsAsset.php | 2 +- src/Support/Arr.php | 2 -- src/Traits/Smith.php | 1 - 16 files changed, 6 insertions(+), 23 deletions(-) diff --git a/src/API/Com/Atrproto/Repo/CreateRecord.php b/src/API/Com/Atrproto/Repo/CreateRecord.php index d2d4a90..99cfa3c 100644 --- a/src/API/Com/Atrproto/Repo/CreateRecord.php +++ b/src/API/Com/Atrproto/Repo/CreateRecord.php @@ -5,7 +5,6 @@ use Atproto\Contracts\HTTP\RequestContract; use Atproto\Contracts\RecordBuilderContract; use Atproto\Exceptions\Http\Request\RequestBodyHasMissingRequiredFields; -use Atproto\Resources\Com\Atproto\Repo\CreateRecordResource; use InvalidArgumentException; /** diff --git a/src/API/Com/Atrproto/Repo/UploadBlob.php b/src/API/Com/Atrproto/Repo/UploadBlob.php index 4c8095f..53c9cdd 100644 --- a/src/API/Com/Atrproto/Repo/UploadBlob.php +++ b/src/API/Com/Atrproto/Repo/UploadBlob.php @@ -5,7 +5,6 @@ use Atproto\Contracts\HTTP\RequestContract; use Atproto\Exceptions\Http\Request\RequestBodyHasMissingRequiredFields; use Atproto\Support\FileSupport; -use Atproto\Resources\Com\Atproto\Repo\UploadBlobResource; use InvalidArgumentException; /** diff --git a/src/Collections/FacetCollection.php b/src/Collections/FacetCollection.php index fd55269..269e83d 100644 --- a/src/Collections/FacetCollection.php +++ b/src/Collections/FacetCollection.php @@ -3,7 +3,6 @@ namespace Atproto\Collections; use Atproto\Contracts\Lexicons\App\Bsky\RichText\FacetContract; -use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use GenericCollection\GenericCollection; class FacetCollection extends GenericCollection diff --git a/src/Contracts/AuthStrategyContract.php b/src/Contracts/AuthStrategyContract.php index 203b35a..bf4cf59 100644 --- a/src/Contracts/AuthStrategyContract.php +++ b/src/Contracts/AuthStrategyContract.php @@ -2,8 +2,6 @@ namespace Atproto\Contracts; -use Exception; - /** * Interface AuthStrategyContract * diff --git a/src/Contracts/HTTP/RequestContract.php b/src/Contracts/HTTP/RequestContract.php index f647699..f6e8426 100644 --- a/src/Contracts/HTTP/RequestContract.php +++ b/src/Contracts/HTTP/RequestContract.php @@ -2,8 +2,6 @@ namespace Atproto\Contracts\HTTP; -use Atproto\Contracts\HTTP\Resources\ResourceContract; - /** * Interface RequestContract * diff --git a/src/Contracts/RecordBuilderContract.php b/src/Contracts/RecordBuilderContract.php index dc0264a..53d438f 100644 --- a/src/Contracts/RecordBuilderContract.php +++ b/src/Contracts/RecordBuilderContract.php @@ -3,7 +3,6 @@ namespace Atproto\Contracts; use DateTimeImmutable; -use InvalidArgumentException; use stdClass; /** diff --git a/src/DataModel/Blob/BinaryBlobHandler.php b/src/DataModel/Blob/BinaryBlobHandler.php index 0753bfc..5f95024 100644 --- a/src/DataModel/Blob/BinaryBlobHandler.php +++ b/src/DataModel/Blob/BinaryBlobHandler.php @@ -2,9 +2,9 @@ namespace Atproto\DataModel\Blob; +use Atproto\Contracts\DataModel\BlobHandler; use Atproto\Exceptions\InvalidArgumentException; use finfo; -use Atproto\Contracts\DataModel\BlobHandler; class BinaryBlobHandler implements BlobHandler { diff --git a/src/DataModel/Blob/Blob.php b/src/DataModel/Blob/Blob.php index 26fca09..22e1ca8 100644 --- a/src/DataModel/Blob/Blob.php +++ b/src/DataModel/Blob/Blob.php @@ -2,12 +2,12 @@ namespace Atproto\DataModel\Blob; +use Atproto\Contracts\DataModel\BlobContract; +use Atproto\Contracts\DataModel\BlobHandler; use Atproto\IPFS\CID\CID; use Atproto\MultiFormats\MultiBase\MultiBase; use Atproto\MultiFormats\MultiCodec; use Atproto\Support\FileSupport; -use Atproto\Contracts\DataModel\BlobContract; -use Atproto\Contracts\DataModel\BlobHandler; class Blob implements BlobContract { diff --git a/src/DataModel/Blob/FileBlobHandler.php b/src/DataModel/Blob/FileBlobHandler.php index da04d2d..72bd326 100644 --- a/src/DataModel/Blob/FileBlobHandler.php +++ b/src/DataModel/Blob/FileBlobHandler.php @@ -2,9 +2,9 @@ namespace Atproto\DataModel\Blob; +use Atproto\Contracts\DataModel\BlobHandler; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Support\FileSupport; -use Atproto\Contracts\DataModel\BlobHandler; class FileBlobHandler implements BlobHandler { diff --git a/src/Exceptions/InvalidArgumentException.php b/src/Exceptions/InvalidArgumentException.php index 4c80bfc..07900d8 100644 --- a/src/Exceptions/InvalidArgumentException.php +++ b/src/Exceptions/InvalidArgumentException.php @@ -2,8 +2,6 @@ namespace Atproto\Exceptions; -use Atproto\Exceptions\BlueskyException; - class InvalidArgumentException extends BlueskyException { } diff --git a/src/Lexicons/App/Bsky/RichText/Link.php b/src/Lexicons/App/Bsky/RichText/Link.php index 6f8fc19..f23945e 100644 --- a/src/Lexicons/App/Bsky/RichText/Link.php +++ b/src/Lexicons/App/Bsky/RichText/Link.php @@ -2,9 +2,6 @@ namespace Atproto\Lexicons\App\Bsky\RichText; -use Atproto\Contracts\Lexicons\App\Bsky\RichText\FacetContract; -use Atproto\Exceptions\InvalidArgumentException; - class Link extends FeatureAbstract { protected function schema(): array diff --git a/src/MultiFormats/MultiCodec.php b/src/MultiFormats/MultiCodec.php index ae68c02..0980971 100644 --- a/src/MultiFormats/MultiCodec.php +++ b/src/MultiFormats/MultiCodec.php @@ -2,7 +2,6 @@ namespace Atproto\MultiFormats; -use Atproto\Exceptions\InvalidArgumentException; use Atproto\Support\Enum; abstract class MultiCodec diff --git a/src/Resources/Assets/FollowersAsset.php b/src/Resources/Assets/FollowersAsset.php index 637fcc3..e42cf35 100644 --- a/src/Resources/Assets/FollowersAsset.php +++ b/src/Resources/Assets/FollowersAsset.php @@ -2,8 +2,8 @@ namespace Atproto\Resources\Assets; -use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Collections\Types\NonPrimitive\FollowerAssetType; +use Atproto\Contracts\HTTP\Resources\AssetContract; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; use GenericCollection\Interfaces\TypeInterface; diff --git a/src/Resources/Assets/LabelsAsset.php b/src/Resources/Assets/LabelsAsset.php index 9894d25..d4a0944 100644 --- a/src/Resources/Assets/LabelsAsset.php +++ b/src/Resources/Assets/LabelsAsset.php @@ -2,8 +2,8 @@ namespace Atproto\Resources\Assets; -use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Collections\Types\NonPrimitive\LabelAssetType; +use Atproto\Contracts\HTTP\Resources\AssetContract; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; use GenericCollection\Interfaces\TypeInterface; diff --git a/src/Support/Arr.php b/src/Support/Arr.php index d8777ab..01d3917 100644 --- a/src/Support/Arr.php +++ b/src/Support/Arr.php @@ -2,8 +2,6 @@ namespace Atproto\Support; -use ArrayAccess; - class Arr { /** diff --git a/src/Traits/Smith.php b/src/Traits/Smith.php index 21ebde5..37d36ae 100644 --- a/src/Traits/Smith.php +++ b/src/Traits/Smith.php @@ -5,7 +5,6 @@ use Atproto\Client; use Atproto\Contracts\HTTP\APIRequestContract; use Atproto\Contracts\Observer; -use Atproto\Contracts\RequestContract; use Atproto\Exceptions\Http\Request\RequestNotFoundException; trait Smith From d1458b155bfa60ab8151fdbd533f9d43234f37c7 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Mon, 28 Oct 2024 02:12:04 +0400 Subject: [PATCH 42/59] Complete the implementation of the "GetFollowers" lexicon. - Removed unused Authentication logic from GetFollowers - Enhanced GetFollowersResource with SubjectAsset - Added SubjectAsset class for expanded resource support - Introduced feature test for GetFollowers functionality - Updated unit tests for authorization and missing field exceptions --- src/Lexicons/App/Bsky/Graph/GetFollowers.php | 10 ----- .../App/Bsky/Graph/GetFollowersResource.php | 2 + src/Resources/Assets/SubjectAsset.php | 11 +++++ .../App/Bsky/Graph/GetFollowersTest.php | 41 +++++++++++++++++++ .../App/Bsky/Graph/GetFollowersTest.php | 20 ++------- 5 files changed, 58 insertions(+), 26 deletions(-) create mode 100644 src/Resources/Assets/SubjectAsset.php create mode 100644 tests/Feature/Lexicons/App/Bsky/Graph/GetFollowersTest.php diff --git a/src/Lexicons/App/Bsky/Graph/GetFollowers.php b/src/Lexicons/App/Bsky/Graph/GetFollowers.php index eee5495..3e09dc3 100644 --- a/src/Lexicons/App/Bsky/Graph/GetFollowers.php +++ b/src/Lexicons/App/Bsky/Graph/GetFollowers.php @@ -5,17 +5,12 @@ use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\Contracts\RequestContract; use Atproto\Exceptions\Http\MissingFieldProvidedException; -use Atproto\Exceptions\Http\Response\AuthMissingException; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\APIRequest; -use Atproto\Lexicons\Traits\Authentication; use Atproto\Resources\App\Bsky\Graph\GetFollowersResource; -use Atproto\Support\Arr; class GetFollowers extends APIRequest { - use Authentication; - public function actor(string $actor = null) { if (is_null($actor)) { @@ -63,14 +58,9 @@ public function resource(array $data): ResourceContract /** * @throws MissingFieldProvidedException - * @throws AuthMissingException */ public function build(): RequestContract { - if (! Arr::exists($this->headers(false), 'Authorization')) { - throw new AuthMissingException(); - } - if (! $this->queryParameter('actor')) { throw new MissingFieldProvidedException('actor'); } diff --git a/src/Resources/App/Bsky/Graph/GetFollowersResource.php b/src/Resources/App/Bsky/Graph/GetFollowersResource.php index e97c562..0968e4c 100644 --- a/src/Resources/App/Bsky/Graph/GetFollowersResource.php +++ b/src/Resources/App/Bsky/Graph/GetFollowersResource.php @@ -5,6 +5,7 @@ use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\Resources\Assets\BaseAsset; use Atproto\Resources\Assets\FollowersAsset; +use Atproto\Resources\Assets\SubjectAsset; use Atproto\Resources\BaseResource; use Atproto\Traits\Castable; @@ -23,6 +24,7 @@ protected function casts(): array { return [ 'followers' => FollowersAsset::class, + 'subject' => SubjectAsset::class, ]; } } diff --git a/src/Resources/Assets/SubjectAsset.php b/src/Resources/Assets/SubjectAsset.php new file mode 100644 index 0000000..19cbd5e --- /dev/null +++ b/src/Resources/Assets/SubjectAsset.php @@ -0,0 +1,11 @@ +authenticate( + getenv('BLUESKY_IDENTIFIER'), + getenv('BLUESKY_PASSWORD'), + ); + } + + public function testGetFollowers() + { + $client = static::$client; + + $request = $client->app() + ->bsky() + ->graph() + ->getFollowers() + ->forge(); + + $request->actor($client->authenticated()->did()) + ->build(); + + $response = $request->send(); + + $this->assertSame($client->authenticated()->did(), $response->subject()->did()); + $this->assertInstanceOf(FollowersAsset::class, $response->followers()); + } +} diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php index a12c961..0aefa6c 100644 --- a/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php @@ -65,34 +65,22 @@ public function testCursorSetterAndGetter() $this->assertSame($cursor, $this->request->cursor(), 'Cursor getter should return the value set by the setter.'); } - public function testBuildThrowsExceptionWhenAuthorizationHeaderMissing() - { - $this->expectException(AuthMissingException::class); - $this->expectExceptionMessage('Authentication Required'); - - // Set required 'actor' parameter - $this->request->actor('testActor'); - - // Do not set 'Authorization' header - $this->request->build(); - } - public function testBuildThrowsExceptionWhenActorParameterMissing() { $this->expectException(MissingFieldProvidedException::class); $this->expectExceptionMessage("Missing fields provided: actor"); - // Set 'Authorization' header - $this->request->token('Bearer token'); - // Do not set 'actor' parameter $this->request->build(); } + /** + * @throws MissingFieldProvidedException + * @throws AuthMissingException + */ public function testBuildSucceedsWithRequiredParameters() { // Set required 'Authorization' header and 'actor' parameter - $this->request->token('Bearer token'); $this->request->actor('testActor'); // Should not throw any exceptions From 5a251ddb4320215062448cd956ef29c3ad5eba5e Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Mon, 28 Oct 2024 02:34:02 +0400 Subject: [PATCH 43/59] Complete the implementation of "CreateRecord" lexicon - Updated `CreateRecord::record()` to accept only `PostBuilderContract` type, updated type safety for record instances - Added validation in `CreateRecord::rkey()` to restrict `rkey` to a max of 15 characters, throwing `InvalidArgumentException` on failure - Adjusted unit tests to mock `PostBuilderContract` for compatibility with updated `record` method signature --- src/Lexicons/Com/Atproto/Repo/CreateRecord.php | 11 ++++++++++- .../Requests/Com/Atproto/Repo/CreateRecordTest.php | 7 ++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php index 405aef3..de8fd71 100644 --- a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php +++ b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php @@ -4,8 +4,10 @@ use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\Contracts\LexiconContract; +use Atproto\Contracts\Lexicons\App\Bsky\Feed\PostBuilderContract; use Atproto\Contracts\RequestContract; use Atproto\Exceptions\Http\MissingFieldProvidedException; +use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\APIRequest; use Atproto\Lexicons\Traits\Authentication; use Atproto\Resources\Com\Atproto\Repo\CreateRecordResource; @@ -49,12 +51,19 @@ public function collection(string $collection = null) return $this; } + /** + * @throws InvalidArgumentException + */ public function rkey(string $rkey = null) { if (is_null($rkey)) { return $this->parameter('rkey') ?? null; } + if (strlen($rkey) > 15) { + throw new InvalidArgumentException("The 'rkey' must be a maximum of 15 characters."); + } + $this->parameter('rkey', $rkey); return $this; @@ -71,7 +80,7 @@ public function validate(bool $validate = null) return $this; } - public function record(object $record = null) + public function record(PostBuilderContract $record = null) { if (is_null($record)) { return $this->parameter('record') ?? null; diff --git a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php index 8a1da0c..25348b9 100644 --- a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php +++ b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\HTTP\API\Requests\Com\Atproto\Repo; use Atproto\Client; +use Atproto\Contracts\Lexicons\App\Bsky\Feed\PostBuilderContract; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Lexicons\Com\Atproto\Repo\CreateRecord; use Faker\Factory; @@ -59,7 +60,7 @@ public function testRecord() { $this->assertNull($this->createRecord->record()); - $record = (object)['key' => 'value']; + $record = $this->createMock(PostBuilderContract::class); $this->createRecord->record($record); $this->assertEquals($record, $this->createRecord->record()); } @@ -80,7 +81,7 @@ public function testBuildWithAllRequiredFields() { $this->createRecord->repo($this->faker->word) ->collection($this->faker->word) - ->record((object)['key' => 'value']); + ->record($this->createMock(PostBuilderContract::class)); $result = $this->createRecord->build(); @@ -104,7 +105,7 @@ public function testChaining() ->collection($this->faker->word) ->rkey($this->faker->word) ->validate(true) - ->record((object)['key' => 'value']) + ->record($this->createMock(PostBuilderContract::class)) ->swapCommit($this->faker->word); $this->assertInstanceOf(CreateRecord::class, $result); From 7d5f1cf298e083d927abd1f52d4e08c051b24fdc Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Wed, 30 Oct 2024 15:56:45 +0400 Subject: [PATCH 44/59] Rename and remove the old API --- src/API/App/Bsky/Actor/GetProfile.php | 161 --------- src/API/Com/Atrproto/Repo/CreateRecord.php | 324 ------------------ src/API/Com/Atrproto/Repo/UploadBlob.php | 174 ---------- src/API/Traits/ResourceSupport.php | 14 - .../Strategies/PasswordAuthentication.php | 94 ----- src/Builders/Bluesky/RecordBuilder.php | 232 ------------- src/Clients/BlueskyClient.php | 279 --------------- src/Contracts/AuthStrategyContract.php | 19 - src/Contracts/BuilderInterface.php | 8 - src/Contracts/ClientContract.php | 25 -- src/Contracts/HTTP/RequestContract.php | 53 --- .../{HTTP => Lexicons}/APIRequestContract.php | 3 +- .../{ => Lexicons}/RequestContract.php | 2 +- src/Contracts/RecordBuilderContract.php | 102 ------ .../{HTTP => }/Resources/AssetContract.php | 2 +- .../{HTTP => }/Resources/ResourceContract.php | 2 +- src/DataModel/Blob/Blob.php | 4 +- src/Exceptions/Auth/AuthFailed.php | 13 - .../Http/InvalidRequestException.php | 13 - .../RequestBodyHasMissingRequiredFields.php | 25 -- .../Http/Token/ExpiredTokenException.php | 13 - .../Http/Token/InvalidTokenException.php | 13 - src/Exceptions/Http/UnsupportedHTTPMethod.php | 10 - src/Exceptions/RuntimeException.php | 7 - src/IPFS/CID/CID.php | 4 +- src/IPFS/CID/Versions/CIDV1.php | 4 +- .../MultiBase/Encoders/Base32Encoder.php | 2 +- .../MultiBase/Encoders/MultiBaseSupport.php | 2 +- .../MultiFormats/MultiBase/MultiBase.php | 4 +- src/{ => IPFS}/MultiFormats/MultiCodec.php | 2 +- src/{ => IPFS}/MultiFormats/MultiHash.php | 2 +- src/Lexicons/APIRequest.php | 4 +- src/Lexicons/App/Bsky/Actor/GetProfile.php | 4 +- src/Lexicons/App/Bsky/Actor/GetProfiles.php | 4 +- src/Lexicons/App/Bsky/Graph/GetFollowers.php | 4 +- .../Com/Atproto/Repo/CreateRecord.php | 4 +- src/Lexicons/Com/Atproto/Repo/UploadBlob.php | 4 +- .../Com/Atproto/Server/CreateSession.php | 4 +- src/Lexicons/Request.php | 2 +- src/Lexicons/Traits/RequestHandler.php | 2 +- .../App/Bsky/Actor/GetProfileResource.php | 2 +- .../App/Bsky/Actor/GetProfilesResource.php | 2 +- .../App/Bsky/Graph/GetFollowersResource.php | 2 +- src/Resources/Assets/AssociatedAsset.php | 4 +- src/Resources/Assets/BaseAsset.php | 2 +- src/Resources/Assets/BlockingByListAsset.php | 4 +- src/Resources/Assets/ChatAsset.php | 4 +- src/Resources/Assets/CollectionAsset.php | 2 +- src/Resources/Assets/CreatorAsset.php | 4 +- src/Resources/Assets/DatetimeAsset.php | 2 +- src/Resources/Assets/FollowerAsset.php | 4 +- src/Resources/Assets/FollowersAsset.php | 2 +- .../Assets/JoinedViaStarterPackAsset.php | 4 +- src/Resources/Assets/KnownFollowersAsset.php | 2 +- src/Resources/Assets/LabelAsset.php | 4 +- src/Resources/Assets/LabelsAsset.php | 2 +- src/Resources/Assets/MutedByListAsset.php | 4 +- src/Resources/Assets/ProfileAsset.php | 4 +- src/Resources/Assets/ProfilesAsset.php | 2 +- src/Resources/Assets/SubjectAsset.php | 4 +- src/Resources/Assets/ViewerAsset.php | 4 +- src/Resources/BaseResource.php | 2 +- .../Com/Atproto/Repo/CreateRecordResource.php | 2 +- .../Com/Atproto/Repo/UploadBlobResource.php | 2 +- .../Atproto/Server/CreateSessionResource.php | 2 +- src/Traits/Smith.php | 2 +- tests/Feature/BlueskyClientTest.php | 286 ---------------- tests/Feature/ClientTest.php | 2 +- .../App/Bsky/Actor/GetProfilesTest.php | 116 ------- tests/Supports/NonPrimitiveAssetTest.php | 2 +- tests/Unit/ClientTest.php | 2 +- tests/Unit/DataModel/Blob/BlobTest.php | 4 +- tests/Unit/IPFS/CID/CIDTest.php | 6 +- .../{HTTP/API => Lexicons}/APIRequestTest.php | 2 +- .../App/Bsky/Actor/GetProfileTest.php | 6 +- .../App/Bsky/Actor/GetProfilesTest.php | 2 +- .../App/Bsky/Graph/GetFollowersTest.php | 4 +- .../Com/Atproto/Repo/CreateRecordTest.php | 2 +- .../Com/Atproto/Repo/UploadBlobTest.php | 2 +- tests/Unit/{HTTP => Lexicons}/RequestTest.php | 4 +- .../App/Bsky/Actor/GetProfileResourceTest.php | 2 +- tests/Unit/Resources/Assets/BaseAssetTest.php | 2 +- .../Resources/Assets/CollectionAssetTest.php | 2 +- .../Resources/Assets/FollowersAssetTest.php | 2 +- tests/Unit/Resources/BaseResourceTest.php | 4 +- 85 files changed, 94 insertions(+), 2076 deletions(-) delete mode 100644 src/API/App/Bsky/Actor/GetProfile.php delete mode 100644 src/API/Com/Atrproto/Repo/CreateRecord.php delete mode 100644 src/API/Com/Atrproto/Repo/UploadBlob.php delete mode 100644 src/API/Traits/ResourceSupport.php delete mode 100644 src/Auth/Strategies/PasswordAuthentication.php delete mode 100644 src/Builders/Bluesky/RecordBuilder.php delete mode 100644 src/Clients/BlueskyClient.php delete mode 100644 src/Contracts/AuthStrategyContract.php delete mode 100644 src/Contracts/BuilderInterface.php delete mode 100644 src/Contracts/ClientContract.php delete mode 100644 src/Contracts/HTTP/RequestContract.php rename src/Contracts/{HTTP => Lexicons}/APIRequestContract.php (81%) rename src/Contracts/{ => Lexicons}/RequestContract.php (98%) delete mode 100644 src/Contracts/RecordBuilderContract.php rename src/Contracts/{HTTP => }/Resources/AssetContract.php (68%) rename src/Contracts/{HTTP => }/Resources/ResourceContract.php (93%) delete mode 100644 src/Exceptions/Auth/AuthFailed.php delete mode 100644 src/Exceptions/Http/InvalidRequestException.php delete mode 100644 src/Exceptions/Http/Request/RequestBodyHasMissingRequiredFields.php delete mode 100644 src/Exceptions/Http/Token/ExpiredTokenException.php delete mode 100644 src/Exceptions/Http/Token/InvalidTokenException.php delete mode 100644 src/Exceptions/Http/UnsupportedHTTPMethod.php delete mode 100644 src/Exceptions/RuntimeException.php rename src/{ => IPFS}/MultiFormats/MultiBase/Encoders/Base32Encoder.php (96%) rename src/{ => IPFS}/MultiFormats/MultiBase/Encoders/MultiBaseSupport.php (87%) rename src/{ => IPFS}/MultiFormats/MultiBase/MultiBase.php (80%) rename src/{ => IPFS}/MultiFormats/MultiCodec.php (84%) rename src/{ => IPFS}/MultiFormats/MultiHash.php (94%) delete mode 100644 tests/Feature/BlueskyClientTest.php delete mode 100644 tests/Feature/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php rename tests/Unit/{HTTP/API => Lexicons}/APIRequestTest.php (97%) rename tests/Unit/{HTTP/API/Requests => Lexicons}/App/Bsky/Actor/GetProfileTest.php (95%) rename tests/Unit/{HTTP/API/Requests => Lexicons}/App/Bsky/Actor/GetProfilesTest.php (98%) rename tests/Unit/{HTTP/API/Requests => Lexicons}/App/Bsky/Graph/GetFollowersTest.php (97%) rename tests/Unit/{HTTP/API/Requests => Lexicons}/Com/Atproto/Repo/CreateRecordTest.php (98%) rename tests/Unit/{HTTP/API/Requests => Lexicons}/Com/Atproto/Repo/UploadBlobTest.php (97%) rename tests/Unit/{HTTP => Lexicons}/RequestTest.php (98%) diff --git a/src/API/App/Bsky/Actor/GetProfile.php b/src/API/App/Bsky/Actor/GetProfile.php deleted file mode 100644 index ae969dc..0000000 --- a/src/API/App/Bsky/Actor/GetProfile.php +++ /dev/null @@ -1,161 +0,0 @@ - "application/json" - ]; - - /** - * Constructs a new GetProfile instance. - */ - public function __construct() - { - $this->body = (object) [ - 'actor' => '', - ]; - } - - /** - * Sets the actor to be performed. - * - * @param string $actor The 'actor' to be set - * - * @throws InvalidArgumentException if $actor is not a string - */ - public function setActor($actor) - { - if (! is_string($actor)) { - throw new InvalidArgumentException("'actor' must be a string"); - } - - $this->body->actor = $actor; - } - - /** - * Retrieves the 'actor' that has been set. - * - * @return string The 'actor' field value - */ - public function getActor() - { - return $this->body->actor; - } - - /** - * Sets the request headers. - * - * @param array $headers The headers to be set - * - * @return $this - */ - public function setHeaders(array $headers) - { - $this->headers = array_diff_key( - $this->headers, - array_flip(array_keys($headers)) - ); - - $this->headers = array_merge( - $this->headers, - $headers - ); - - return $this; - } - - /** - * Retrieves the request headers. - * - * @return array The request headers - */ - public function getHeaders() - { - return $this->headers; - } - - /** - * Retrieves the HTTP method for the request. - * - * @return string The HTTP method - */ - public function getMethod() - { - return 'GET'; - } - - /** - * Retrieves the URI for the request. - * - * @return string The URI - */ - public function getUri() - { - return '/app.bsky.actor.getProfile'; - } - - /** - * Indicates whether authentication is required for the request. - * - * @return bool true if authentication is required, false otherwise - */ - public function authRequired() - { - return true; - } - - /** - * Boots the request with authentication response. - * - * @param mixed $authResponse The authentication response - */ - public function boot($authResponse) - { - $this->headers = array_merge($this->headers, [ - 'Authorization' => "Bearer $authResponse->accessJwt" - ]); - } - - /** - * Retrieves the request body. - * - * @return mixed The request body - * - * @throws RequestBodyHasMissingRequiredFields if the request body is missing required fields - */ - public function getBody() - { - if (! isset($this->body->actor)) { - throw new RequestBodyHasMissingRequiredFields('actor'); - } - - return ['actor' => $this->body->actor]; - } - - public function resource(array $response): ResourceContract - { - return new GetProfileResource($response); - } -} diff --git a/src/API/Com/Atrproto/Repo/CreateRecord.php b/src/API/Com/Atrproto/Repo/CreateRecord.php deleted file mode 100644 index 99cfa3c..0000000 --- a/src/API/Com/Atrproto/Repo/CreateRecord.php +++ /dev/null @@ -1,324 +0,0 @@ - 'application/json', - 'Content-Type' => 'application/json', - ]; - - public function __construct() - { - trigger_error( - "This class deprecated and will be removed in a future version.", - E_USER_DEPRECATED - ); - - $this->body = (object) [ - 'repo' => '', - 'rkey' => '', - 'validate' => true, - 'record' => [], - 'collection' => 'app.bsky.feed.post', - 'swapCommit' => '', - ]; - } - - /** - * Set the repository for the record. - * - * @param string $repo The repository name - * @return $this - * @throws InvalidArgumentException If $repo is not a string - */ - public function setRepo($repo) - { - if (!is_string($repo)) { - throw new InvalidArgumentException("'repo' must be a string"); - } - - $this->body->repo = $repo; - - return $this; - } - - /** - * Get the repository for the record. - * - * @return string The repository name - */ - public function getRepo() - { - return $this->body->repo; - } - - /** - * Set the key for the record. - * - * @param string $rkey The record key - * @return $this - * @throws InvalidArgumentException If $rkey is not a string or its length is invalid - */ - public function setRkey($rkey) - { - if (! is_string($rkey)) { - throw new InvalidArgumentException("'key' must be a string"); - } - - if (strlen($rkey) > 15 || 1 > strlen($rkey)) { - throw new InvalidArgumentException("'key' length must be between 1 and 15 characters"); - } - - $this->body->rkey = $rkey; - - return $this; - } - - /** - * Get the key for the record. - * - * @return string The record key - */ - public function getRkey() - { - return $this->body->rkey; - } - - /** - * Set whether to validate the record. - * - * @param bool $validate Whether to validate the record - * @return $this - * @throws InvalidArgumentException If $validate is not a boolean - */ - public function setValidate($validate) - { - if (! is_bool($validate)) { - throw new InvalidArgumentException("'validate' must be a boolean"); - } - - $this->body->validate = $validate; - - return $this; - } - - /** - * Get whether to validate the record. - * - * @return bool Whether to validate the record - */ - public function getValidate() - { - return $this->body->validate; - } - - /** - * Set the record data. - * - * @param RecordBuilderContract $record The record data - * @return $this - * @throws InvalidArgumentException If the record data is invalid - */ - public function setRecord(RecordBuilderContract $record) - { - $this->body->record = $record->buildRecord(); - - return $this; - } - - /** - * Get the record data. - * - * @return array The record data - */ - public function getRecord() - { - return $this->body->record; - } - - /** - * Set the collection to store the record in. - * - * @param string $collection The collection name - * @return $this - * @throws InvalidArgumentException If $collection is not a string or it is not one of the acceptable collections - */ - public function setCollection($collection) - { - $acceptableCollections = [ - 'app.bsky.feed.post', - 'app.bsky.feed.like', - 'app.bsky.actor.profile', - 'app.bsky.graph.follow' - ]; - - if (! is_string($collection)) { - throw new InvalidArgumentException("'collection' must be a string"); - } - - if (! in_array($collection, $acceptableCollections)) { - throw new InvalidArgumentException("'collection' must be one of '" . implode("', '", $acceptableCollections) . "'"); - } - - $this->body->collection = $collection; - - return $this; - } - - /** - * Get the collection to store the record in. - * - * @return string The collection name - */ - public function getCollection() - { - return $this->body->collection; - } - - /** - * Set the swap commit. - * - * @param mixed $swapCommit The swap commit value - * @return $this - */ - public function swapCommit($swapCommit) - { - $this->body->swapCommit = $swapCommit; - - return $this; - } - - /** - * Get the swap commit. - * - * @return mixed The swap commit value - */ - public function getSwapCommit() - { - return $this->body->swapCommit; - } - - /** - * Get the URI for the request. - * - * @return string The URI - */ - public function getUri() - { - return '/com.atproto.repo.createRecord'; - } - - /** - * Get the HTTP method for the request. - * - * @return string The HTTP method - */ - public function getMethod() - { - return 'POST'; - } - - /** - * Get the headers for the request. - * - * @return array The headers - */ - public function getHeaders() - { - return $this->headers; - } - - /** - * Set the headers for the request. - * - * @param array $headers The header/s for the request. - * @return $this - */ - public function setHeaders(array $headers) - { - $this->headers = array_diff_key( - $this->headers, - array_flip(array_keys($headers)) - ); - - $this->headers = array_merge( - $this->headers, - $headers - ); - - return $this; - } - - /** - * Get the body for the request. - * - * @return string The body - * @throws RequestBodyHasMissingRequiredFields If required fields are missing in the body - */ - public function getBody() - { - $fields = array_filter([ - 'repo' => $this->body->repo, - 'rkey' => $this->body->rkey, - 'validate' => $this->body->validate, - 'record' => $this->body->record, - 'collection' => $this->body->collection, - 'swapCommit' => $this->body->swapCommit, - ]); - - $requiredFields = [ - 'repo', - 'record', - 'collection' - ]; - - $missingFields = array_diff( - $requiredFields, - array_keys($fields) - ); - - if (! empty($missingFields)) { - throw new RequestBodyHasMissingRequiredFields(implode(', ', $missingFields)); - } - - return json_encode($fields); - } - - /** - * Check if authentication is required for the request. - * - * @return bool True if authentication is required, false otherwise - */ - public function authRequired() - { - return true; - } - - /** - * Boot the request with authentication response. - * - * @param mixed $authResponse The authentication response - */ - public function boot($authResponse) - { - $this->headers = array_merge($this->headers, [ - "Authorization" => "Bearer $authResponse->accessJwt" - ]); - - $this->body->repo = $authResponse->did; - } -} diff --git a/src/API/Com/Atrproto/Repo/UploadBlob.php b/src/API/Com/Atrproto/Repo/UploadBlob.php deleted file mode 100644 index 53c9cdd..0000000 --- a/src/API/Com/Atrproto/Repo/UploadBlob.php +++ /dev/null @@ -1,174 +0,0 @@ - '*/*', - 'Accept' => 'application/json', - ]; - - /** - * The class constructor. - * - * @return void - */ - public function __construct() - { - trigger_error( - "This class deprecated and will be removed in a future version.", - E_USER_DEPRECATED - ); - - $this->body = (object) []; - } - - /** - * Set the blob content. - * - * @param string $filePath The path to the blob file. - * @return $this - * @throws InvalidArgumentException If the blob path is invalid or blob size exceeds the maximum allowed. - */ - public function setBlob($filePath) - { - $file = new FileSupport($filePath); - - if (! $file->exists()) { - throw new InvalidArgumentException("File '$filePath' does not exist"); - } - - if (! $file->isFile()) { - throw new InvalidArgumentException("File '$filePath' is not a file"); - } - - $maxSize = 1000000; - if ($file->getFileSize() > $maxSize) { - throw new InvalidArgumentException("File '$filePath' is too big. Max file size is $maxSize bytes."); - } - - $this->body->blob = $file; - - return $this; - } - - /** - * Get the blob content. - * - * @return ?FileSupport The blob content. - */ - public function getBlob() - { - return $this->body->blob; - } - - /** - * Get the body of the request. - * - * @return array The body of the request. - * @throws RequestBodyHasMissingRequiredFields If the blob field is missing. - */ - public function getBody() - { - if (! isset($this->body->blob)) { - throw new RequestBodyHasMissingRequiredFields(implode(', ', ['blob'])); - } - - return $this->body - ->blob - ->getBlob(); - } - - /** - * Get the headers for the request. - * - * @return array The headers for the request. - */ - public function getHeaders() - { - return $this->headers; - } - - /** - * Set the headers for the request. - * - * @param array $headers The header/s for the request. - * @return $this - */ - public function setHeaders(array $headers) - { - $this->headers = array_diff_key( - $this->headers, - array_flip(array_keys($headers)) - ); - - $this->headers = array_merge( - $this->headers, - $headers - ); - - return $this; - } - - /** - * Get the HTTP method for the request. - * - * @return string The HTTP method for the request. - */ - public function getMethod() - { - return 'POST'; - } - - /** - * Get the URI for the request. - * - * @return string The URI for the request. - */ - public function getUri() - { - return '/com.atproto.repo.uploadBlob'; - } - - /** - * Check if authentication is required for the request. - * - * @return bool True if authentication is required, false otherwise. - */ - public function authRequired() - { - return true; - } - - /** - * Boot the request with authentication response. - * - * @param object $authResponse The authentication response. - */ - public function boot($authResponse) - { - $this->headers = array_merge($this->headers, [ - 'Authorization' => "Bearer $authResponse->accessJwt" - ]); - } -} diff --git a/src/API/Traits/ResourceSupport.php b/src/API/Traits/ResourceSupport.php deleted file mode 100644 index 6b428b4..0000000 --- a/src/API/Traits/ResourceSupport.php +++ /dev/null @@ -1,14 +0,0 @@ -validateCredentials($credentials); - $this->credentials = $this->filterCredentials($credentials); - } - - /** - * Validate the provided credentials. - * - * @param array $credentials The credentials to validate - * @throws InvalidArgumentException If required credentials are missing - */ - private function validateCredentials(array $credentials) - { - if (!isset($credentials['identifier']) || !isset($credentials['password'])) { - throw new InvalidArgumentException("Both 'identifier' and 'password' must be provided in credentials"); - } - } - - /** - * Filter the provided credentials to contain only necessary keys. - * - * @param array $credentials The credentials to filter - * @return array Filtered credentials containing only 'identifier' and 'password' - */ - private function filterCredentials(array $credentials) - { - return array_intersect_key( - $credentials, - array_flip(['identifier', 'password']) - ); - } - - /** - * Authenticate using the provided credentials. - * - * @param array $credentials The credentials for authentication - * @return mixed The authentication result - * @throws AuthFailed If authentication fails - */ - public function authenticate(array $credentials) - { - $this->init($credentials); - - $ch = curl_init(); - - curl_setopt_array($ch, [ - CURLOPT_URL => 'https://bsky.social/xrpc/com.atproto.server.createSession', - CURLOPT_RETURNTRANSFER => true, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => json_encode($this->credentials), - CURLOPT_HTTPHEADER => [ - 'Content-Type: application/json', - 'Accept: application/json', - ], - ]); - - $response = curl_exec($ch); - $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($statusCode !== 200) { - throw new AuthFailed("Authentication failed: " . ($response ? json_decode($response)->message : "Unknown error")); - } - - return json_decode($response); - } -} diff --git a/src/Builders/Bluesky/RecordBuilder.php b/src/Builders/Bluesky/RecordBuilder.php deleted file mode 100644 index a4bffd6..0000000 --- a/src/Builders/Bluesky/RecordBuilder.php +++ /dev/null @@ -1,232 +0,0 @@ -record = new stdClass(); - $this->record->text = ""; - - return $this; - } - - /** - * Adds text to the record. - * - * @param string $text The text to be added. - * - * @return $this - * @throws InvalidArgumentException - * - * @deprecated This method deprecated and will be removed in a future version. Use `text()` instead. - */ - public function addText($text) - { - trigger_error( - "This method deprecated and will be removed in a future version. Use `text()` instead.", - E_USER_DEPRECATED - ); - - if (! is_string($text)) { - throw new InvalidArgumentException("'text' must be string"); - } - - $this->record->text = (string) $this->record->text . "$text\n"; - - preg_match_all( - self::$urlRegex, - (string) $this->record->text, - $urlMatches, - PREG_OFFSET_CAPTURE - ); - - if (! empty($urlMatches)) { - $this->record->facets = []; - } - - foreach($urlMatches[0] as $match) { - $url = $match[0]; - $startPos = $match[1]; - $endPos = $startPos + strlen($url); - - $this->record->facets[] = [ - "index" => [ - "byteStart" => $startPos, - "byteEnd" => $endPos, - ], - "features" => [ - [ - "\$type" => "app.bsky.richtext.facet#link", - "uri" => $url - ] - ] - ]; - } - - return $this; - } - - public function text($text) - { - return $this->addText($text); - } - - /** - * Adds type to the record. - * - * @param string $type The type to be added. - * - * @return $this - * @throws InvalidArgumentException - * - * @deprecated This method deprecated and will be removed in a future version. Use `type()` instead. - */ - public function addType($type = 'app.bsky.feed.post') - { - trigger_error( - "This method deprecated and will be removed in a future version. Use `type()` instead.", - E_USER_DEPRECATED - ); - - if (! is_string($type)) { - throw new InvalidArgumentException("'type' must be string"); - } - - $acceptedTypes = ['app.bsky.feed.post']; - - if (! in_array($type, $acceptedTypes)) { - throw new InvalidArgumentException( - "'$type' is not a valid for 'type' value. It can only be one of the following: " . implode(', ', $acceptedTypes) - ); - } - - $this->record->type = $type; - - return $this; - } - - public function type($type = 'app.bsky.feed.post') - { - return $this->addType($type); - } - - /** - * Adds creation date to the record. - * - * @param DateTimeImmutable|null $createdAt The creation date to be added. - * - * @return $this - * @throws InvalidArgumentException - * - * @deprecated This method deprecated and will be removed in a future version. Use `createdAt()` instead. - */ - public function addCreatedAt($createdAt = null) - { - trigger_error( - "This method deprecated and will be removed in a future version. Use `createdAt()` instead.", - E_USER_DEPRECATED - ); - - if (! is_null($createdAt)) { - $createdAt = $createdAt->format('c'); - } else { - $createdAt = date('c'); - } - - $this->record->createdAt = $createdAt; - - return $this; - } - - public function createdAt(DateTimeImmutable $createdAt = null) - { - return $this->addCreatedAt($createdAt); - } - - /** - * Adds image to the record. - * - * @param string $blob The image blob. - * @param string $alt The alternative text for the image. - * - * @return $this - * @throws InvalidArgumentException - * - * @deprecated This method deprecated and will be removed in a future version. Use `image()` instead. - */ - public function addImage($blob, $alt = "") - { - trigger_error( - "This method deprecated and will be removed in a future version. Use `image()` instead.", - E_USER_DEPRECATED - ); - - if (! is_string($alt)) { - throw new InvalidArgumentException("'alt' must be a string"); - } - - if (! isset($this->record->embed)) { - $this->record->embed = (object) [ - "\$type" => "app.bsky.embed.images", - "images" => [] - ]; - } - - $this->record->embed->images[] = [ - "image" => $blob, - "alt" => $alt - ]; - - return $this; - } - - public function image($blob, $alt = "") - { - return $this->addImage($blob, $alt); - } - - /** - * Builds the record. - * - * @return stdClass The built record. - * - * @deprecated This method deprecated and will be removed in a future version. Use `build()` instead. - */ - public function buildRecord() - { - trigger_error( - "This method deprecated and will be removed in a future version. Use `build()` instead.", - E_USER_DEPRECATED - ); - - return $this->record; - } - - - public function build() - { - return $this->buildRecord(); - } -} diff --git a/src/Clients/BlueskyClient.php b/src/Clients/BlueskyClient.php deleted file mode 100644 index cfa6fd4..0000000 --- a/src/Clients/BlueskyClient.php +++ /dev/null @@ -1,279 +0,0 @@ -url = $url; - $this->request = $requestContract; - $this->authenticated = (object) []; - $this->authStrategy = new PasswordAuthentication(); - } - - /** - * Set the authentication strategy for the client. - * - * @param AuthStrategyContract $strategyContract The authentication strategy - * @return $this - * - * @deprecated This method is deprecated and will be removed in a future version. - * Authentication should be handled directly via `authenticate()` with credentials. - */ - public function setStrategy(AuthStrategyContract $strategyContract) - { - trigger_error( - "This method is deprecated and will be removed in a future version. Authentication should be handled directly via `authenticate()` with credentials.", - E_USER_DEPRECATED - ); - - $this->authStrategy = $strategyContract; - return $this; - } - - /** - * Set the request object for the client. - * - * @param RequestContract $requestContract The request object - * @return $this - */ - public function setRequest(RequestContract $requestContract) - { - $this->request = $requestContract; - return $this; - } - - /** - * Authenticate the client with provided credentials. - * - * @param array $credentials The authentication credentials - * @return mixed The authentication result - * @throws RuntimeException If $authStrategy is not set - * @throws AuthFailed If authentication failed - */ - public function authenticate($credentials) - { - if (! $this->authStrategy) { - throw new RuntimeException("You must set an authentication strategy first"); - } - - $this->authenticated = $this->authStrategy - ->authenticate($credentials); - - return $this->authenticated; - } - - /** - * Execute the request. - * - * @return object The response from the API - * @throws cURLException If cURL request fails - * @throws InvalidRequestException If the API request is invalid - * @throws InvalidTokenException If the token used for authentication is invalid - * @throws ExpiredTokenException If the token used for authentication has expired - * @throws AuthRequired If authentication is required for the request but not provided - * @throws UnsupportedHTTPMethod If the HTTP method specified in the request is not supported - * - * @deprecated This method will be renamed in the future for simplicity and to shorten method names. Use 'send()' - * instead. - */ - public function execute(): object - { - trigger_error( - "This method will be renamed in the future for simplicity and to shorten method names. Use 'send()' instead.", - E_USER_DEPRECATED - ); - - if ($this->request->authRequired() && empty($this->authenticated)) { - throw new AuthRequired("You must be authenticated to use this method"); - } - - $this->request->boot($this->authenticated); - - return $this->sendRequest($this->request); - } - - /** - * @throws UnsupportedHTTPMethod - * @throws cURLException - * @throws AuthRequired - * @throws InvalidRequestException - * @throws InvalidTokenException - * @throws ExpiredTokenException - * - * @return ResourceContract|object - */ - public function send(): object - { - if (! in_array(ResourceSupport::class, class_uses($this->request))) { - return $this->execute(); - } - - $response = json_decode( - json_encode($this->execute()), - true - ); - - return $this->request->resource($response); - } - - /** - * Get the request object associated with this client. - * - * @return RequestContract The request object - * - * @deprecated This method will be removed in a future version. Directly manipulate the request object before passing it to the client. Use the appropriate request methods for setting data instead of relying on this method. - */ - public function getRequest() - { - trigger_error( - "The 'getRequest()' method is deprecated and will be removed in a future version. Instead of using this method, please manipulate the request object directly before passing it to the client. Use the request-specific methods to set or modify data as needed.", - E_USER_DEPRECATED - ); - - return $this->request; - } - - /** - * Send the API request. - * - * @param RequestContract $request The request object - * @return object The response from the API - * @throws cURLException If cURL request fails - * @throws InvalidTokenException If the token used for authentication is invalid - * @throws InvalidRequestException If the API request is invalid - * @throws ExpiredTokenException If the token used for authentication has expired - * @throws UnsupportedHTTPMethod If the HTTP method specified in the request is not supported - */ - private function sendRequest($request): object - { - $curl = curl_init(); - - $headers = $request->getHeaders(); - array_walk($headers, function (&$value, $key) { - $value = sprintf('%s: %s', $key, $value); - }); - - curl_setopt_array($curl, [ - CURLOPT_URL => $this->url . $request->getURI(), - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => $headers, - ]); - - $this->setRequestMethod($curl, $request); - - $response = curl_exec($curl); - - if (curl_errno($curl)) { - throw new cURLException(curl_error($curl)); - } - - $response = json_decode($response); - - if (curl_getinfo($curl, CURLINFO_HTTP_CODE) != 200) { - switch ($response->error) { - case "InvalidToken": - throw new InvalidTokenException( - $response->message - ); - - case "InvalidRequest": - throw new InvalidRequestException( - $response->message - ); - - case "ExpiredToken": - throw new ExpiredTokenException( - $response->message - ); - } - } - - curl_close($curl); - - return $response; - } - - /** - * Sets the request method for the cURL handle based on the HTTP method specified in the request object. - * - * @param resource $curl The cURL handle - * @param RequestContract $request The request object - * - * @throws UnsupportedHTTPMethod if the HTTP method specified in the request is not supported - */ - private function setRequestMethod($curl, $request) - { - switch ($request->getMethod()) { - case "POST": - curl_setopt_array($curl, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $request->getBody(), - ]); - break; - case "GET": - curl_setopt( - $curl, - CURLOPT_URL, - sprintf( - '%s%s?%s', - $this->url, - $request->getUri(), - http_build_query($request->getBody()) - ) - ); - break; - default: - throw new UnsupportedHTTPMethod( - "The package does not support this method: " . $request->getMethod() - ); - } - } -} diff --git a/src/Contracts/AuthStrategyContract.php b/src/Contracts/AuthStrategyContract.php deleted file mode 100644 index bf4cf59..0000000 --- a/src/Contracts/AuthStrategyContract.php +++ /dev/null @@ -1,19 +0,0 @@ -getProperty('url'); - $property->setAccessible(true); - - $url = $property->getValue($client); - - $this->assertIsString($url); - $this->assertEquals('https://bsky.social/xrpc', $url); - } - - // Test constructor with custom URL - public function testConstructorWithCustomURL() - { - $expected = "https://shahmal1yev.com/api"; - $client = new BlueskyClient(new CreateRecord(), $expected); - - $reflection = new \ReflectionClass(BlueskyClient::class); - $property = $reflection->getProperty('url'); - $property->setAccessible(true); - - $url = $property->getValue($client); - - $this->assertIsString($url); - $this->assertEquals($expected, $url); - } - - // Test getRequest method - public function testGetRequestMethod() - { - $request = new CreateRecord(); - $client = new BlueskyClient($request); - - $this->assertInstanceOf(RequestContract::class, $client->getRequest()); - $this->assertInstanceOf(CreateRecord::class, $client->getRequest()); - $this->assertSame($request, $client->getRequest()); - } - - // Test authenticate method with valid credentials - public function testAuthenticateWithValidCredentials() - { - $client = new BlueskyClient(new CreateRecord()); - $client->setStrategy(new PasswordAuthentication()); - - $authenticated = $client->authenticate([ - 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], - 'password' => $_ENV["BLUESKY_PASSWORD"] - ]); - - $this->assertIsObject($authenticated); - $this->assertNotNull($authenticated); - $this->assertIsString($authenticated->did); - $this->assertIsString($authenticated->accessJwt); - } - - // Test authenticate method with invalid credentials - public function testAuthenticateWithInvalidCredentials() - { - $this->expectException(AuthFailed::class); - $this->expectExceptionMessage("Authentication failed: "); - - $client = new BlueskyClient(new CreateRecord()); - $client->setStrategy(new PasswordAuthentication()); - - $client->authenticate([ - 'identifier' => 'invalid identifier', - 'password' => 'invalid password' - ]); - } - - // Test execute method with CreateRecord - public function testExecuteWithCreateRecord() - { - $request = new CreateRecord(); - - $recordBuilder = (new RecordBuilder()) - ->addText("Hello World! I am posted from PHP Unit tests for testing this URL adding to this post: \n1. https://www.fs-poster.com \n2. https://github.com/shahmal1yev/blueskysdk \n3. https://github.com/easypay/php-yigim") - ->addType() - ->addCreatedAt(); - - $request->setRecord($recordBuilder); - - $client = new BlueskyClient($request); - - $client->setStrategy(new PasswordAuthentication()) - ->authenticate([ - 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], - 'password' => $_ENV["BLUESKY_PASSWORD"] - ]); - - $response = $client->execute(); - - $this->assertIsObject($response); - $this->assertNotEmpty($response); - $this->assertIsString($response->uri); - $this->assertIsString($response->cid); - } - - // Test execute method with UploadBlob - public function testExecuteWithUploadBlob() - { - $client = new BlueskyClient(new UploadBlob()); - - $client->setStrategy(new PasswordAuthentication()) - ->authenticate([ - 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], - 'password' => $_ENV["BLUESKY_PASSWORD"] - ]); - - $client->getRequest()->setBlob('art/file.png'); - - $response = $client->execute(); - - $this->assertIsObject($response); - $this->assertNotEmpty($response); - } - - // Test execute method with GetProfile - public function testExecuteWithGetProfile() - { - $client = new BlueskyClient(new GetProfile()); - - $client->authenticate([ - 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], - 'password' => $_ENV["BLUESKY_PASSWORD"] - ]); - - $client->getRequest()->setActor('shahmal1yevv.bsky.social'); - - $response = $client->execute(); - - $this->assertIsObject($response); - $this->assertNotNull($response); - $this->assertIsString($response->did); - $this->assertIsString($response->handle); - } - - // Test send method with GetProfile - public function testSendWithGetProfile() - { - $request = new GetProfile(); - - $request->setActor('shahmal1yevv.bsky.social'); - - $client = new BlueskyClient($request); - - $client->authenticate([ - 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], - 'password' => $_ENV["BLUESKY_PASSWORD"] - ]); - - /** @var GetProfileResource $response */ - $response = $client->send(); - - $this->assertIsObject($response); - $this->assertNotNull($response); - $this->assertIsString($response->did()); - $this->assertIsString($response->handle()); - $this->assertInstanceOf(Carbon::class, $response->indexedAt()); - $this->assertInstanceOf(Carbon::class, $response->createdAt()); - - $associated = $response->associated(); - - $this->assertIsInt($associated->lists()); - $this->assertInstanceOf(AssociatedAsset::class, $associated); - } - - /** - * @throws UnsupportedHTTPMethod - * @throws cURLException - * @throws AuthFailed - * @throws AuthRequired - * @throws InvalidRequestException - * @throws ExpiredTokenException - * @throws InvalidTokenException - */ - public function testSendWithRequestWhichHasNotResourceSupport() - { - $request = (new UploadBlob())->setBlob('art/file.png'); - - $client = new BlueskyClient($request); - - $client->authenticate([ - 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], - 'password' => $_ENV["BLUESKY_PASSWORD"] - ]); - - $response = $client->send(); - - $this->assertIsObject($response); - $this->assertNotEmpty($response); - } - - // Test execute method with both UploadBlob and CreateRecord - public function testExecuteWithUploadBlobAndCreateRecord() - { - $client = new BlueskyClient(new UploadBlob()); - - $client->setStrategy(new PasswordAuthentication()) - ->authenticate([ - 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], - 'password' => $_ENV["BLUESKY_PASSWORD"] - ]); - - $client->getRequest() - ->setBlob('art/file.png') - ->setHeaders([ - 'Content-Type' => $client->getRequest() - ->getBlob() - ->getMimeType() - ]); - - $image = $client->execute(); - - $recordBuilder = (new RecordBuilder()) - ->addText("Hello World!") - ->addText("") - ->addText("I was sent to test the inclusion of these URLs in this post:") - ->addText("") - ->addText("1. https://www.fs-poster.com") - ->addText("2. https://github.com/shahmal1yev/blueskysdk") - ->addText("3. https://www.wordpress.php") - ->addType() - ->addImage($image->blob) - ->addImage($image->blob) - ->addCreatedAt(); - - $client->setRequest(new CreateRecord()); - - $client->getRequest()->setRecord($recordBuilder); - - $response = $client->execute(); - - $this->assertIsObject($response); - $this->assertNotEmpty($response); - $this->assertIsString($response->uri); - $this->assertIsString($response->cid); - } - - // Test setStrategy method - public function testSetStrategyMethod() - { - $authStrategy = new PasswordAuthentication(); - $client = new BlueskyClient(new CreateRecord()); - - $client->setStrategy($authStrategy); - - $reflection = new \ReflectionClass($client); - $property = $reflection->getProperty('authStrategy'); - $property->setAccessible(true); - - $this->assertInstanceOf(AuthStrategyContract::class, $property->getValue($client)); - $this->assertInstanceOf(PasswordAuthentication::class, $property->getValue($client)); - $this->assertSame($authStrategy, $property->getValue($client)); - } -} diff --git a/tests/Feature/ClientTest.php b/tests/Feature/ClientTest.php index 79030a7..42c9b9a 100644 --- a/tests/Feature/ClientTest.php +++ b/tests/Feature/ClientTest.php @@ -3,7 +3,7 @@ namespace Tests\Feature; use Atproto\Client; -use Atproto\Contracts\HTTP\Resources\ResourceContract; +use Atproto\Contracts\Resources\ResourceContract; use Atproto\Exceptions\BlueskyException; use Atproto\Exceptions\Http\Response\AuthenticationRequiredException; use Atproto\Exceptions\Http\Response\AuthMissingException; diff --git a/tests/Feature/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php b/tests/Feature/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php deleted file mode 100644 index a524822..0000000 --- a/tests/Feature/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php +++ /dev/null @@ -1,116 +0,0 @@ -client = new Client(); - - $this->request = $this->client - ->app() - ->bsky() - ->actor() - ->getProfiles() - ->forge(); - } - - /** - * @throws BlueskyException - */ - private function authenticate(): void - { - $username = $_ENV['BLUESKY_IDENTIFIER']; - $password = $_ENV['BLUESKY_PASSWORD']; - - $this->assertIsString($username); - $this->assertIsString($password); - - $this->client->authenticate($username, $password); - } - - /** - * @throws InvalidArgumentException - * @throws \Atproto\Exceptions\InvalidArgumentException - * @throws BlueskyException - */ - public function testGettingProfiles(): void - { - $this->authenticate(); - - $profiles = new GenericCollection(new StringType(), [ - $this->client->authenticated()->did() - ]); - - $response = $this->client - ->app() - ->bsky() - ->actor() - ->getProfiles() - ->forge() - ->actors($profiles) - ->build() - ->send(); - - $this->assertInstanceOf(GetProfilesResource::class, $response); - $this->assertSame($profiles->count(), $response->profiles()->count()); - - $actualDidArr = array_map(fn (ProfileAsset $profile) => $profile->did(), $response->profiles()->toArray()); - - $this->assertSame($profiles->toArray(), $actualDidArr); - } - - public function testGettingProfilesThrowsExceptionWhenAuthTokenIsMissing(): void - { - $this->expectException(BlueskyException::class); - $this->expectException(AuthMissingException::class); - $this->expectExceptionMessage("Authentication Required"); - - $this->request->send(); - } - - public function testGettingProfilesThrowsExceptionWhenAuthTokenIsInvalid(): void - { - $this->expectException(BlueskyException::class); - $this->expectException(InvalidTokenException::class); - $this->expectExceptionMessage("Malformed authorization header"); - $this->expectExceptionCode(400); - - $this->request->token('Bearer token')->send(); - } - - public function testGettingProfilesThrowsExceptionWhenActorsMissing(): void - { - $this->expectException(BlueskyException::class); - $this->expectException(MissingFieldProvidedException::class); - $this->expectExceptionMessage("Missing fields provided: actors"); - - $this->authenticate(); - - $request = (new GetProfiles($this->client))->build(); - - $request->send(); - } -} diff --git a/tests/Supports/NonPrimitiveAssetTest.php b/tests/Supports/NonPrimitiveAssetTest.php index 08fd401..21785e2 100644 --- a/tests/Supports/NonPrimitiveAssetTest.php +++ b/tests/Supports/NonPrimitiveAssetTest.php @@ -2,7 +2,7 @@ namespace Tests\Supports; -use Atproto\Contracts\HTTP\Resources\AssetContract; +use Atproto\Contracts\Resources\AssetContract; use GenericCollection\Exceptions\InvalidArgumentException; use ReflectionClass; use ReflectionException; diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php index 7b6347d..b8c3b6a 100644 --- a/tests/Unit/ClientTest.php +++ b/tests/Unit/ClientTest.php @@ -3,7 +3,7 @@ namespace Tests\Unit; use Atproto\Client; -use Atproto\Contracts\RequestContract; +use Atproto\Contracts\Lexicons\RequestContract; use Atproto\Exceptions\Http\Request\RequestNotFoundException; use Atproto\Lexicons\APIRequest; use Atproto\Lexicons\Com\Atproto\Server\CreateSession; diff --git a/tests/Unit/DataModel/Blob/BlobTest.php b/tests/Unit/DataModel/Blob/BlobTest.php index 22a4656..faa71bd 100644 --- a/tests/Unit/DataModel/Blob/BlobTest.php +++ b/tests/Unit/DataModel/Blob/BlobTest.php @@ -6,8 +6,8 @@ use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; use Atproto\IPFS\CID\CID; -use Atproto\MultiFormats\MultiBase\MultiBase; -use Atproto\MultiFormats\MultiCodec; +use Atproto\IPFS\MultiFormats\MultiBase\MultiBase; +use Atproto\IPFS\MultiFormats\MultiCodec; use Atproto\Support\FileSupport; use finfo; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/IPFS/CID/CIDTest.php b/tests/Unit/IPFS/CID/CIDTest.php index 8f92f06..a74d379 100644 --- a/tests/Unit/IPFS/CID/CIDTest.php +++ b/tests/Unit/IPFS/CID/CIDTest.php @@ -4,9 +4,9 @@ use Atproto\IPFS\CID\CID; use Atproto\IPFS\CID\CIDVersion; -use Atproto\MultiFormats\MultiBase\Encoders\Base32Encoder; -use Atproto\MultiFormats\MultiBase\MultiBase; -use Atproto\MultiFormats\MultiCodec; +use Atproto\IPFS\MultiFormats\MultiBase\Encoders\Base32Encoder; +use Atproto\IPFS\MultiFormats\MultiBase\MultiBase; +use Atproto\IPFS\MultiFormats\MultiCodec; use PHPUnit\Framework\TestCase; use Tests\Supports\Reflection; diff --git a/tests/Unit/HTTP/API/APIRequestTest.php b/tests/Unit/Lexicons/APIRequestTest.php similarity index 97% rename from tests/Unit/HTTP/API/APIRequestTest.php rename to tests/Unit/Lexicons/APIRequestTest.php index 6674eee..ce639ce 100644 --- a/tests/Unit/HTTP/API/APIRequestTest.php +++ b/tests/Unit/Lexicons/APIRequestTest.php @@ -1,6 +1,6 @@ Date: Thu, 31 Oct 2024 03:07:34 +0400 Subject: [PATCH 45/59] Refactor: Rename and Standardize Lexicon Interfaces and Traits - **Renaming**: - `RequestNotFoundException` renamed to `LexiconNotFoundException` for consistency in exception naming. - Renamed `type_name` to `nsid` in `PostBuilderContract` to better align with lexicon naming conventions. - `Authentication` trait renamed to `AuthenticatedLexicon` for lexicons that require authentication, improving clarity. - **New Implementations**: - Introduced `LexiconContract` interface on all lexicons for unified structure. - Created a new `Lexicon` trait to prevent code duplication across lexicons. Enhancement: Add Serializable and Endpoint Traits - **New Traits**: - **Serializable**: Applied to all serializable classes to reduce code duplication. - **Endpoint**: Extends `Serializable` and applies to endpoint lexicons. - **AuthenticatedEndpoint**: Dedicated for lexicons that require authentication. - **New Interface - SerializableContract**: - **SerializableContract**: Introduced to support classes needing serialization, currently implementing `Stringable` and `JsonSerializable`. Future support for `dagCbor` is planned. - **LexiconContract**: Extends `SerializableContract` and applies to all lexicon classes within Atproto. Future updates will add `nsid` and other signatures. --- src/Contracts/LexiconBuilder.php | 9 ---- src/Contracts/LexiconContract.php | 2 +- .../App/Bsky/Embed/CaptionContract.php | 5 +- .../App/Bsky/Embed/EmbedInterface.php | 4 +- .../App/Bsky/Embed/ImageInterface.php | 5 +- .../App/Bsky/Feed/PostBuilderContract.php | 5 +- .../App/Bsky/RichText/ByteSliceContract.php | 4 +- .../App/Bsky/RichText/FacetContract.php | 5 +- src/Contracts/SerializableContract.php | 7 +++ ...ption.php => LexiconNotFoundException.php} | 2 +- src/Lexicons/App/Bsky/Actor/GetProfile.php | 7 +-- src/Lexicons/App/Bsky/Actor/GetProfiles.php | 7 +-- src/Lexicons/App/Bsky/Embed/BlobHandler.php | 48 ------------------- src/Lexicons/App/Bsky/Embed/Caption.php | 8 ++-- .../Embed/Collections/CaptionCollection.php | 4 +- .../Embed/Collections/EmbedCollection.php | 8 ++-- src/Lexicons/App/Bsky/Embed/External.php | 9 ++-- src/Lexicons/App/Bsky/Embed/Image.php | 8 ++-- src/Lexicons/App/Bsky/Embed/Record.php | 9 ++-- src/Lexicons/App/Bsky/Embed/Video.php | 9 ++-- src/Lexicons/App/Bsky/Feed/Post.php | 12 ++--- src/Lexicons/App/Bsky/Graph/GetFollowers.php | 6 ++- src/Lexicons/App/Bsky/RichText/ByteSlice.php | 3 ++ src/Lexicons/App/Bsky/RichText/Facet.php | 10 ++-- .../App/Bsky/RichText/FeatureAbstract.php | 5 +- src/Lexicons/App/Bsky/RichText/Tag.php | 2 + src/Lexicons/Com/Atproto/Label/SelfLabels.php | 14 +++--- .../Com/Atproto/Repo/CreateRecord.php | 9 +--- src/Lexicons/Com/Atproto/Repo/StrongRef.php | 13 ++--- src/Lexicons/Com/Atproto/Repo/UploadBlob.php | 6 ++- .../Com/Atproto/Server/CreateSession.php | 7 ++- ...tication.php => AuthenticatedEndpoint.php} | 4 +- src/Lexicons/Traits/Endpoint.php | 21 ++++++++ src/Lexicons/Traits/Serializable.php | 24 ++++++++++ src/Traits/Smith.php | 6 +-- .../Com/Atproto/Repo/CreateRecordTest.php | 4 +- tests/Unit/ClientTest.php | 6 +-- 37 files changed, 153 insertions(+), 164 deletions(-) delete mode 100644 src/Contracts/LexiconBuilder.php create mode 100644 src/Contracts/SerializableContract.php rename src/Exceptions/Http/Request/{RequestNotFoundException.php => LexiconNotFoundException.php} (63%) delete mode 100644 src/Lexicons/App/Bsky/Embed/BlobHandler.php rename src/Lexicons/Traits/{Authentication.php => AuthenticatedEndpoint.php} (94%) create mode 100644 src/Lexicons/Traits/Endpoint.php create mode 100644 src/Lexicons/Traits/Serializable.php diff --git a/src/Contracts/LexiconBuilder.php b/src/Contracts/LexiconBuilder.php deleted file mode 100644 index ae6024f..0000000 --- a/src/Contracts/LexiconBuilder.php +++ /dev/null @@ -1,9 +0,0 @@ -path = $path; - $this->handle(); - } - - /** - * @throws InvalidArgumentException - */ - private function handle(): void - { - $this->isFile(); - $this->isReadable(); - } - - /** - * @throws InvalidArgumentException - */ - private function isFile(): void - { - if (! is_file($this->path)) { - throw new InvalidArgumentException("$this->path is not a file."); - } - } - - /** - * @throws InvalidArgumentException - */ - private function isReadable(): void - { - if (! is_readable($this->path)) { - throw new InvalidArgumentException("$this->path is not readable."); - } - } -} diff --git a/src/Lexicons/App/Bsky/Embed/Caption.php b/src/Lexicons/App/Bsky/Embed/Caption.php index b67fc19..aab4028 100644 --- a/src/Lexicons/App/Bsky/Embed/Caption.php +++ b/src/Lexicons/App/Bsky/Embed/Caption.php @@ -5,9 +5,12 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionContract; use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; +use Atproto\Lexicons\Traits\Serializable; class Caption implements CaptionContract { + use Serializable; + private const MAX_SIZE = 20000; private string $lang; @@ -65,9 +68,4 @@ public function jsonSerialize(): array 'file' => $this->file(), ]; } - - public function __toString(): string - { - return json_encode($this); - } } diff --git a/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php index 68487a5..fd65057 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php @@ -3,13 +3,15 @@ namespace Atproto\Lexicons\App\Bsky\Embed\Collections; use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionContract; +use Atproto\Contracts\SerializableContract; use Atproto\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; use JsonSerializable; -class CaptionCollection extends GenericCollection implements JsonSerializable +class CaptionCollection extends GenericCollection implements SerializableContract { use EmbedCollection; + private const MAX_SIZE = 20; protected function validator(): \Closure diff --git a/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php index e8c9e4c..dd460bc 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php @@ -3,9 +3,12 @@ namespace Atproto\Lexicons\App\Bsky\Embed\Collections; use Atproto\Exceptions\InvalidArgumentException; +use Atproto\Lexicons\Traits\Serializable; trait EmbedCollection { + use Serializable; + /** * @throws InvalidArgumentException */ @@ -42,9 +45,4 @@ public function jsonSerialize(): array { return $this->toArray(); } - - public function __toString(): string - { - return json_encode($this); - } } diff --git a/src/Lexicons/App/Bsky/Embed/External.php b/src/Lexicons/App/Bsky/Embed/External.php index 90b7d78..525f450 100644 --- a/src/Lexicons/App/Bsky/Embed/External.php +++ b/src/Lexicons/App/Bsky/Embed/External.php @@ -6,9 +6,13 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaContract; use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; +use Atproto\Lexicons\Traits\Endpoint; +use Atproto\Lexicons\Traits\Serializable; class External implements EmbedInterface, MediaContract { + use Serializable; + private string $uri; private string $title; private string $description; @@ -100,11 +104,6 @@ public function jsonSerialize(): array ]); } - public function __toString(): string - { - return json_encode($this); - } - public function type(): string { return 'app.bsky.embed.external'; diff --git a/src/Lexicons/App/Bsky/Embed/Image.php b/src/Lexicons/App/Bsky/Embed/Image.php index c2c1ae8..807ea4f 100644 --- a/src/Lexicons/App/Bsky/Embed/Image.php +++ b/src/Lexicons/App/Bsky/Embed/Image.php @@ -5,9 +5,12 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\ImageInterface; use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; +use Atproto\Lexicons\Traits\Serializable; class Image implements ImageInterface { + use Serializable; + private Blob $file; private string $alt; private ?array $aspectRatio = null; @@ -73,9 +76,4 @@ public function size(): int { return $this->file->size(); } - - public function __toString(): string - { - return json_encode($this); - } } diff --git a/src/Lexicons/App/Bsky/Embed/Record.php b/src/Lexicons/App/Bsky/Embed/Record.php index f1cf13b..9cd2d11 100644 --- a/src/Lexicons/App/Bsky/Embed/Record.php +++ b/src/Lexicons/App/Bsky/Embed/Record.php @@ -4,9 +4,13 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; +use Atproto\Lexicons\Traits\Endpoint; +use Atproto\Lexicons\Traits\Serializable; class Record implements EmbedInterface { + use Serializable; + private StrongRef $ref; public function __construct(StrongRef $ref) @@ -14,11 +18,6 @@ public function __construct(StrongRef $ref) $this->ref = $ref; } - public function __toString(): string - { - return json_encode($this); - } - public function jsonSerialize(): array { return [ diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php index a7f96b9..b0413cd 100644 --- a/src/Lexicons/App/Bsky/Embed/Video.php +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -7,9 +7,12 @@ use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; +use Atproto\Lexicons\Traits\Serializable; class Video implements VideoInterface, MediaContract { + use Serializable; + private Blob $file; private ?string $alt = null; private CaptionCollection $captions; @@ -88,12 +91,6 @@ public function captions(CaptionCollection $captions = null) return $this; } - public function __toString(): string - { - $result = json_encode($this); - return $result; - } - public function type(): string { return 'app.bsky.embed.video'; diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php index 9737904..bfc85c7 100644 --- a/src/Lexicons/App/Bsky/Feed/Post.php +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -13,12 +13,15 @@ use Atproto\Lexicons\App\Bsky\RichText\FeatureFactory; use Atproto\Lexicons\Com\Atproto\Label\SelfLabels; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; +use Atproto\Lexicons\Traits\Serializable; use Carbon\Carbon; use DateTimeImmutable; class Post implements PostBuilderContract { - private const TYPE_NAME = 'app.bsky.feed.post'; + use Serializable; + + private const NSID = 'app.bsky.feed.post'; private const TEXT_LIMIT = 3000; private string $text = ''; @@ -200,7 +203,7 @@ public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract public function jsonSerialize(): array { return array_filter([ - '$type' => self::TYPE_NAME, + '$type' => self::NSID, 'createdAt' => $this->getFormattedCreatedAt(), 'text' => $this->text, 'facets' => $this->facets->toArray(), @@ -212,11 +215,6 @@ public function jsonSerialize(): array ]); } - public function __toString(): string - { - return json_encode($this); - } - private function isString($item): bool { return is_scalar($item); diff --git a/src/Lexicons/App/Bsky/Graph/GetFollowers.php b/src/Lexicons/App/Bsky/Graph/GetFollowers.php index 7e8a890..c2a6040 100644 --- a/src/Lexicons/App/Bsky/Graph/GetFollowers.php +++ b/src/Lexicons/App/Bsky/Graph/GetFollowers.php @@ -2,15 +2,19 @@ namespace Atproto\Lexicons\App\Bsky\Graph; +use Atproto\Contracts\LexiconContract; use Atproto\Contracts\Lexicons\RequestContract; use Atproto\Contracts\Resources\ResourceContract; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\APIRequest; +use Atproto\Lexicons\Traits\Endpoint; use Atproto\Resources\App\Bsky\Graph\GetFollowersResource; -class GetFollowers extends APIRequest +class GetFollowers extends APIRequest implements LexiconContract { + use Endpoint; + public function actor(string $actor = null) { if (is_null($actor)) { diff --git a/src/Lexicons/App/Bsky/RichText/ByteSlice.php b/src/Lexicons/App/Bsky/RichText/ByteSlice.php index 75884ec..8aa1393 100644 --- a/src/Lexicons/App/Bsky/RichText/ByteSlice.php +++ b/src/Lexicons/App/Bsky/RichText/ByteSlice.php @@ -3,9 +3,12 @@ namespace Atproto\Lexicons\App\Bsky\RichText; use Atproto\Contracts\Lexicons\App\Bsky\RichText\ByteSliceContract; +use Atproto\Lexicons\Traits\Serializable; class ByteSlice implements ByteSliceContract { + use Serializable; + private string $text; private string $added; diff --git a/src/Lexicons/App/Bsky/RichText/Facet.php b/src/Lexicons/App/Bsky/RichText/Facet.php index dfbb9a4..3b868c4 100644 --- a/src/Lexicons/App/Bsky/RichText/Facet.php +++ b/src/Lexicons/App/Bsky/RichText/Facet.php @@ -5,9 +5,12 @@ use Atproto\Collections\FeatureCollection; use Atproto\Contracts\Lexicons\App\Bsky\RichText\ByteSliceContract; use Atproto\Contracts\Lexicons\App\Bsky\RichText\FacetContract; +use Atproto\Lexicons\Traits\Serializable; class Facet implements FacetContract { + use Serializable; + private ByteSliceContract $byteSlice; private FeatureCollection $features; @@ -39,9 +42,4 @@ public function jsonSerialize(): array 'features' => $this->features->toArray() ]; } - - public function __toString(): string - { - return json_encode($this); - } -} +} \ No newline at end of file diff --git a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php index bb50204..5f48ad3 100644 --- a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php +++ b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php @@ -2,10 +2,9 @@ namespace Atproto\Lexicons\App\Bsky\RichText; -use Atproto\Contracts\LexiconBuilder; -use Atproto\Contracts\Stringable; +use Atproto\Contracts\LexiconContract; -abstract class FeatureAbstract implements LexiconBuilder, Stringable +abstract class FeatureAbstract implements LexiconContract { protected string $reference; protected string $label; diff --git a/src/Lexicons/App/Bsky/RichText/Tag.php b/src/Lexicons/App/Bsky/RichText/Tag.php index 9f3291e..a2d39d9 100644 --- a/src/Lexicons/App/Bsky/RichText/Tag.php +++ b/src/Lexicons/App/Bsky/RichText/Tag.php @@ -2,6 +2,8 @@ namespace Atproto\Lexicons\App\Bsky\RichText; +use Atproto\Lexicons\Traits\Serializable; + class Tag extends FeatureAbstract { protected function schema(): array diff --git a/src/Lexicons/Com/Atproto/Label/SelfLabels.php b/src/Lexicons/Com/Atproto/Label/SelfLabels.php index 60489a2..9ab75a6 100644 --- a/src/Lexicons/Com/Atproto/Label/SelfLabels.php +++ b/src/Lexicons/Com/Atproto/Label/SelfLabels.php @@ -2,13 +2,16 @@ namespace Atproto\Lexicons\Com\Atproto\Label; -use Atproto\Contracts\Stringable; +use Atproto\Contracts\LexiconContract; +use Atproto\Contracts\SerializableContract; use Atproto\Exceptions\InvalidArgumentException; +use Atproto\Lexicons\Traits\Serializable; use GenericCollection\GenericCollection; -use JsonSerializable; -class SelfLabels extends GenericCollection implements JsonSerializable, Stringable +class SelfLabels extends GenericCollection implements LexiconContract { + use Serializable; + private const MAXLENGTH = 10; private const MAXLENGTH_BY_ITEM = 128; @@ -57,11 +60,6 @@ private function throw($exception): void ); } - public function __toString(): string - { - return json_encode($this); - } - public function jsonSerialize(): array { return $this->toArray(); diff --git a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php index 151536f..b863619 100644 --- a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php +++ b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php @@ -9,12 +9,12 @@ use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\APIRequest; -use Atproto\Lexicons\Traits\Authentication; +use Atproto\Lexicons\Traits\AuthenticatedEndpoint; use Atproto\Resources\Com\Atproto\Repo\CreateRecordResource; class CreateRecord extends APIRequest implements LexiconContract { - use Authentication; + use AuthenticatedEndpoint; protected array $required = [ 'repo', @@ -125,11 +125,6 @@ public function resource(array $data): ResourceContract return new CreateRecordResource($data); } - public function __toString(): string - { - return json_encode($this); - } - public function jsonSerialize(): array { return array_filter([ diff --git a/src/Lexicons/Com/Atproto/Repo/StrongRef.php b/src/Lexicons/Com/Atproto/Repo/StrongRef.php index 037a584..39bc177 100644 --- a/src/Lexicons/Com/Atproto/Repo/StrongRef.php +++ b/src/Lexicons/Com/Atproto/Repo/StrongRef.php @@ -2,11 +2,13 @@ namespace Atproto\Lexicons\Com\Atproto\Repo; -use Atproto\Contracts\Stringable; -use JsonSerializable; +use Atproto\Contracts\LexiconContract; +use Atproto\Lexicons\Traits\Serializable; -class StrongRef implements JsonSerializable, Stringable +class StrongRef implements LexiconContract { + use Serializable; + private string $uri; private string $cid; @@ -45,9 +47,4 @@ public function jsonSerialize(): array 'cid' => $this->cid, ]; } - - public function __toString(): string - { - return json_encode($this); - } } diff --git a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php index 10cb7ed..6d9a1fc 100644 --- a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php +++ b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php @@ -2,17 +2,21 @@ namespace Atproto\Lexicons\Com\Atproto\Repo; +use Atproto\Contracts\LexiconContract; use Atproto\Contracts\Lexicons\RequestContract; use Atproto\Contracts\Resources\ResourceContract; use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\APIRequest; +use Atproto\Lexicons\Traits\AuthenticatedEndpoint; use Atproto\Resources\Com\Atproto\Repo\UploadBlobResource; use Atproto\Support\FileSupport; -class UploadBlob extends APIRequest +class UploadBlob extends APIRequest implements LexiconContract { + use AuthenticatedEndpoint; + protected function initialize(): void { parent::initialize(); diff --git a/src/Lexicons/Com/Atproto/Server/CreateSession.php b/src/Lexicons/Com/Atproto/Server/CreateSession.php index c5844c7..ebb85db 100644 --- a/src/Lexicons/Com/Atproto/Server/CreateSession.php +++ b/src/Lexicons/Com/Atproto/Server/CreateSession.php @@ -3,13 +3,18 @@ namespace Atproto\Lexicons\Com\Atproto\Server; use Atproto\Client; +use Atproto\Contracts\LexiconContract; use Atproto\Contracts\Lexicons\RequestContract; use Atproto\Contracts\Resources\ResourceContract; use Atproto\Lexicons\APIRequest; +use Atproto\Lexicons\Traits\Endpoint; +use Atproto\Lexicons\Traits\Serializable; use Atproto\Resources\Com\Atproto\Server\CreateSessionResource; -class CreateSession extends APIRequest +class CreateSession extends APIRequest implements LexiconContract { + use Endpoint; + public function __construct(Client $client, string $identifier, string $password) { parent::__construct($client); diff --git a/src/Lexicons/Traits/Authentication.php b/src/Lexicons/Traits/AuthenticatedEndpoint.php similarity index 94% rename from src/Lexicons/Traits/Authentication.php rename to src/Lexicons/Traits/AuthenticatedEndpoint.php index 4aeadd7..ec63d6a 100644 --- a/src/Lexicons/Traits/Authentication.php +++ b/src/Lexicons/Traits/AuthenticatedEndpoint.php @@ -6,8 +6,10 @@ use Atproto\Lexicons\APIRequest; use SplSubject; -trait Authentication +trait AuthenticatedEndpoint { + use Endpoint; + public function __construct(Client $client) { if (! is_subclass_of(static::class, APIRequest::class)) { diff --git a/src/Lexicons/Traits/Endpoint.php b/src/Lexicons/Traits/Endpoint.php new file mode 100644 index 0000000..7983ac9 --- /dev/null +++ b/src/Lexicons/Traits/Endpoint.php @@ -0,0 +1,21 @@ + $this->url(), + 'origin' => $this->origin(), + 'path' => $this->path(), + 'method' => $this->method(), + 'headers' => $this->headers(), + 'parameters' => $this->parameters(), + 'queryParameters' => $this->queryParameters(), + ]; + } +} diff --git a/src/Lexicons/Traits/Serializable.php b/src/Lexicons/Traits/Serializable.php new file mode 100644 index 0000000..1e16100 --- /dev/null +++ b/src/Lexicons/Traits/Serializable.php @@ -0,0 +1,24 @@ +request(); if (! class_exists($request)) { - throw new RequestNotFoundException("$request class does not exist."); + throw new LexiconNotFoundException("$request class does not exist."); } /** @var APIRequestContract $request */ diff --git a/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php index 0fd2434..e11a98d 100644 --- a/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php +++ b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php @@ -23,8 +23,8 @@ public static function setUpBeforeClass(): void static::$client = new Client(); static::$client->authenticate( - getenv('BLUESKY_IDENTIFIER'), - getenv('BLUESKY_PASSWORD') + 'shahmal1yevv.bsky.social', + 'ucvlqcq8' ); } diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php index b8c3b6a..c19859e 100644 --- a/tests/Unit/ClientTest.php +++ b/tests/Unit/ClientTest.php @@ -4,7 +4,7 @@ use Atproto\Client; use Atproto\Contracts\Lexicons\RequestContract; -use Atproto\Exceptions\Http\Request\RequestNotFoundException; +use Atproto\Exceptions\Http\Request\LexiconNotFoundException; use Atproto\Lexicons\APIRequest; use Atproto\Lexicons\Com\Atproto\Server\CreateSession; use Atproto\Resources\Com\Atproto\Server\CreateSessionResource; @@ -56,14 +56,14 @@ public function testForgeThrowsRequestNotFoundException(): void { $this->client->nonExistentMethod(); - $this->expectException(RequestNotFoundException::class); + $this->expectException(LexiconNotFoundException::class); $this->expectExceptionMessage("Atproto\\Lexicons\\NonExistentMethod class does not exist."); $this->client->forge(); } /** - * @throws RequestNotFoundException + * @throws LexiconNotFoundException */ public function testForgeReturnsRequestContract(): void { From 8a264a66e667f24e7f24662ae68b26f1a0a34ffa Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 3 Nov 2024 05:52:13 +0400 Subject: [PATCH 46/59] refactor: standardize NSID handling with Lexicon trait --- src/Contracts/LexiconContract.php | 1 + .../App/Bsky/Embed/EmbedInterface.php | 1 - src/Lexicons/APIRequest.php | 19 +------------------ .../Embed/Collections/CaptionCollection.php | 8 +++++++- .../Embed/Collections/EmbedCollection.php | 8 -------- .../Embed/Collections/ImageCollection.php | 14 ++++++++------ src/Lexicons/App/Bsky/Embed/External.php | 12 +++--------- src/Lexicons/App/Bsky/Embed/Record.php | 12 +++--------- .../App/Bsky/Embed/RecordWithMedia.php | 7 +------ src/Lexicons/App/Bsky/Embed/Video.php | 11 +++-------- src/Lexicons/App/Bsky/Feed/Post.php | 10 +++++----- src/Lexicons/App/Bsky/RichText/Facet.php | 6 +++--- .../App/Bsky/RichText/FeatureAbstract.php | 7 ++++--- src/Lexicons/App/Bsky/RichText/Link.php | 5 ----- src/Lexicons/App/Bsky/RichText/Mention.php | 7 ------- src/Lexicons/App/Bsky/RichText/Tag.php | 7 ------- src/Lexicons/Com/Atproto/Label/SelfLabels.php | 3 +-- .../Com/Atproto/Repo/CreateRecord.php | 7 ------- src/Lexicons/Com/Atproto/Repo/StrongRef.php | 4 ++-- src/Lexicons/Com/Atproto/Repo/UploadBlob.php | 8 +++++--- .../Com/Atproto/Server/CreateSession.php | 3 +-- src/Lexicons/Traits/AuthenticatedEndpoint.php | 10 ++++++++++ src/Lexicons/Traits/Endpoint.php | 12 +++++++++++- src/Lexicons/Traits/Lexicon.php | 18 ++++++++++++++++++ .../App/Bsky/RichText/FeatureAbstractTest.php | 4 ++-- .../App/Bsky/RichText/FeatureTests.php | 2 +- 26 files changed, 90 insertions(+), 116 deletions(-) create mode 100644 src/Lexicons/Traits/Lexicon.php diff --git a/src/Contracts/LexiconContract.php b/src/Contracts/LexiconContract.php index 919b16d..6ba2a7d 100644 --- a/src/Contracts/LexiconContract.php +++ b/src/Contracts/LexiconContract.php @@ -4,4 +4,5 @@ interface LexiconContract extends SerializableContract { + public function nsid(): string; } diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php index e305425..18732d6 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php @@ -6,5 +6,4 @@ interface EmbedInterface extends LexiconContract { - public function type(): string; } diff --git a/src/Lexicons/APIRequest.php b/src/Lexicons/APIRequest.php index ef82636..e1fc6d3 100644 --- a/src/Lexicons/APIRequest.php +++ b/src/Lexicons/APIRequest.php @@ -22,24 +22,7 @@ public function send(): ResourceContract return $this->resource(parent::send()); } - protected function initialize(): void - { - $this->origin(self::API_BASE_URL) - ->path($this->endpoint()) - ->headers(self::API_BASE_HEADERS); - } - - private function endpoint(): string - { - $endpointParts = explode("\\", $this->client->path()); - - $endpoint = array_reduce( - $endpointParts, - fn ($carry, $part) => $carry .= "." . lcfirst($part) - ); - - return sprintf("/xrpc/%s", trim($endpoint, ".")); - } + abstract protected function initialize(): void; abstract public function resource(array $data): ResourceContract; diff --git a/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php index fd65057..c8bd575 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php @@ -5,11 +5,12 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionContract; use Atproto\Contracts\SerializableContract; use Atproto\Exceptions\InvalidArgumentException; +use Atproto\Lexicons\Traits\Serializable; use GenericCollection\GenericCollection; -use JsonSerializable; class CaptionCollection extends GenericCollection implements SerializableContract { + use Serializable; use EmbedCollection; private const MAX_SIZE = 20; @@ -24,4 +25,9 @@ protected function validator(): \Closure return true; }; } + + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php index dd460bc..5354c99 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php @@ -3,12 +3,9 @@ namespace Atproto\Lexicons\App\Bsky\Embed\Collections; use Atproto\Exceptions\InvalidArgumentException; -use Atproto\Lexicons\Traits\Serializable; trait EmbedCollection { - use Serializable; - /** * @throws InvalidArgumentException */ @@ -40,9 +37,4 @@ private function throw($exception): void } abstract protected function validator(): \Closure; - - public function jsonSerialize(): array - { - return $this->toArray(); - } } diff --git a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php index 15800bc..8000f6e 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php @@ -5,11 +5,13 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\ImageInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaContract; +use Atproto\Lexicons\Traits\Lexicon; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; class ImageCollection extends GenericCollection implements EmbedInterface, MediaContract { + use Lexicon; use EmbedCollection; private const MAX_LENGTH = 4; @@ -30,16 +32,16 @@ protected function validator(): \Closure }; } - public function type(): string - { - return 'app.bsky.embed.images'; - } - public function jsonSerialize(): array { return [ 'images' => $this->toArray(), - '$type' => $this->type(), + '$type' => $this->nsid(), ]; } + + public function nsid(): string + { + return 'app.bsky.embed.images'; + } } diff --git a/src/Lexicons/App/Bsky/Embed/External.php b/src/Lexicons/App/Bsky/Embed/External.php index 525f450..b57e883 100644 --- a/src/Lexicons/App/Bsky/Embed/External.php +++ b/src/Lexicons/App/Bsky/Embed/External.php @@ -6,12 +6,11 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaContract; use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; -use Atproto\Lexicons\Traits\Endpoint; -use Atproto\Lexicons\Traits\Serializable; +use Atproto\Lexicons\Traits\Lexicon; class External implements EmbedInterface, MediaContract { - use Serializable; + use Lexicon; private string $uri; private string $title; @@ -96,16 +95,11 @@ public function thumb(Blob $blob = null) public function jsonSerialize(): array { return array_filter([ - '$type' => $this->type(), + '$type' => $this->nsid(), 'uri' => $this->uri, 'title' => $this->title, 'description' => $this->description, 'blob' => ($b = $this->blob) ? $b : null, ]); } - - public function type(): string - { - return 'app.bsky.embed.external'; - } } diff --git a/src/Lexicons/App/Bsky/Embed/Record.php b/src/Lexicons/App/Bsky/Embed/Record.php index 9cd2d11..bf1131c 100644 --- a/src/Lexicons/App/Bsky/Embed/Record.php +++ b/src/Lexicons/App/Bsky/Embed/Record.php @@ -4,12 +4,11 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; -use Atproto\Lexicons\Traits\Endpoint; -use Atproto\Lexicons\Traits\Serializable; +use Atproto\Lexicons\Traits\Lexicon; class Record implements EmbedInterface { - use Serializable; + use Lexicon; private StrongRef $ref; @@ -21,13 +20,8 @@ public function __construct(StrongRef $ref) public function jsonSerialize(): array { return [ - '$type' => $this->type(), + '$type' => $this->nsid(), 'record' => $this->ref, ]; } - - public function type(): string - { - return 'app.bsky.embed.record'; - } } diff --git a/src/Lexicons/App/Bsky/Embed/RecordWithMedia.php b/src/Lexicons/App/Bsky/Embed/RecordWithMedia.php index 509abf0..498f3fc 100644 --- a/src/Lexicons/App/Bsky/Embed/RecordWithMedia.php +++ b/src/Lexicons/App/Bsky/Embed/RecordWithMedia.php @@ -29,13 +29,8 @@ public function media(MediaContract $media = null) public function jsonSerialize(): array { return array_merge(parent::jsonSerialize(), [ - '$type' => $this->type(), + '$type' => $this->nsid(), 'media' => $this->media, ]); } - - public function type(): string - { - return 'app.bsky.embed.recordWithMedia'; - } } diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php index b0413cd..43aee4e 100644 --- a/src/Lexicons/App/Bsky/Embed/Video.php +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -7,11 +7,11 @@ use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; -use Atproto\Lexicons\Traits\Serializable; +use Atproto\Lexicons\Traits\Lexicon; class Video implements VideoInterface, MediaContract { - use Serializable; + use Lexicon; private Blob $file; private ?string $alt = null; @@ -38,7 +38,7 @@ public function __construct(Blob $file) public function jsonSerialize(): array { $result = array_filter([ - '$type' => $this->type(), + '$type' => $this->nsid(), 'alt' => $this->alt() ?: null, 'video' => $this->file, 'aspectRatio' => $this->aspectRatio() ?: null, @@ -90,9 +90,4 @@ public function captions(CaptionCollection $captions = null) return $this; } - - public function type(): string - { - return 'app.bsky.embed.video'; - } } diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php index bfc85c7..1277fac 100644 --- a/src/Lexicons/App/Bsky/Feed/Post.php +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -13,15 +13,13 @@ use Atproto\Lexicons\App\Bsky\RichText\FeatureFactory; use Atproto\Lexicons\Com\Atproto\Label\SelfLabels; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; -use Atproto\Lexicons\Traits\Serializable; +use Atproto\Lexicons\Traits\Lexicon; use Carbon\Carbon; use DateTimeImmutable; class Post implements PostBuilderContract { - use Serializable; - - private const NSID = 'app.bsky.feed.post'; + use Lexicon; private const TEXT_LIMIT = 3000; private string $text = ''; @@ -150,6 +148,8 @@ public function tags(array $tags): PostBuilderContract if (mb_strlen($tag) > 640) { return true; } + + return false; }); if (! empty($invalid)) { @@ -203,7 +203,7 @@ public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract public function jsonSerialize(): array { return array_filter([ - '$type' => self::NSID, + '$type' => $this->nsid(), 'createdAt' => $this->getFormattedCreatedAt(), 'text' => $this->text, 'facets' => $this->facets->toArray(), diff --git a/src/Lexicons/App/Bsky/RichText/Facet.php b/src/Lexicons/App/Bsky/RichText/Facet.php index 3b868c4..29ac758 100644 --- a/src/Lexicons/App/Bsky/RichText/Facet.php +++ b/src/Lexicons/App/Bsky/RichText/Facet.php @@ -5,11 +5,11 @@ use Atproto\Collections\FeatureCollection; use Atproto\Contracts\Lexicons\App\Bsky\RichText\ByteSliceContract; use Atproto\Contracts\Lexicons\App\Bsky\RichText\FacetContract; -use Atproto\Lexicons\Traits\Serializable; +use Atproto\Lexicons\Traits\Lexicon; class Facet implements FacetContract { - use Serializable; + use Lexicon; private ByteSliceContract $byteSlice; @@ -42,4 +42,4 @@ public function jsonSerialize(): array 'features' => $this->features->toArray() ]; } -} \ No newline at end of file +} diff --git a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php index 5f48ad3..62657bb 100644 --- a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php +++ b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php @@ -3,9 +3,12 @@ namespace Atproto\Lexicons\App\Bsky\RichText; use Atproto\Contracts\LexiconContract; +use Atproto\Lexicons\Traits\Lexicon; abstract class FeatureAbstract implements LexiconContract { + use Lexicon; + protected string $reference; protected string $label; @@ -21,10 +24,8 @@ public function __construct(string $reference, string $label = null) final public function jsonSerialize(): array { - return ['type' => $this->type()] + $this->schema(); + return ['type' => $this->nsid()] + $this->schema(); } - abstract protected function type(): string; - abstract protected function schema(): array; } diff --git a/src/Lexicons/App/Bsky/RichText/Link.php b/src/Lexicons/App/Bsky/RichText/Link.php index f23945e..80ceebd 100644 --- a/src/Lexicons/App/Bsky/RichText/Link.php +++ b/src/Lexicons/App/Bsky/RichText/Link.php @@ -16,9 +16,4 @@ public function __toString(): string { return $this->label; } - - protected function type(): string - { - return "link"; - } } diff --git a/src/Lexicons/App/Bsky/RichText/Mention.php b/src/Lexicons/App/Bsky/RichText/Mention.php index 989626b..2b12f6b 100644 --- a/src/Lexicons/App/Bsky/RichText/Mention.php +++ b/src/Lexicons/App/Bsky/RichText/Mention.php @@ -4,8 +4,6 @@ class Mention extends FeatureAbstract { - protected ?string $type = 'mention'; - protected function schema(): array { return [ @@ -18,9 +16,4 @@ public function __toString(): string { return "@$this->label"; } - - protected function type(): string - { - return 'mention'; - } } diff --git a/src/Lexicons/App/Bsky/RichText/Tag.php b/src/Lexicons/App/Bsky/RichText/Tag.php index a2d39d9..d56b27b 100644 --- a/src/Lexicons/App/Bsky/RichText/Tag.php +++ b/src/Lexicons/App/Bsky/RichText/Tag.php @@ -2,8 +2,6 @@ namespace Atproto\Lexicons\App\Bsky\RichText; -use Atproto\Lexicons\Traits\Serializable; - class Tag extends FeatureAbstract { protected function schema(): array @@ -18,9 +16,4 @@ public function __toString(): string { return "#$this->label"; } - - protected function type(): string - { - return 'tag'; - } } diff --git a/src/Lexicons/Com/Atproto/Label/SelfLabels.php b/src/Lexicons/Com/Atproto/Label/SelfLabels.php index 9ab75a6..ff56887 100644 --- a/src/Lexicons/Com/Atproto/Label/SelfLabels.php +++ b/src/Lexicons/Com/Atproto/Label/SelfLabels.php @@ -2,13 +2,12 @@ namespace Atproto\Lexicons\Com\Atproto\Label; -use Atproto\Contracts\LexiconContract; use Atproto\Contracts\SerializableContract; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\Traits\Serializable; use GenericCollection\GenericCollection; -class SelfLabels extends GenericCollection implements LexiconContract +class SelfLabels extends GenericCollection implements SerializableContract { use Serializable; diff --git a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php index b863619..43d1040 100644 --- a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php +++ b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php @@ -22,13 +22,6 @@ class CreateRecord extends APIRequest implements LexiconContract 'record' ]; - protected function initialize(): void - { - parent::initialize(); - - $this->method('POST'); - } - public function repo(string $repo = null) { if (is_null($repo)) { diff --git a/src/Lexicons/Com/Atproto/Repo/StrongRef.php b/src/Lexicons/Com/Atproto/Repo/StrongRef.php index 39bc177..b4c07ea 100644 --- a/src/Lexicons/Com/Atproto/Repo/StrongRef.php +++ b/src/Lexicons/Com/Atproto/Repo/StrongRef.php @@ -3,11 +3,11 @@ namespace Atproto\Lexicons\Com\Atproto\Repo; use Atproto\Contracts\LexiconContract; -use Atproto\Lexicons\Traits\Serializable; +use Atproto\Lexicons\Traits\Lexicon; class StrongRef implements LexiconContract { - use Serializable; + use Lexicon; private string $uri; private string $cid; diff --git a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php index 6d9a1fc..06caf6a 100644 --- a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php +++ b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php @@ -19,9 +19,11 @@ class UploadBlob extends APIRequest implements LexiconContract protected function initialize(): void { - parent::initialize(); - $this->method('POST') - ->header('Content-Type', '*/*'); + $this->origin(self::API_BASE_URL) + ->headers(self::API_BASE_HEADERS) + ->header('Content-Type', '*/*') + ->path(sprintf("/xrpc/%s", $this->nsid())) + ->method($this->method); } /** diff --git a/src/Lexicons/Com/Atproto/Server/CreateSession.php b/src/Lexicons/Com/Atproto/Server/CreateSession.php index ebb85db..8acc0f5 100644 --- a/src/Lexicons/Com/Atproto/Server/CreateSession.php +++ b/src/Lexicons/Com/Atproto/Server/CreateSession.php @@ -8,7 +8,6 @@ use Atproto\Contracts\Resources\ResourceContract; use Atproto\Lexicons\APIRequest; use Atproto\Lexicons\Traits\Endpoint; -use Atproto\Lexicons\Traits\Serializable; use Atproto\Resources\Com\Atproto\Server\CreateSessionResource; class CreateSession extends APIRequest implements LexiconContract @@ -19,7 +18,7 @@ public function __construct(Client $client, string $identifier, string $password { parent::__construct($client); - $this->method('POST')->origin('https://bsky.social/')->parameters([ + $this->parameters([ 'identifier' => $identifier, 'password' => $password, ]); diff --git a/src/Lexicons/Traits/AuthenticatedEndpoint.php b/src/Lexicons/Traits/AuthenticatedEndpoint.php index ec63d6a..e347c65 100644 --- a/src/Lexicons/Traits/AuthenticatedEndpoint.php +++ b/src/Lexicons/Traits/AuthenticatedEndpoint.php @@ -16,6 +16,8 @@ public function __construct(Client $client) return; } + $this->method = 'POST'; + parent::__construct($client); $this->update($client); } @@ -40,4 +42,12 @@ public function token(string $token = null) return $this; } + + protected function initialize(): void + { + $this->origin(self::API_BASE_URL) + ->headers(self::API_BASE_HEADERS) + ->path(sprintf("/xrpc/%s", $this->nsid())) + ->method($this->method); + } } diff --git a/src/Lexicons/Traits/Endpoint.php b/src/Lexicons/Traits/Endpoint.php index 7983ac9..2dcd99c 100644 --- a/src/Lexicons/Traits/Endpoint.php +++ b/src/Lexicons/Traits/Endpoint.php @@ -4,7 +4,9 @@ trait Endpoint { - use Serializable; + use Lexicon; + + protected string $method = 'GET'; public function jsonSerialize(): array { @@ -18,4 +20,12 @@ public function jsonSerialize(): array 'queryParameters' => $this->queryParameters(), ]; } + + protected function initialize(): void + { + $this->origin(self::API_BASE_URL) + ->headers(self::API_BASE_HEADERS) + ->path(sprintf("/xrpc/%s", $this->nsid())) + ->method($this->method); + } } diff --git a/src/Lexicons/Traits/Lexicon.php b/src/Lexicons/Traits/Lexicon.php new file mode 100644 index 0000000..190df89 --- /dev/null +++ b/src/Lexicons/Traits/Lexicon.php @@ -0,0 +1,18 @@ +getMockBuilder(FeatureAbstract::class) ->setConstructorArgs(['reference', 'label']) - ->onlyMethods(['schema', 'type']) + ->onlyMethods(['schema', 'nsid']) ->getMockForAbstractClass(); $schema = ['key' => 'value']; @@ -42,7 +42,7 @@ public function testJsonSerializeReturnsCorrectArray() ->willReturn($schema); $mock->expects($this->once()) - ->method('type') + ->method('nsid') ->willReturn('feature'); $this->assertSame( diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php index b6237d5..1cad091 100644 --- a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php +++ b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php @@ -25,7 +25,7 @@ public function setUp(): void public function testTypeReturnsCorrectType(): void { $feature = $this->feature($this->reference); - $method = $this->method('type', $feature); + $method = $this->method('nsid', $feature); $expected = $this->type; $this->assertSame( From 5cfc5db4ece634372400e5bdd86ee7c2b6f46e4d Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 3 Nov 2024 05:59:41 +0400 Subject: [PATCH 47/59] fix: refactor RichText feature serialization because they are not a lexicon --- .../App/Bsky/RichText/FeatureAbstract.php | 7 ------- src/Lexicons/App/Bsky/RichText/Link.php | 2 +- src/Lexicons/App/Bsky/RichText/Mention.php | 2 +- src/Lexicons/App/Bsky/RichText/Tag.php | 2 +- .../App/Bsky/RichText/FeatureAbstractTest.php | 11 +++-------- .../Lexicons/App/Bsky/RichText/FeatureTests.php | 17 ----------------- 6 files changed, 6 insertions(+), 35 deletions(-) diff --git a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php index 62657bb..97593a7 100644 --- a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php +++ b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php @@ -21,11 +21,4 @@ public function __construct(string $reference, string $label = null) $this->reference = $reference; $this->label = $label; } - - final public function jsonSerialize(): array - { - return ['type' => $this->nsid()] + $this->schema(); - } - - abstract protected function schema(): array; } diff --git a/src/Lexicons/App/Bsky/RichText/Link.php b/src/Lexicons/App/Bsky/RichText/Link.php index 80ceebd..1f8afc0 100644 --- a/src/Lexicons/App/Bsky/RichText/Link.php +++ b/src/Lexicons/App/Bsky/RichText/Link.php @@ -4,7 +4,7 @@ class Link extends FeatureAbstract { - protected function schema(): array + public function jsonSerialize(): array { return [ "label" => $this->label, diff --git a/src/Lexicons/App/Bsky/RichText/Mention.php b/src/Lexicons/App/Bsky/RichText/Mention.php index 2b12f6b..9c499d1 100644 --- a/src/Lexicons/App/Bsky/RichText/Mention.php +++ b/src/Lexicons/App/Bsky/RichText/Mention.php @@ -4,7 +4,7 @@ class Mention extends FeatureAbstract { - protected function schema(): array + public function jsonSerialize(): array { return [ 'label' => "@$this->label", diff --git a/src/Lexicons/App/Bsky/RichText/Tag.php b/src/Lexicons/App/Bsky/RichText/Tag.php index d56b27b..67209b7 100644 --- a/src/Lexicons/App/Bsky/RichText/Tag.php +++ b/src/Lexicons/App/Bsky/RichText/Tag.php @@ -4,7 +4,7 @@ class Tag extends FeatureAbstract { - protected function schema(): array + public function jsonSerialize(): array { return [ "label" => "#$this->label", diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php index 437c84d..3b33fab 100644 --- a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php +++ b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php @@ -18,7 +18,6 @@ public function testConstructorAssignsReferenceAndLabel() { $mock = $this->getMockBuilder(FeatureAbstract::class) ->setConstructorArgs(['reference', 'label']) - ->onlyMethods(['schema']) ->getMockForAbstractClass(); $reference = $this->getPropertyValue('reference', $mock); @@ -32,21 +31,17 @@ public function testJsonSerializeReturnsCorrectArray() { $mock = $this->getMockBuilder(FeatureAbstract::class) ->setConstructorArgs(['reference', 'label']) - ->onlyMethods(['schema', 'nsid']) + ->onlyMethods(['jsonSerialize']) ->getMockForAbstractClass(); $schema = ['key' => 'value']; $mock->expects($this->once()) - ->method('schema') + ->method('jsonSerialize') ->willReturn($schema); - $mock->expects($this->once()) - ->method('nsid') - ->willReturn('feature'); - $this->assertSame( - ['type' => 'feature'] + $schema, + $schema, $mock->jsonSerialize() ); } diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php index 1cad091..2c537e7 100644 --- a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php +++ b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php @@ -19,21 +19,6 @@ public function setUp(): void $this->reference = 'reference'; } - /** - * @throws ReflectionException - */ - public function testTypeReturnsCorrectType(): void - { - $feature = $this->feature($this->reference); - $method = $this->method('nsid', $feature); - $expected = $this->type; - - $this->assertSame( - $expected, - $method->invoke($feature) - ); - } - public function test__toStringReturnsCorrectLabelWhenPassedBothParameters(): void { $feature = $this->feature($this->reference, $this->label); @@ -60,7 +45,6 @@ public function testSchemaReturnsCorrectSchemaWhenPassedBothParameters(): void $feature = $this->feature($this->reference, $this->label); $expected = [ - 'type' => $this->type, 'label' => $this->prefix . $this->label, $this->key => $this->reference, ]; @@ -78,7 +62,6 @@ public function testSchemaReturnsCorrectSchemaWhenPassedSingleParameter(): void private function schema(string $label): array { return [ - 'type' => $this->type, 'label' => $this->prefix . $label, $this->key => $this->reference, ]; From 21822c47f051128de483ca534c19b7f27f501ddf Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 3 Nov 2024 06:00:44 +0400 Subject: [PATCH 48/59] fix: remove hardcoded credentials from test --- tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php index e11a98d..0fd2434 100644 --- a/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php +++ b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php @@ -23,8 +23,8 @@ public static function setUpBeforeClass(): void static::$client = new Client(); static::$client->authenticate( - 'shahmal1yevv.bsky.social', - 'ucvlqcq8' + getenv('BLUESKY_IDENTIFIER'), + getenv('BLUESKY_PASSWORD') ); } From 0fd1a3f34a9937b22c864fd985f90ed4ea7483a3 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 3 Nov 2024 06:03:45 +0400 Subject: [PATCH 49/59] Update workflow: remove the 'continue-on-error' flag from feature tests and add secrets a environment variables --- .github/workflows/php.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index f3b430b..a108bbd 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -16,6 +16,11 @@ jobs: matrix: php-version: ['7.4', '8.0', '8.1'] name: PHP ${{ matrix.php-version }} Test + + env: + BLUESKY_IDENTIFIER: ${{ secrets.BLUESKY_IDENTIFIER }} + BLUESKY_PASSWORD: ${{ secrets.BLUESKY_PASSWORD }} + steps: - uses: shivammathur/setup-php@v2 with: @@ -43,7 +48,6 @@ jobs: - name: Run Feature Tests run: composer run-script test-feature - continue-on-error: true - name: Static Analyse run: composer run-script analyse \ No newline at end of file From b19ea29219134b563b70585b720f7079252af48e Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 3 Nov 2024 06:14:30 +0400 Subject: [PATCH 50/59] refactor: use getenv() instead of $_ENV superglobal --- tests/Feature/ClientTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Feature/ClientTest.php b/tests/Feature/ClientTest.php index 42c9b9a..a0fa951 100644 --- a/tests/Feature/ClientTest.php +++ b/tests/Feature/ClientTest.php @@ -33,8 +33,8 @@ public function setUp(): void */ public function testGetProfile(): void { - $username = $_ENV['BLUESKY_IDENTIFIER']; - $password = $_ENV['BLUESKY_PASSWORD']; + $username = getenv('BLUESKY_IDENTIFIER'); + $password = getenv('BLUESKY_PASSWORD'); $this->assertIsString($username); $this->assertIsString($password); @@ -106,8 +106,8 @@ public function testObserverNotificationOnAuthentication(): void ->forge(); $this->client->authenticate( - $_ENV['BLUESKY_IDENTIFIER'], - $_ENV['BLUESKY_PASSWORD'] + getenv('BLUESKY_IDENTIFIER'), + getenv('BLUESKY_PASSWORD') ); $response = $request->actor($this->client->authenticated()->did()) From 9bde17e0d5c2a3b6cfab404af58302c3d69469b7 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 3 Nov 2024 06:29:11 +0400 Subject: [PATCH 51/59] fix: standardize HTTP methods in API endpoints - Use explicit HTTP method definitions in each endpoint - Remove hardcoded POST method from AuthenticatedEndpoint trait - Add AuthenticatedEndpoint to GetFollowers endpoint - Fix initialization order in CreateSession endpoint --- src/Lexicons/App/Bsky/Graph/GetFollowers.php | 3 ++- src/Lexicons/Com/Atproto/Repo/CreateRecord.php | 8 ++++++++ src/Lexicons/Com/Atproto/Repo/UploadBlob.php | 2 +- src/Lexicons/Com/Atproto/Server/CreateSession.php | 2 +- src/Lexicons/Traits/AuthenticatedEndpoint.php | 2 -- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/Lexicons/App/Bsky/Graph/GetFollowers.php b/src/Lexicons/App/Bsky/Graph/GetFollowers.php index c2a6040..91610c7 100644 --- a/src/Lexicons/App/Bsky/Graph/GetFollowers.php +++ b/src/Lexicons/App/Bsky/Graph/GetFollowers.php @@ -8,12 +8,13 @@ use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\APIRequest; +use Atproto\Lexicons\Traits\AuthenticatedEndpoint; use Atproto\Lexicons\Traits\Endpoint; use Atproto\Resources\App\Bsky\Graph\GetFollowersResource; class GetFollowers extends APIRequest implements LexiconContract { - use Endpoint; + use AuthenticatedEndpoint; public function actor(string $actor = null) { diff --git a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php index 43d1040..59c6adc 100644 --- a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php +++ b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php @@ -22,6 +22,14 @@ class CreateRecord extends APIRequest implements LexiconContract 'record' ]; + protected function initialize(): void + { + $this->origin(self::API_BASE_URL) + ->headers(self::API_BASE_HEADERS) + ->path(sprintf("/xrpc/%s", $this->nsid())) + ->method('POST'); + } + public function repo(string $repo = null) { if (is_null($repo)) { diff --git a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php index 06caf6a..9a5a92e 100644 --- a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php +++ b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php @@ -23,7 +23,7 @@ protected function initialize(): void ->headers(self::API_BASE_HEADERS) ->header('Content-Type', '*/*') ->path(sprintf("/xrpc/%s", $this->nsid())) - ->method($this->method); + ->method('POST'); } /** diff --git a/src/Lexicons/Com/Atproto/Server/CreateSession.php b/src/Lexicons/Com/Atproto/Server/CreateSession.php index 8acc0f5..10de78c 100644 --- a/src/Lexicons/Com/Atproto/Server/CreateSession.php +++ b/src/Lexicons/Com/Atproto/Server/CreateSession.php @@ -18,7 +18,7 @@ public function __construct(Client $client, string $identifier, string $password { parent::__construct($client); - $this->parameters([ + $this->method('POST')->parameters([ 'identifier' => $identifier, 'password' => $password, ]); diff --git a/src/Lexicons/Traits/AuthenticatedEndpoint.php b/src/Lexicons/Traits/AuthenticatedEndpoint.php index e347c65..461170c 100644 --- a/src/Lexicons/Traits/AuthenticatedEndpoint.php +++ b/src/Lexicons/Traits/AuthenticatedEndpoint.php @@ -16,8 +16,6 @@ public function __construct(Client $client) return; } - $this->method = 'POST'; - parent::__construct($client); $this->update($client); } From 89669480b7a1a8a729215b01d6245012ad00a8ad Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 3 Nov 2024 08:52:47 +0400 Subject: [PATCH 52/59] refactor: improve RichText handling and blob upload --- src/Lexicons/App/Bsky/Feed/Post.php | 4 +- .../App/Bsky/RichText/FeatureAbstract.php | 21 +++++ src/Lexicons/App/Bsky/RichText/Link.php | 2 +- src/Lexicons/App/Bsky/RichText/Mention.php | 2 +- .../{FeatureFactory.php => RichText.php} | 2 +- src/Lexicons/App/Bsky/RichText/Tag.php | 2 +- src/Lexicons/Com/Atproto/Repo/UploadBlob.php | 15 +++- .../Com/Atproto/Repo/CreateRecordTest.php | 86 ++++++++----------- .../App/Bsky/RichText/FeatureTests.php | 4 + 9 files changed, 80 insertions(+), 58 deletions(-) rename src/Lexicons/App/Bsky/RichText/{FeatureFactory.php => RichText.php} (95%) diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php index 1277fac..469535c 100644 --- a/src/Lexicons/App/Bsky/Feed/Post.php +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -10,7 +10,7 @@ use Atproto\Lexicons\App\Bsky\RichText\ByteSlice; use Atproto\Lexicons\App\Bsky\RichText\Facet; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; -use Atproto\Lexicons\App\Bsky\RichText\FeatureFactory; +use Atproto\Lexicons\App\Bsky\RichText\RichText; use Atproto\Lexicons\Com\Atproto\Label\SelfLabels; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use Atproto\Lexicons\Traits\Lexicon; @@ -272,7 +272,7 @@ private function processItem($item): void */ private function addFeatureItem(string $type, string $reference, ?string $label): PostBuilderContract { - $feature = FeatureFactory::{$type}($reference, $label); + $feature = RichText::{$type}($reference, $label); $this->addFeature($feature); return $this; diff --git a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php index 97593a7..ab71af2 100644 --- a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php +++ b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php @@ -4,6 +4,7 @@ use Atproto\Contracts\LexiconContract; use Atproto\Lexicons\Traits\Lexicon; +use Atproto\Lexicons\Traits\Serializable; abstract class FeatureAbstract implements LexiconContract { @@ -21,4 +22,24 @@ public function __construct(string $reference, string $label = null) $this->reference = $reference; $this->label = $label; } + + public function jsonSerialize(): array + { + return ['$type' => sprintf("%s#%s", $this->nsid(), $this->type())] + $this->schema(); + } + + public function nsid(): string + { + return 'app.bsky.richtext.facet'; + } + + public function type(): string + { + $namespace = static::class; + $parts = explode('\\', $namespace); + + return strtolower(end($parts)); + } + + abstract protected function schema(): array; } diff --git a/src/Lexicons/App/Bsky/RichText/Link.php b/src/Lexicons/App/Bsky/RichText/Link.php index 1f8afc0..80ceebd 100644 --- a/src/Lexicons/App/Bsky/RichText/Link.php +++ b/src/Lexicons/App/Bsky/RichText/Link.php @@ -4,7 +4,7 @@ class Link extends FeatureAbstract { - public function jsonSerialize(): array + protected function schema(): array { return [ "label" => $this->label, diff --git a/src/Lexicons/App/Bsky/RichText/Mention.php b/src/Lexicons/App/Bsky/RichText/Mention.php index 9c499d1..2b12f6b 100644 --- a/src/Lexicons/App/Bsky/RichText/Mention.php +++ b/src/Lexicons/App/Bsky/RichText/Mention.php @@ -4,7 +4,7 @@ class Mention extends FeatureAbstract { - public function jsonSerialize(): array + protected function schema(): array { return [ 'label' => "@$this->label", diff --git a/src/Lexicons/App/Bsky/RichText/FeatureFactory.php b/src/Lexicons/App/Bsky/RichText/RichText.php similarity index 95% rename from src/Lexicons/App/Bsky/RichText/FeatureFactory.php rename to src/Lexicons/App/Bsky/RichText/RichText.php index 3000d05..3a1fcc1 100644 --- a/src/Lexicons/App/Bsky/RichText/FeatureFactory.php +++ b/src/Lexicons/App/Bsky/RichText/RichText.php @@ -2,7 +2,7 @@ namespace Atproto\Lexicons\App\Bsky\RichText; -class FeatureFactory +class RichText { public static function link(string $reference, string $label = null): Link { diff --git a/src/Lexicons/App/Bsky/RichText/Tag.php b/src/Lexicons/App/Bsky/RichText/Tag.php index 67209b7..d56b27b 100644 --- a/src/Lexicons/App/Bsky/RichText/Tag.php +++ b/src/Lexicons/App/Bsky/RichText/Tag.php @@ -4,7 +4,7 @@ class Tag extends FeatureAbstract { - public function jsonSerialize(): array + protected function schema(): array { return [ "label" => "#$this->label", diff --git a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php index 9a5a92e..2f541db 100644 --- a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php +++ b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php @@ -32,18 +32,27 @@ protected function initialize(): void public function blob(string $blob = null) { if (is_null($blob)) { - return hex2bin($this->parameter('blob')); + return $this->parameter('blob'); } $blob = (! mb_check_encoding($blob, 'UTF-8')) ? $blob : (new FileSupport($blob))->getBlob(); - $this->parameter('blob', bin2hex($blob)); + $this->parameter('blob', $blob); return $this; } + public function parameters($parameters = null) + { + if (is_bool($parameters)) { + return $this->parameters['blob'] ?: ''; + } + + parent::parameters($parameters); + } + public function token(string $token = null) { if (is_null($token)) { @@ -76,7 +85,7 @@ public function build(): RequestContract public function resource(array $data): ResourceContract { return new UploadBlobResource([ - 'blob' => Blob::viaBinary(hex2bin($this->parameter('blob'))) + 'blob' => Blob::viaBinary($this->parameter('blob')) ]); } } diff --git a/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php index 0fd2434..3b16764 100644 --- a/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php +++ b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php @@ -9,6 +9,10 @@ use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\ImageCollection; use Atproto\Lexicons\App\Bsky\Embed\Image; +use Atproto\Lexicons\App\Bsky\RichText\RichText; +use Atproto\Lexicons\App\Bsky\RichText\Link; +use Atproto\Lexicons\App\Bsky\RichText\Mention; +use Atproto\Lexicons\App\Bsky\RichText\Tag; use Atproto\Lexicons\Com\Atproto\Repo\CreateRecord; use Atproto\Support\Arr; use Atproto\Support\FileSupport; @@ -21,7 +25,6 @@ class CreateRecordTest extends TestCase public static function setUpBeforeClass(): void { static::$client = new Client(); - static::$client->authenticate( getenv('BLUESKY_IDENTIFIER'), getenv('BLUESKY_PASSWORD') @@ -35,39 +38,6 @@ public function testBuildCreateRecordRequestWithPost(): void { $client = static::$client; - $post = $client->app()->bsky()->feed()->post()->forge()->text( - "Hello World! ", - "This post was sent from a feature test of the BlueSky PHP SDK ", - ); - - $this->assertInstanceOf(PostBuilderContract::class, $post); - - $createRecord = $client->com()->atproto()->repo()->createRecord()->forge() - ->record($post) - ->repo($client->authenticated()->did()) - ->collection('app.bsky.feed.post'); - - $this->assertInstanceOf(CreateRecord::class, $createRecord); - - $serializedPost = json_decode($post, true); - $actualPost = Arr::get(json_decode($createRecord, true), 'record'); - - $this->assertSame($serializedPost, $actualPost); - - $response = $createRecord->send(); - - $this->assertIsString($response->uri()); - - echo $response->uri(); - } - - /** - * @throws BlueskyException - */ - public function testSendPostWithBlobUsingPostBuilderAPI(): void - { - $client = static::$client; - /** @var Blob $uploadBlob */ $uploadedBlob = $client->com() ->atproto() @@ -75,37 +45,55 @@ public function testSendPostWithBlobUsingPostBuilderAPI(): void ->uploadBlob() ->forge() // Atproto\Lexicons\Com\Atproto\Repo\UploadBlob ->token($client->authenticated()->accessJwt()) - ->blob(__DIR__.'/../../../../../../art/file.png') + ->blob(__DIR__.'/../../../../../../art/logo-small.webp') ->build() ->send() // Atproto\Resources\Com\Atproto\Repo\UploadBlobResource ->blob(); - $this->assertInstanceOf(Blob::class, $uploadedBlob); - $post = $client->app() ->bsky() ->feed() ->post() ->forge() - ->text("Hello World!") + ->text( + "Hello World! ", + "This post was sent from a feature test of the ", + RichText::link('https://github.com/shahmal1yev/blueskysdk', 'BlueSky PHP SDK'), + '. You can read the docs from ', + RichText::link('https://blueskysdk.shahmal1yev.dev', 'here'), + '. Author? Yes, ', + RichText::mention('did:plc:bdkw6ic5ugy6ni4pqvljcpva', 'here'), + ' it is. ', + RichText::tag('https://bsky.app/hashtag/php', 'php'), + " ", + RichText::tag('https://bsky.app/hashtag/sdk', 'sdk'), + " ", + RichText::tag('https://bsky.app/hashtag/bluesky', 'bluesky'), + ) ->embed(new ImageCollection([ - new Image($uploadedBlob, "Alt text") + new Image($uploadedBlob, "PHP BlueSky SDK Logo") ])); + $this->assertInstanceOf(PostBuilderContract::class, $post); - $createdRecord = $client->com() - ->atproto() - ->repo() - ->createRecord() - ->forge() - ->record($post) + /** @var CreateRecord $createRecord */ + $createRecord = $client->com()->atproto()->repo()->createRecord()->forge(); + + $createRecord->record($post) ->repo($client->authenticated()->did()) - ->collection('app.bsky.feed.post') - ->build() - ->send(); + ->collection($post->nsid()); - $this->assertIsString($createdRecord->uri()); + $this->assertInstanceOf(CreateRecord::class, $createRecord); + + $serializedPost = json_decode($post, true); + $actualPost = Arr::get(json_decode($createRecord, true), 'record'); + + $this->assertSame($serializedPost, $actualPost); + + $response = $createRecord->send(); + + $this->assertIsString($response->uri()); } /** diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php index 2c537e7..cf07f59 100644 --- a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php +++ b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php @@ -10,6 +10,8 @@ trait FeatureTests { use Reflection; + private string $nsid = 'app.bsky.richtext.facet'; + private string $label; private string $reference; @@ -45,6 +47,7 @@ public function testSchemaReturnsCorrectSchemaWhenPassedBothParameters(): void $feature = $this->feature($this->reference, $this->label); $expected = [ + '$type' => $feature->nsid() . "#" . $feature->type(), 'label' => $this->prefix . $this->label, $this->key => $this->reference, ]; @@ -62,6 +65,7 @@ public function testSchemaReturnsCorrectSchemaWhenPassedSingleParameter(): void private function schema(string $label): array { return [ + '$type' => sprintf("%s#%s", $this->nsid, $this->type), 'label' => $this->prefix . $label, $this->key => $this->reference, ]; From 8cff56880da5408b53d50871165f1066606e5e67 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Thu, 7 Nov 2024 00:15:54 +0400 Subject: [PATCH 53/59] refactor: rename Resource to Response and Asset to Object for better semantics --- README.md | 16 +++---- .../Types/NonPrimitive/FollowerAssetType.php | 4 +- .../Types/NonPrimitive/LabelAssetType.php | 4 +- .../Types/NonPrimitive/ProfileAssetType.php | 4 +- .../{AssetContract.php => ObjectContract.php} | 2 +- ...ourceContract.php => ResponseContract.php} | 2 +- src/Lexicons/APIRequest.php | 8 ++-- src/Lexicons/App/Bsky/Actor/GetProfile.php | 8 ++-- src/Lexicons/App/Bsky/Actor/GetProfiles.php | 8 ++-- src/Lexicons/App/Bsky/Graph/GetFollowers.php | 8 ++-- .../Com/Atproto/Repo/CreateRecord.php | 8 ++-- src/Lexicons/Com/Atproto/Repo/UploadBlob.php | 8 ++-- .../Com/Atproto/Server/CreateSession.php | 8 ++-- src/Lexicons/Traits/RequestHandler.php | 4 +- .../App/Bsky/Actor/GetProfileResource.php | 33 ------------- .../App/Bsky/Actor/GetProfilesResource.php | 21 --------- .../App/Bsky/Graph/GetFollowersResource.php | 30 ------------ src/Resources/Assets/AssociatedAsset.php | 34 -------------- src/Resources/Assets/BlockingByListAsset.php | 23 --------- src/Resources/Assets/ChatAsset.php | 26 ---------- src/Resources/Assets/CreatorAsset.php | 28 ----------- src/Resources/Assets/DatetimeAsset.php | 16 ------- src/Resources/Assets/FollowerAsset.php | 21 --------- .../Assets/JoinedViaStarterPackAsset.php | 40 ---------------- src/Resources/Assets/KnownFollowersAsset.php | 26 ---------- src/Resources/Assets/LabelAsset.php | 38 --------------- src/Resources/Assets/MutedByListAsset.php | 23 --------- src/Resources/Assets/ProfileAsset.php | 11 ----- src/Resources/Assets/SubjectAsset.php | 11 ----- src/Resources/Assets/UserAsset.php | 47 ------------------- src/Resources/Assets/ViewerAsset.php | 41 ---------------- .../Com/Atproto/Repo/CreateRecordResource.php | 11 ----- .../Com/Atproto/Repo/UploadBlobResource.php | 14 ------ .../App/Bsky/Actor/GetProfileResponse.php | 33 +++++++++++++ .../App/Bsky/Actor/GetProfilesResponse.php | 21 +++++++++ .../App/Bsky/Graph/GetFollowersResponse.php | 30 ++++++++++++ .../BaseResponse.php} | 8 ++-- .../Com/Atproto/Repo/CreateRecordResponse.php | 11 +++++ .../Com/Atproto/Repo/UploadBlobResponse.php | 14 ++++++ .../Atproto/Server/CreateSessionResponse.php} | 10 ++-- src/Responses/Objects/AssociatedObject.php | 34 ++++++++++++++ .../Objects/BaseObject.php} | 8 ++-- .../Objects/BlockingByListObject.php | 23 +++++++++ .../Objects}/ByListAsset.php | 18 +++---- src/Responses/Objects/ChatObject.php | 26 ++++++++++ .../Objects}/CollectionAsset.php | 8 ++-- src/Responses/Objects/CreatorObject.php | 28 +++++++++++ src/Responses/Objects/DatetimeObject.php | 16 +++++++ src/Responses/Objects/FollowerObject.php | 21 +++++++++ .../Objects/FollowersObject.php} | 10 ++-- .../Objects/JoinedViaStarterPackObject.php | 40 ++++++++++++++++ .../Objects/KnownFollowersObject.php | 26 ++++++++++ src/Responses/Objects/LabelObject.php | 38 +++++++++++++++ .../Objects/LabelsObject.php} | 10 ++-- src/Responses/Objects/MutedByListObject.php | 23 +++++++++ src/Responses/Objects/ProfileObject.php | 11 +++++ .../Objects/ProfilesObject.php} | 10 ++-- src/Responses/Objects/SubjectObject.php | 11 +++++ src/Responses/Objects/UserObject.php | 47 +++++++++++++++++++ src/Responses/Objects/ViewerObject.php | 41 ++++++++++++++++ src/Traits/Authentication.php | 8 ++-- tests/Feature/ClientTest.php | 18 +++---- .../App/Bsky/Graph/GetFollowersTest.php | 4 +- tests/Supports/ByListAssetTest.php | 8 ++-- tests/Supports/NonPrimitiveAssetTest.php | 4 +- tests/Supports/UserAssetTest.php | 12 ++--- tests/Unit/ClientTest.php | 4 +- .../App/Bsky/Actor/GetProfilesTest.php | 6 +-- .../App/Bsky/Graph/GetFollowersTest.php | 6 +-- .../Assets/BlockingByListAssetTest.php | 21 --------- .../Resources/Assets/CreatorAssetTest.php | 17 ------- .../Resources/Assets/FollowerAssetTest.php | 23 --------- .../Resources/Assets/MutedByListAssetTest.php | 21 --------- .../Unit/Resources/Assets/ViewerAssetTest.php | 45 ------------------ .../Bsky/Actor/GetProfileResponseTest.php} | 30 ++++++------ .../Assets/AssociatedObjectTest.php} | 14 +++--- .../Assets/BaseObjectTest.php} | 22 ++++----- .../Assets/BlockingByListObjectTest.php | 21 +++++++++ .../Assets/ChatObjectTest.php} | 10 ++-- .../Assets/CollectionObjectTest.php} | 38 +++++++-------- .../Responses/Assets/CreatorObjectTest.php | 17 +++++++ .../Assets/DatetimeObjectTest.php} | 12 ++--- .../Responses/Assets/FollowerObjectTest.php | 23 +++++++++ .../Assets/FollowersObjectTest.php} | 46 +++++++++--------- .../JoinedViaStarterPackObjectTest.php} | 18 +++---- .../Assets/KnownFollowersObjectTest.php} | 14 +++--- .../Assets/LabelObjectTest.php} | 12 ++--- .../Assets/LabelsObjectTest.php} | 20 ++++---- .../Assets/MutedByListObjectTest.php | 21 +++++++++ .../Assets/UserObjectTest.php} | 40 ++++++++-------- .../Responses/Assets/ViewerObjectTest.php | 45 ++++++++++++++++++ .../BaseResponseTest.php} | 30 ++++++------ 92 files changed, 896 insertions(+), 896 deletions(-) rename src/Contracts/Resources/{AssetContract.php => ObjectContract.php} (81%) rename src/Contracts/Resources/{ResourceContract.php => ResponseContract.php} (95%) delete mode 100644 src/Resources/App/Bsky/Actor/GetProfileResource.php delete mode 100644 src/Resources/App/Bsky/Actor/GetProfilesResource.php delete mode 100644 src/Resources/App/Bsky/Graph/GetFollowersResource.php delete mode 100644 src/Resources/Assets/AssociatedAsset.php delete mode 100644 src/Resources/Assets/BlockingByListAsset.php delete mode 100644 src/Resources/Assets/ChatAsset.php delete mode 100644 src/Resources/Assets/CreatorAsset.php delete mode 100644 src/Resources/Assets/DatetimeAsset.php delete mode 100644 src/Resources/Assets/FollowerAsset.php delete mode 100644 src/Resources/Assets/JoinedViaStarterPackAsset.php delete mode 100644 src/Resources/Assets/KnownFollowersAsset.php delete mode 100644 src/Resources/Assets/LabelAsset.php delete mode 100644 src/Resources/Assets/MutedByListAsset.php delete mode 100644 src/Resources/Assets/ProfileAsset.php delete mode 100644 src/Resources/Assets/SubjectAsset.php delete mode 100644 src/Resources/Assets/UserAsset.php delete mode 100644 src/Resources/Assets/ViewerAsset.php delete mode 100644 src/Resources/Com/Atproto/Repo/CreateRecordResource.php delete mode 100644 src/Resources/Com/Atproto/Repo/UploadBlobResource.php create mode 100644 src/Responses/App/Bsky/Actor/GetProfileResponse.php create mode 100644 src/Responses/App/Bsky/Actor/GetProfilesResponse.php create mode 100644 src/Responses/App/Bsky/Graph/GetFollowersResponse.php rename src/{Resources/BaseResource.php => Responses/BaseResponse.php} (90%) create mode 100644 src/Responses/Com/Atproto/Repo/CreateRecordResponse.php create mode 100644 src/Responses/Com/Atproto/Repo/UploadBlobResponse.php rename src/{Resources/Com/Atproto/Server/CreateSessionResource.php => Responses/Com/Atproto/Server/CreateSessionResponse.php} (69%) create mode 100644 src/Responses/Objects/AssociatedObject.php rename src/{Resources/Assets/BaseAsset.php => Responses/Objects/BaseObject.php} (65%) create mode 100644 src/Responses/Objects/BlockingByListObject.php rename src/{Resources/Assets => Responses/Objects}/ByListAsset.php (58%) create mode 100644 src/Responses/Objects/ChatObject.php rename src/{Resources/Assets => Responses/Objects}/CollectionAsset.php (74%) create mode 100644 src/Responses/Objects/CreatorObject.php create mode 100644 src/Responses/Objects/DatetimeObject.php create mode 100644 src/Responses/Objects/FollowerObject.php rename src/{Resources/Assets/FollowersAsset.php => Responses/Objects/FollowersObject.php} (62%) create mode 100644 src/Responses/Objects/JoinedViaStarterPackObject.php create mode 100644 src/Responses/Objects/KnownFollowersObject.php create mode 100644 src/Responses/Objects/LabelObject.php rename src/{Resources/Assets/LabelsAsset.php => Responses/Objects/LabelsObject.php} (62%) create mode 100644 src/Responses/Objects/MutedByListObject.php create mode 100644 src/Responses/Objects/ProfileObject.php rename src/{Resources/Assets/ProfilesAsset.php => Responses/Objects/ProfilesObject.php} (62%) create mode 100644 src/Responses/Objects/SubjectObject.php create mode 100644 src/Responses/Objects/UserObject.php create mode 100644 src/Responses/Objects/ViewerObject.php delete mode 100644 tests/Unit/Resources/Assets/BlockingByListAssetTest.php delete mode 100644 tests/Unit/Resources/Assets/CreatorAssetTest.php delete mode 100644 tests/Unit/Resources/Assets/FollowerAssetTest.php delete mode 100644 tests/Unit/Resources/Assets/MutedByListAssetTest.php delete mode 100644 tests/Unit/Resources/Assets/ViewerAssetTest.php rename tests/Unit/{Resources/App/Bsky/Actor/GetProfileResourceTest.php => Responses/App/Bsky/Actor/GetProfileResponseTest.php} (56%) rename tests/Unit/{Resources/Assets/AssociatedAssetTest.php => Responses/Assets/AssociatedObjectTest.php} (71%) rename tests/Unit/{Resources/Assets/BaseAssetTest.php => Responses/Assets/BaseObjectTest.php} (64%) create mode 100644 tests/Unit/Responses/Assets/BlockingByListObjectTest.php rename tests/Unit/{Resources/Assets/ChatAssetTest.php => Responses/Assets/ChatObjectTest.php} (56%) rename tests/Unit/{Resources/Assets/CollectionAssetTest.php => Responses/Assets/CollectionObjectTest.php} (64%) create mode 100644 tests/Unit/Responses/Assets/CreatorObjectTest.php rename tests/Unit/{Resources/Assets/DatetimeAssetTest.php => Responses/Assets/DatetimeObjectTest.php} (79%) create mode 100644 tests/Unit/Responses/Assets/FollowerObjectTest.php rename tests/Unit/{Resources/Assets/FollowersAssetTest.php => Responses/Assets/FollowersObjectTest.php} (75%) rename tests/Unit/{Resources/Assets/JoinedViaStarterPackAssetTest.php => Responses/Assets/JoinedViaStarterPackObjectTest.php} (73%) rename tests/Unit/{Resources/Assets/KnownFollowersAssetTest.php => Responses/Assets/KnownFollowersObjectTest.php} (67%) rename tests/Unit/{Resources/Assets/LabelAssetTest.php => Responses/Assets/LabelObjectTest.php} (83%) rename tests/Unit/{Resources/Assets/LabelsAssetTest.php => Responses/Assets/LabelsObjectTest.php} (77%) create mode 100644 tests/Unit/Responses/Assets/MutedByListObjectTest.php rename tests/Unit/{Resources/Assets/UserAssetTest.php => Responses/Assets/UserObjectTest.php} (74%) create mode 100644 tests/Unit/Responses/Assets/ViewerObjectTest.php rename tests/Unit/{Resources/BaseResourceTest.php => Responses/BaseResponseTest.php} (58%) diff --git a/README.md b/README.md index 444d39b..5167fff 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ First, instantiate the `Client` class and authenticate using your BlueSky creden ```php use Atproto\Client; -use Atproto\Resources\Com\Atproto\Server\CreateSessionResource; +use Atproto\Responses\Com\Atproto\Server\CreateSessionResponse; $client = new Client(); @@ -50,7 +50,7 @@ $client = new Client(); $client->authenticate($identifier, $password); // Once authenticated, you can retrieve the user's session resource -/** @var CreateSessionResource $session */ +/** @var CreateSessionResponse $session */ $session = $client->authenticated(); ``` @@ -94,17 +94,17 @@ $createdAt = $profile->createdAt(); BlueSky SDK allows you to access complex assets like followers and labels directly through the resource instances. ```php -use Atproto\Resources\Assets\FollowersAsset; -use Atproto\Resources\Assets\FollowerAsset; +use Atproto\Responses\Objects\FollowersObject; +use Atproto\Responses\Objects\FollowerObject; // Fetch the user's followers -/** @var FollowersAsset $followers */ +/** @var FollowersObject $followers */ $followers = $profile->viewer() ->knownFollowers() ->followers(); foreach ($followers as $follower) { - /** @var FollowerAsset $follower */ + /** @var FollowerObject $follower */ echo $follower->displayName() . " - Created at: " . $follower->createdAt()->format(DATE_ATOM) . "\n"; } ``` @@ -116,7 +116,7 @@ Here is a more complete example of fetching and displaying profile information, ```php use Atproto\Client; use Atproto\API\App\Bsky\Actor\GetProfile; -use Atproto\Resources\App\Bsky\Actor\GetProfileResource; +use Atproto\Responses\App\Bsky\Actor\GetProfileResponse; $client->authenticate('user@example.com', 'password'); @@ -127,7 +127,7 @@ $client->app() ->forge(); // ->actor($client->authenticated()->did()); -/** @var GetProfileResource $user */ +/** @var GetProfileResponse $user */ $user = $client->send(); // Output profile details diff --git a/src/Collections/Types/NonPrimitive/FollowerAssetType.php b/src/Collections/Types/NonPrimitive/FollowerAssetType.php index 9e156e3..fff2403 100644 --- a/src/Collections/Types/NonPrimitive/FollowerAssetType.php +++ b/src/Collections/Types/NonPrimitive/FollowerAssetType.php @@ -2,13 +2,13 @@ namespace Atproto\Collections\Types\NonPrimitive; -use Atproto\Resources\Assets\FollowerAsset; +use Atproto\Responses\Objects\FollowerObject; use GenericCollection\Interfaces\TypeInterface; class FollowerAssetType implements TypeInterface { public function validate($value): bool { - return $value instanceof FollowerAsset; + return $value instanceof FollowerObject; } } diff --git a/src/Collections/Types/NonPrimitive/LabelAssetType.php b/src/Collections/Types/NonPrimitive/LabelAssetType.php index 03cfec6..d555be4 100644 --- a/src/Collections/Types/NonPrimitive/LabelAssetType.php +++ b/src/Collections/Types/NonPrimitive/LabelAssetType.php @@ -2,13 +2,13 @@ namespace Atproto\Collections\Types\NonPrimitive; -use Atproto\Resources\Assets\LabelAsset; +use Atproto\Responses\Objects\LabelObject; use GenericCollection\Interfaces\TypeInterface; class LabelAssetType implements TypeInterface { public function validate($value): bool { - return $value instanceof LabelAsset; + return $value instanceof LabelObject; } } diff --git a/src/Collections/Types/NonPrimitive/ProfileAssetType.php b/src/Collections/Types/NonPrimitive/ProfileAssetType.php index b64bf70..8f87cb3 100644 --- a/src/Collections/Types/NonPrimitive/ProfileAssetType.php +++ b/src/Collections/Types/NonPrimitive/ProfileAssetType.php @@ -2,13 +2,13 @@ namespace Atproto\Collections\Types\NonPrimitive; -use Atproto\Resources\Assets\ProfileAsset; +use Atproto\Responses\Objects\ProfileObject; use GenericCollection\Interfaces\TypeInterface; class ProfileAssetType implements TypeInterface { public function validate($value): bool { - return $value instanceof ProfileAsset; + return $value instanceof ProfileObject; } } diff --git a/src/Contracts/Resources/AssetContract.php b/src/Contracts/Resources/ObjectContract.php similarity index 81% rename from src/Contracts/Resources/AssetContract.php rename to src/Contracts/Resources/ObjectContract.php index a283e43..a2648d7 100644 --- a/src/Contracts/Resources/AssetContract.php +++ b/src/Contracts/Resources/ObjectContract.php @@ -2,7 +2,7 @@ namespace Atproto\Contracts\Resources; -interface AssetContract +interface ObjectContract { public function cast(); public function revert(); diff --git a/src/Contracts/Resources/ResourceContract.php b/src/Contracts/Resources/ResponseContract.php similarity index 95% rename from src/Contracts/Resources/ResourceContract.php rename to src/Contracts/Resources/ResponseContract.php index 816375d..abc52e6 100644 --- a/src/Contracts/Resources/ResourceContract.php +++ b/src/Contracts/Resources/ResponseContract.php @@ -12,7 +12,7 @@ * @see CreateRecord * @see UploadBlob */ -interface ResourceContract +interface ResponseContract { /** * @param string $name diff --git a/src/Lexicons/APIRequest.php b/src/Lexicons/APIRequest.php index e1fc6d3..9c6e427 100644 --- a/src/Lexicons/APIRequest.php +++ b/src/Lexicons/APIRequest.php @@ -4,7 +4,7 @@ use Atproto\Client; use Atproto\Contracts\Lexicons\APIRequestContract; -use Atproto\Contracts\Resources\ResourceContract; +use Atproto\Contracts\Resources\ResponseContract; use SplSubject; abstract class APIRequest extends Request implements APIRequestContract @@ -17,14 +17,14 @@ public function __construct(Client $client) $this->initialize(); } - public function send(): ResourceContract + public function send(): ResponseContract { - return $this->resource(parent::send()); + return $this->response(parent::send()); } abstract protected function initialize(): void; - abstract public function resource(array $data): ResourceContract; + abstract public function response(array $data): ResponseContract; public function update(SplSubject $subject): void { diff --git a/src/Lexicons/App/Bsky/Actor/GetProfile.php b/src/Lexicons/App/Bsky/Actor/GetProfile.php index 5307585..0adf153 100644 --- a/src/Lexicons/App/Bsky/Actor/GetProfile.php +++ b/src/Lexicons/App/Bsky/Actor/GetProfile.php @@ -4,12 +4,12 @@ use Atproto\Contracts\LexiconContract; use Atproto\Contracts\Lexicons\RequestContract; -use Atproto\Contracts\Resources\ResourceContract; +use Atproto\Contracts\Resources\ResponseContract; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Exceptions\Http\Response\AuthMissingException; use Atproto\Lexicons\APIRequest; use Atproto\Lexicons\Traits\AuthenticatedEndpoint; -use Atproto\Resources\App\Bsky\Actor\GetProfileResource; +use Atproto\Responses\App\Bsky\Actor\GetProfileResponse; use Exception; class GetProfile extends APIRequest implements LexiconContract @@ -60,8 +60,8 @@ public function build(): RequestContract return $this; } - public function resource(array $data): ResourceContract + public function response(array $data): ResponseContract { - return new GetProfileResource($data); + return new GetProfileResponse($data); } } diff --git a/src/Lexicons/App/Bsky/Actor/GetProfiles.php b/src/Lexicons/App/Bsky/Actor/GetProfiles.php index 72a4793..0bb137a 100644 --- a/src/Lexicons/App/Bsky/Actor/GetProfiles.php +++ b/src/Lexicons/App/Bsky/Actor/GetProfiles.php @@ -4,13 +4,13 @@ use Atproto\Contracts\LexiconContract; use Atproto\Contracts\Lexicons\RequestContract; -use Atproto\Contracts\Resources\ResourceContract; +use Atproto\Contracts\Resources\ResponseContract; use Atproto\Exceptions\Auth\AuthRequired; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\APIRequest; use Atproto\Lexicons\Traits\AuthenticatedEndpoint; -use Atproto\Resources\App\Bsky\Actor\GetProfilesResource; +use Atproto\Responses\App\Bsky\Actor\GetProfilesResponse; use GenericCollection\Interfaces\GenericCollectionInterface; use GenericCollection\Types\Primitive\StringType; @@ -20,9 +20,9 @@ class GetProfiles extends APIRequest implements LexiconContract private ?GenericCollectionInterface $actors = null; - public function resource(array $data): ResourceContract + public function response(array $data): ResponseContract { - return new GetProfilesResource($data); + return new GetProfilesResponse($data); } /** diff --git a/src/Lexicons/App/Bsky/Graph/GetFollowers.php b/src/Lexicons/App/Bsky/Graph/GetFollowers.php index 91610c7..ffb0aa7 100644 --- a/src/Lexicons/App/Bsky/Graph/GetFollowers.php +++ b/src/Lexicons/App/Bsky/Graph/GetFollowers.php @@ -4,13 +4,13 @@ use Atproto\Contracts\LexiconContract; use Atproto\Contracts\Lexicons\RequestContract; -use Atproto\Contracts\Resources\ResourceContract; +use Atproto\Contracts\Resources\ResponseContract; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\APIRequest; use Atproto\Lexicons\Traits\AuthenticatedEndpoint; use Atproto\Lexicons\Traits\Endpoint; -use Atproto\Resources\App\Bsky\Graph\GetFollowersResource; +use Atproto\Responses\App\Bsky\Graph\GetFollowersResponse; class GetFollowers extends APIRequest implements LexiconContract { @@ -56,9 +56,9 @@ public function cursor(string $cursor = null) return $this; } - public function resource(array $data): ResourceContract + public function response(array $data): ResponseContract { - return new GetFollowersResource($data); + return new GetFollowersResponse($data); } /** diff --git a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php index 59c6adc..6b1c13b 100644 --- a/src/Lexicons/Com/Atproto/Repo/CreateRecord.php +++ b/src/Lexicons/Com/Atproto/Repo/CreateRecord.php @@ -5,12 +5,12 @@ use Atproto\Contracts\LexiconContract; use Atproto\Contracts\Lexicons\App\Bsky\Feed\PostBuilderContract; use Atproto\Contracts\Lexicons\RequestContract; -use Atproto\Contracts\Resources\ResourceContract; +use Atproto\Contracts\Resources\ResponseContract; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\APIRequest; use Atproto\Lexicons\Traits\AuthenticatedEndpoint; -use Atproto\Resources\Com\Atproto\Repo\CreateRecordResource; +use Atproto\Responses\Com\Atproto\Repo\CreateRecordResponse; class CreateRecord extends APIRequest implements LexiconContract { @@ -121,9 +121,9 @@ public function build(): RequestContract return $this; } - public function resource(array $data): ResourceContract + public function response(array $data): ResponseContract { - return new CreateRecordResource($data); + return new CreateRecordResponse($data); } public function jsonSerialize(): array diff --git a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php index 2f541db..4efa836 100644 --- a/src/Lexicons/Com/Atproto/Repo/UploadBlob.php +++ b/src/Lexicons/Com/Atproto/Repo/UploadBlob.php @@ -4,13 +4,13 @@ use Atproto\Contracts\LexiconContract; use Atproto\Contracts\Lexicons\RequestContract; -use Atproto\Contracts\Resources\ResourceContract; +use Atproto\Contracts\Resources\ResponseContract; use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\APIRequest; use Atproto\Lexicons\Traits\AuthenticatedEndpoint; -use Atproto\Resources\Com\Atproto\Repo\UploadBlobResource; +use Atproto\Responses\Com\Atproto\Repo\UploadBlobResponse; use Atproto\Support\FileSupport; class UploadBlob extends APIRequest implements LexiconContract @@ -82,9 +82,9 @@ public function build(): RequestContract return $this; } - public function resource(array $data): ResourceContract + public function response(array $data): ResponseContract { - return new UploadBlobResource([ + return new UploadBlobResponse([ 'blob' => Blob::viaBinary($this->parameter('blob')) ]); } diff --git a/src/Lexicons/Com/Atproto/Server/CreateSession.php b/src/Lexicons/Com/Atproto/Server/CreateSession.php index 10de78c..7fd5dec 100644 --- a/src/Lexicons/Com/Atproto/Server/CreateSession.php +++ b/src/Lexicons/Com/Atproto/Server/CreateSession.php @@ -5,10 +5,10 @@ use Atproto\Client; use Atproto\Contracts\LexiconContract; use Atproto\Contracts\Lexicons\RequestContract; -use Atproto\Contracts\Resources\ResourceContract; +use Atproto\Contracts\Resources\ResponseContract; use Atproto\Lexicons\APIRequest; use Atproto\Lexicons\Traits\Endpoint; -use Atproto\Resources\Com\Atproto\Server\CreateSessionResource; +use Atproto\Responses\Com\Atproto\Server\CreateSessionResponse; class CreateSession extends APIRequest implements LexiconContract { @@ -29,8 +29,8 @@ public function build(): RequestContract return $this; } - public function resource(array $data): ResourceContract + public function response(array $data): ResponseContract { - return new CreateSessionResource($data); + return new CreateSessionResponse($data); } } diff --git a/src/Lexicons/Traits/RequestHandler.php b/src/Lexicons/Traits/RequestHandler.php index 9be41e8..dd0b6f6 100644 --- a/src/Lexicons/Traits/RequestHandler.php +++ b/src/Lexicons/Traits/RequestHandler.php @@ -2,13 +2,13 @@ namespace Atproto\Lexicons\Traits; -use Atproto\Contracts\Resources\ResourceContract; +use Atproto\Contracts\Resources\ResponseContract; use Atproto\Exceptions\BlueskyException; use Atproto\Exceptions\cURLException; trait RequestHandler { - /** @var resource|ResourceContract $resource */ + /** @var resource|ResponseContract $resource */ private $resource; /** @var array $responseHeaders */ diff --git a/src/Resources/App/Bsky/Actor/GetProfileResource.php b/src/Resources/App/Bsky/Actor/GetProfileResource.php deleted file mode 100644 index e087373..0000000 --- a/src/Resources/App/Bsky/Actor/GetProfileResource.php +++ /dev/null @@ -1,33 +0,0 @@ - ProfilesAsset::class - ]; - } -} diff --git a/src/Resources/App/Bsky/Graph/GetFollowersResource.php b/src/Resources/App/Bsky/Graph/GetFollowersResource.php deleted file mode 100644 index 3db3eb8..0000000 --- a/src/Resources/App/Bsky/Graph/GetFollowersResource.php +++ /dev/null @@ -1,30 +0,0 @@ -content = $content; - } - - protected function casts(): array - { - return [ - 'followers' => FollowersAsset::class, - 'subject' => SubjectAsset::class, - ]; - } -} diff --git a/src/Resources/Assets/AssociatedAsset.php b/src/Resources/Assets/AssociatedAsset.php deleted file mode 100644 index 43c3532..0000000 --- a/src/Resources/Assets/AssociatedAsset.php +++ /dev/null @@ -1,34 +0,0 @@ -content = $content; - } - - protected function casts(): array - { - return [ - 'chat' => ChatAsset::class, - ]; - } -} diff --git a/src/Resources/Assets/BlockingByListAsset.php b/src/Resources/Assets/BlockingByListAsset.php deleted file mode 100644 index 1ad6a52..0000000 --- a/src/Resources/Assets/BlockingByListAsset.php +++ /dev/null @@ -1,23 +0,0 @@ -content = $content; - } - - public function cast(): self - { - return $this; - } -} diff --git a/src/Resources/Assets/CreatorAsset.php b/src/Resources/Assets/CreatorAsset.php deleted file mode 100644 index 3ecaeba..0000000 --- a/src/Resources/Assets/CreatorAsset.php +++ /dev/null @@ -1,28 +0,0 @@ -value); - } -} diff --git a/src/Resources/Assets/FollowerAsset.php b/src/Resources/Assets/FollowerAsset.php deleted file mode 100644 index 9b4c284..0000000 --- a/src/Resources/Assets/FollowerAsset.php +++ /dev/null @@ -1,21 +0,0 @@ -content = $content; - } - - public function casts(): array - { - return [ - 'creator' => CreatorAsset::class, - 'labels' => LabelsAsset::class, - 'indexedAt' => DatetimeAsset::class, - ]; - } -} diff --git a/src/Resources/Assets/KnownFollowersAsset.php b/src/Resources/Assets/KnownFollowersAsset.php deleted file mode 100644 index 7c588df..0000000 --- a/src/Resources/Assets/KnownFollowersAsset.php +++ /dev/null @@ -1,26 +0,0 @@ -content = $content; - } - - protected function casts(): array - { - return [ - 'followers' => FollowersAsset::class, - ]; - } -} diff --git a/src/Resources/Assets/LabelAsset.php b/src/Resources/Assets/LabelAsset.php deleted file mode 100644 index 16b3a5e..0000000 --- a/src/Resources/Assets/LabelAsset.php +++ /dev/null @@ -1,38 +0,0 @@ -content = $content; - } - - public function casts(): array - { - return [ - 'cts' => DatetimeAsset::class, - 'exp' => DatetimeAsset::class, - ]; - } -} diff --git a/src/Resources/Assets/MutedByListAsset.php b/src/Resources/Assets/MutedByListAsset.php deleted file mode 100644 index 8843faf..0000000 --- a/src/Resources/Assets/MutedByListAsset.php +++ /dev/null @@ -1,23 +0,0 @@ -content = $content; - } - - protected function casts(): array - { - return [ - 'associated' => AssociatedAsset::class, - 'indexedAt' => DatetimeAsset::class, - 'joinedViaStarterPack' => JoinedViaStarterPackAsset::class, - 'createdAt' => DatetimeAsset::class, - 'viewer' => ViewerAsset::class, - 'labels' => LabelsAsset::class, - ]; - } -} diff --git a/src/Resources/Assets/ViewerAsset.php b/src/Resources/Assets/ViewerAsset.php deleted file mode 100644 index 37582c1..0000000 --- a/src/Resources/Assets/ViewerAsset.php +++ /dev/null @@ -1,41 +0,0 @@ -content = $content; - } - - public function casts(): array - { - return [ - 'mutedByList' => MutedByListAsset::class, - 'blockingByList' => BlockingByListAsset::class, - 'knownFollowers' => KnownFollowersAsset::class, - 'labels' => LabelsAsset::class, - ]; - } -} diff --git a/src/Resources/Com/Atproto/Repo/CreateRecordResource.php b/src/Resources/Com/Atproto/Repo/CreateRecordResource.php deleted file mode 100644 index 3b94621..0000000 --- a/src/Resources/Com/Atproto/Repo/CreateRecordResource.php +++ /dev/null @@ -1,11 +0,0 @@ - ProfilesObject::class + ]; + } +} diff --git a/src/Responses/App/Bsky/Graph/GetFollowersResponse.php b/src/Responses/App/Bsky/Graph/GetFollowersResponse.php new file mode 100644 index 0000000..197ed3f --- /dev/null +++ b/src/Responses/App/Bsky/Graph/GetFollowersResponse.php @@ -0,0 +1,30 @@ +content = $content; + } + + protected function casts(): array + { + return [ + 'followers' => FollowersObject::class, + 'subject' => SubjectObject::class, + ]; + } +} diff --git a/src/Resources/BaseResource.php b/src/Responses/BaseResponse.php similarity index 90% rename from src/Resources/BaseResource.php rename to src/Responses/BaseResponse.php index 25a7c4c..ce7964b 100644 --- a/src/Resources/BaseResource.php +++ b/src/Responses/BaseResponse.php @@ -1,13 +1,13 @@ content, $name); if (in_array(Castable::class, class_uses_recursive(static::class))) { - /** @var ?AssetContract $cast */ + /** @var ?ObjectContract $cast */ $asset = Arr::get($this->casts(), $name); if ($asset) { diff --git a/src/Responses/Com/Atproto/Repo/CreateRecordResponse.php b/src/Responses/Com/Atproto/Repo/CreateRecordResponse.php new file mode 100644 index 0000000..2332e27 --- /dev/null +++ b/src/Responses/Com/Atproto/Repo/CreateRecordResponse.php @@ -0,0 +1,11 @@ +content = $content; + } + + protected function casts(): array + { + return [ + 'chat' => ChatObject::class, + ]; + } +} diff --git a/src/Resources/Assets/BaseAsset.php b/src/Responses/Objects/BaseObject.php similarity index 65% rename from src/Resources/Assets/BaseAsset.php rename to src/Responses/Objects/BaseObject.php index 6f17505..3713e30 100644 --- a/src/Resources/Assets/BaseAsset.php +++ b/src/Responses/Objects/BaseObject.php @@ -1,10 +1,10 @@ cast(); } - public function cast(): AssetContract + public function cast(): ObjectContract { return $this; } diff --git a/src/Responses/Objects/BlockingByListObject.php b/src/Responses/Objects/BlockingByListObject.php new file mode 100644 index 0000000..1f8cb85 --- /dev/null +++ b/src/Responses/Objects/BlockingByListObject.php @@ -0,0 +1,23 @@ + LabelsAsset::class, - 'viewer' => ViewerAsset::class, - 'indexedAt' => DatetimeAsset::class, + 'labels' => LabelsObject::class, + 'viewer' => ViewerObject::class, + 'indexedAt' => DatetimeObject::class, ]; } } diff --git a/src/Responses/Objects/ChatObject.php b/src/Responses/Objects/ChatObject.php new file mode 100644 index 0000000..79cebcd --- /dev/null +++ b/src/Responses/Objects/ChatObject.php @@ -0,0 +1,26 @@ +content = $content; + } + + public function cast(): self + { + return $this; + } +} diff --git a/src/Resources/Assets/CollectionAsset.php b/src/Responses/Objects/CollectionAsset.php similarity index 74% rename from src/Resources/Assets/CollectionAsset.php rename to src/Responses/Objects/CollectionAsset.php index 2602dc5..5b40161 100644 --- a/src/Resources/Assets/CollectionAsset.php +++ b/src/Responses/Objects/CollectionAsset.php @@ -1,13 +1,13 @@ value); + } +} diff --git a/src/Responses/Objects/FollowerObject.php b/src/Responses/Objects/FollowerObject.php new file mode 100644 index 0000000..0b2723d --- /dev/null +++ b/src/Responses/Objects/FollowerObject.php @@ -0,0 +1,21 @@ +content = $content; + } + + public function casts(): array + { + return [ + 'creator' => CreatorObject::class, + 'labels' => LabelsObject::class, + 'indexedAt' => DatetimeObject::class, + ]; + } +} diff --git a/src/Responses/Objects/KnownFollowersObject.php b/src/Responses/Objects/KnownFollowersObject.php new file mode 100644 index 0000000..dc2a030 --- /dev/null +++ b/src/Responses/Objects/KnownFollowersObject.php @@ -0,0 +1,26 @@ +content = $content; + } + + protected function casts(): array + { + return [ + 'followers' => FollowersObject::class, + ]; + } +} diff --git a/src/Responses/Objects/LabelObject.php b/src/Responses/Objects/LabelObject.php new file mode 100644 index 0000000..cc82696 --- /dev/null +++ b/src/Responses/Objects/LabelObject.php @@ -0,0 +1,38 @@ +content = $content; + } + + public function casts(): array + { + return [ + 'cts' => DatetimeObject::class, + 'exp' => DatetimeObject::class, + ]; + } +} diff --git a/src/Resources/Assets/LabelsAsset.php b/src/Responses/Objects/LabelsObject.php similarity index 62% rename from src/Resources/Assets/LabelsAsset.php rename to src/Responses/Objects/LabelsObject.php index 55f7904..cc27747 100644 --- a/src/Resources/Assets/LabelsAsset.php +++ b/src/Responses/Objects/LabelsObject.php @@ -1,23 +1,23 @@ content = $content; + } + + protected function casts(): array + { + return [ + 'associated' => AssociatedObject::class, + 'indexedAt' => DatetimeObject::class, + 'joinedViaStarterPack' => JoinedViaStarterPackObject::class, + 'createdAt' => DatetimeObject::class, + 'viewer' => ViewerObject::class, + 'labels' => LabelsObject::class, + ]; + } +} diff --git a/src/Responses/Objects/ViewerObject.php b/src/Responses/Objects/ViewerObject.php new file mode 100644 index 0000000..646fdf5 --- /dev/null +++ b/src/Responses/Objects/ViewerObject.php @@ -0,0 +1,41 @@ +content = $content; + } + + public function casts(): array + { + return [ + 'mutedByList' => MutedByListObject::class, + 'blockingByList' => BlockingByListObject::class, + 'knownFollowers' => KnownFollowersObject::class, + 'labels' => LabelsObject::class, + ]; + } +} diff --git a/src/Traits/Authentication.php b/src/Traits/Authentication.php index 11b11ac..1b5d8bd 100644 --- a/src/Traits/Authentication.php +++ b/src/Traits/Authentication.php @@ -3,13 +3,13 @@ namespace Atproto\Traits; use Atproto\Exceptions\BlueskyException; -use Atproto\Resources\Com\Atproto\Server\CreateSessionResource; +use Atproto\Responses\Com\Atproto\Server\CreateSessionResponse; use SplObjectStorage; use SplObserver; trait Authentication { - private ?CreateSessionResource $authenticated = null; + private ?CreateSessionResponse $authenticated = null; private SplObjectStorage $observers; public function __construct() @@ -24,7 +24,7 @@ public function authenticate(string $identifier, string $password): void { $request = $this->com()->atproto()->server()->createSession()->forge($identifier, $password); - /** @var CreateSessionResource $response */ + /** @var CreateSessionResponse $response */ $response = $request->send(); $this->authenticated = $response; @@ -32,7 +32,7 @@ public function authenticate(string $identifier, string $password): void $this->notify(); } - public function authenticated(): ?CreateSessionResource + public function authenticated(): ?CreateSessionResponse { return $this->authenticated; } diff --git a/tests/Feature/ClientTest.php b/tests/Feature/ClientTest.php index a0fa951..15219e4 100644 --- a/tests/Feature/ClientTest.php +++ b/tests/Feature/ClientTest.php @@ -3,12 +3,12 @@ namespace Tests\Feature; use Atproto\Client; -use Atproto\Contracts\Resources\ResourceContract; +use Atproto\Contracts\Resources\ResponseContract; use Atproto\Exceptions\BlueskyException; use Atproto\Exceptions\Http\Response\AuthenticationRequiredException; use Atproto\Exceptions\Http\Response\AuthMissingException; -use Atproto\Resources\App\Bsky\Actor\GetProfileResource; -use Atproto\Resources\Com\Atproto\Server\CreateSessionResource; +use Atproto\Responses\App\Bsky\Actor\GetProfileResponse; +use Atproto\Responses\Com\Atproto\Server\CreateSessionResponse; use Carbon\Carbon; use PHPUnit\Framework\TestCase; use ReflectionException; @@ -44,10 +44,10 @@ public function testGetProfile(): void $password ); - /** @var CreateSessionResource $authenticated */ + /** @var CreateSessionResponse $authenticated */ $authenticated = $this->getPropertyValue('authenticated', $this->client); - $this->assertInstanceOf(ResourceContract::class, $authenticated); + $this->assertInstanceOf(ResponseContract::class, $authenticated); $this->assertIsString($authenticated->handle()); $this->assertSame($username, $authenticated->handle()); @@ -61,8 +61,8 @@ public function testGetProfile(): void ->actor($this->client->authenticated()->did()) ->send(); - $this->assertInstanceOf(ResourceContract::class, $profile); - $this->assertInstanceOf(GetProfileResource::class, $profile); + $this->assertInstanceOf(ResponseContract::class, $profile); + $this->assertInstanceOf(GetProfileResponse::class, $profile); $this->assertInstanceOf(Carbon::class, $profile->createdAt()); } @@ -114,7 +114,7 @@ public function testObserverNotificationOnAuthentication(): void ->build() ->send(); - $this->assertInstanceOf(ResourceContract::class, $response); - $this->assertInstanceOf(GetProfileResource::class, $response); + $this->assertInstanceOf(ResponseContract::class, $response); + $this->assertInstanceOf(GetProfileResponse::class, $response); } } diff --git a/tests/Feature/Lexicons/App/Bsky/Graph/GetFollowersTest.php b/tests/Feature/Lexicons/App/Bsky/Graph/GetFollowersTest.php index 88e9c29..8256390 100644 --- a/tests/Feature/Lexicons/App/Bsky/Graph/GetFollowersTest.php +++ b/tests/Feature/Lexicons/App/Bsky/Graph/GetFollowersTest.php @@ -3,7 +3,7 @@ namespace Tests\Feature\Lexicons\App\Bsky\Graph; use Atproto\Client; -use Atproto\Resources\Assets\FollowersAsset; +use Atproto\Responses\Objects\FollowersObject; use PHPUnit\Framework\TestCase; class GetFollowersTest extends TestCase @@ -36,6 +36,6 @@ public function testGetFollowers() $response = $request->send(); $this->assertSame($client->authenticated()->did(), $response->subject()->did()); - $this->assertInstanceOf(FollowersAsset::class, $response->followers()); + $this->assertInstanceOf(FollowersObject::class, $response->followers()); } } diff --git a/tests/Supports/ByListAssetTest.php b/tests/Supports/ByListAssetTest.php index afd74db..0b7cb44 100644 --- a/tests/Supports/ByListAssetTest.php +++ b/tests/Supports/ByListAssetTest.php @@ -2,8 +2,8 @@ namespace Tests\Supports; -use Atproto\Resources\Assets\LabelsAsset; -use Atproto\Resources\Assets\ViewerAsset; +use Atproto\Responses\Objects\LabelsObject; +use Atproto\Responses\Objects\ViewerObject; use Carbon\Carbon; trait ByListAssetTest @@ -27,8 +27,8 @@ public function nonPrimitiveAssetsProvider(): array list($this->faker) = self::getData(); return [ - ['labels', LabelsAsset::class, []], - ['viewer', ViewerAsset::class, []], + ['labels', LabelsObject::class, []], + ['viewer', ViewerObject::class, []], ['indexedAt', Carbon::class, $this->faker->dateTime->format(DATE_ATOM)], ]; } diff --git a/tests/Supports/NonPrimitiveAssetTest.php b/tests/Supports/NonPrimitiveAssetTest.php index 21785e2..feb2ea6 100644 --- a/tests/Supports/NonPrimitiveAssetTest.php +++ b/tests/Supports/NonPrimitiveAssetTest.php @@ -2,7 +2,7 @@ namespace Tests\Supports; -use Atproto\Contracts\Resources\AssetContract; +use Atproto\Contracts\Resources\ObjectContract; use GenericCollection\Exceptions\InvalidArgumentException; use ReflectionClass; use ReflectionException; @@ -27,7 +27,7 @@ public function testNonPrimitiveAssets(string $name, string $expectedAsset, $val $this->assertTrue(true); - if (! $actualAsset instanceof AssetContract) { + if (! $actualAsset instanceof ObjectContract) { return; } diff --git a/tests/Supports/UserAssetTest.php b/tests/Supports/UserAssetTest.php index 637154c..b0bacce 100644 --- a/tests/Supports/UserAssetTest.php +++ b/tests/Supports/UserAssetTest.php @@ -2,9 +2,9 @@ namespace Tests\Supports; -use Atproto\Resources\Assets\AssociatedAsset; -use Atproto\Resources\Assets\JoinedViaStarterPackAsset; -use Atproto\Resources\Assets\ViewerAsset; +use Atproto\Responses\Objects\AssociatedObject; +use Atproto\Responses\Objects\JoinedViaStarterPackObject; +use Atproto\Responses\Objects\ViewerObject; use GenericCollection\Interfaces\GenericCollectionInterface; trait UserAssetTest @@ -32,9 +32,9 @@ public function primitiveAssetsProvider(): array public function nonPrimitiveAssetsProvider(): array { return [ - ['associated', AssociatedAsset::class, []], - ['joinedViaStarterPack', JoinedViaStarterPackAsset::class, []], - ['viewer', ViewerAsset::class, []], + ['associated', AssociatedObject::class, []], + ['joinedViaStarterPack', JoinedViaStarterPackObject::class, []], + ['viewer', ViewerObject::class, []], ['labels', GenericCollectionInterface::class, []], ]; } diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php index c19859e..fd15366 100644 --- a/tests/Unit/ClientTest.php +++ b/tests/Unit/ClientTest.php @@ -7,7 +7,7 @@ use Atproto\Exceptions\Http\Request\LexiconNotFoundException; use Atproto\Lexicons\APIRequest; use Atproto\Lexicons\Com\Atproto\Server\CreateSession; -use Atproto\Resources\Com\Atproto\Server\CreateSessionResource; +use Atproto\Responses\Com\Atproto\Server\CreateSessionResponse; use PHPUnit\Framework\TestCase; use ReflectionException; use SplObserver; @@ -163,7 +163,7 @@ private function mockAuthenticate() $mockCreateSession->expects($this->once()) ->method('send') - ->willReturn($this->createMock(CreateSessionResource::class)); + ->willReturn($this->createMock(CreateSessionResponse::class)); $this->client = $this->getMockBuilder(Client::class) ->onlyMethods(['forge']) diff --git a/tests/Unit/Lexicons/App/Bsky/Actor/GetProfilesTest.php b/tests/Unit/Lexicons/App/Bsky/Actor/GetProfilesTest.php index 3d4204f..3812c8d 100644 --- a/tests/Unit/Lexicons/App/Bsky/Actor/GetProfilesTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Actor/GetProfilesTest.php @@ -7,7 +7,7 @@ use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Actor\GetProfiles; -use Atproto\Resources\App\Bsky\Actor\GetProfilesResource; +use Atproto\Responses\App\Bsky\Actor\GetProfilesResponse; use GenericCollection\GenericCollection; use GenericCollection\Types\Primitive\StringType; use PHPUnit\Framework\TestCase; @@ -88,7 +88,7 @@ public function testBuildSucceeds(): void public function testResourceMethodReturnsCorrectInstance(): void { $data = ['actors' => []]; - $resource = $this->request->resource($data); - $this->assertInstanceOf(GetProfilesResource::class, $resource, 'Resource method should return an instance of GetProfilesResource.'); + $resource = $this->request->response($data); + $this->assertInstanceOf(GetProfilesResponse::class, $resource, 'Resource method should return an instance of GetProfilesResource.'); } } diff --git a/tests/Unit/Lexicons/App/Bsky/Graph/GetFollowersTest.php b/tests/Unit/Lexicons/App/Bsky/Graph/GetFollowersTest.php index 9dbe78f..09644be 100644 --- a/tests/Unit/Lexicons/App/Bsky/Graph/GetFollowersTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Graph/GetFollowersTest.php @@ -3,7 +3,7 @@ namespace Tests\Unit\Lexicons\App\Bsky\Graph; use Atproto\Client; -use Atproto\Contracts\Resources\ResourceContract; +use Atproto\Contracts\Resources\ResponseContract; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\Exceptions\Http\Response\AuthMissingException; use Atproto\Exceptions\InvalidArgumentException; @@ -91,8 +91,8 @@ public function testBuildSucceedsWithRequiredParameters() public function testResourceMethodReturnsCorrectInstance() { $data = ['followers' => []]; - $resource = $this->request->resource($data); - $this->assertInstanceOf(ResourceContract::class, $resource, 'Resource method should return an instance of ResourceContract.'); + $resource = $this->request->response($data); + $this->assertInstanceOf(ResponseContract::class, $resource, 'Resource method should return an instance of ResourceContract.'); } public function testLimitGetterReturnsNullWhenNotSet() diff --git a/tests/Unit/Resources/Assets/BlockingByListAssetTest.php b/tests/Unit/Resources/Assets/BlockingByListAssetTest.php deleted file mode 100644 index 24de5a3..0000000 --- a/tests/Unit/Resources/Assets/BlockingByListAssetTest.php +++ /dev/null @@ -1,21 +0,0 @@ -faker) = self::getData(); return [ - ['associated', AssociatedAsset::class, ['data' => $this->faker->uuid]], - ['joinedViaStarterPack', JoinedViaStarterPackAsset::class, ['data' => $this->faker->uuid]], - ['viewer', ViewerAsset::class, ['data' => $this->faker->uuid]], + ['associated', AssociatedObject::class, ['data' => $this->faker->uuid]], + ['joinedViaStarterPack', JoinedViaStarterPackObject::class, ['data' => $this->faker->uuid]], + ['viewer', ViewerObject::class, ['data' => $this->faker->uuid]], ['labels', GenericCollectionInterface::class, [ ['cts' => $this->faker->dateTime], ['exp' => $this->faker->dateTime], ]], - ['indexedAt', DatetimeAsset::class, $this->faker->dateTime], - ['createdAt', DatetimeAsset::class, $this->faker->dateTime], + ['indexedAt', DatetimeObject::class, $this->faker->dateTime], + ['createdAt', DatetimeObject::class, $this->faker->dateTime], ]; } - protected function resource(array $data): ResourceContract + protected function resource(array $data): ResponseContract { - return new GetProfileResource($data); + return new GetProfileResponse($data); } } diff --git a/tests/Unit/Resources/Assets/AssociatedAssetTest.php b/tests/Unit/Responses/Assets/AssociatedObjectTest.php similarity index 71% rename from tests/Unit/Resources/Assets/AssociatedAssetTest.php rename to tests/Unit/Responses/Assets/AssociatedObjectTest.php index c85dc7f..8e804af 100644 --- a/tests/Unit/Resources/Assets/AssociatedAssetTest.php +++ b/tests/Unit/Responses/Assets/AssociatedObjectTest.php @@ -1,15 +1,15 @@ faker) = self::getData(); return [ - ['chat', ChatAsset::class, ['allowIncoming' => $this->faker->shuffleString]], + ['chat', ChatObject::class, ['allowIncoming' => $this->faker->shuffleString]], ]; } /** * @throws InvalidArgumentException */ - protected function resource($data): AssociatedAsset + protected function resource($data): AssociatedObject { - return new AssociatedAsset($data); + return new AssociatedObject($data); } } diff --git a/tests/Unit/Resources/Assets/BaseAssetTest.php b/tests/Unit/Responses/Assets/BaseObjectTest.php similarity index 64% rename from tests/Unit/Resources/Assets/BaseAssetTest.php rename to tests/Unit/Responses/Assets/BaseObjectTest.php index 42008ce..e17a407 100644 --- a/tests/Unit/Resources/Assets/BaseAssetTest.php +++ b/tests/Unit/Responses/Assets/BaseObjectTest.php @@ -1,21 +1,21 @@ asset->cast(); - $this->assertInstanceOf(AssetContract::class, $result); + $this->assertInstanceOf(ObjectContract::class, $result); $this->assertSame($this->asset, $result); } @@ -47,13 +47,13 @@ public function testRevertReturnsValue() /** * @throws InvalidArgumentException */ - protected function resource(array $data): TestableAsset + protected function resource(array $data): TestableObject { - return new TestableAsset($data); + return new TestableObject($data); } } -class TestableAsset implements AssetContract +class TestableObject implements ObjectContract { - use BaseAsset; + use BaseObject; } diff --git a/tests/Unit/Responses/Assets/BlockingByListObjectTest.php b/tests/Unit/Responses/Assets/BlockingByListObjectTest.php new file mode 100644 index 0000000..a1f514f --- /dev/null +++ b/tests/Unit/Responses/Assets/BlockingByListObjectTest.php @@ -0,0 +1,21 @@ +assertInstanceOf(TestCollectionAsset::class, $this->collectionAsset); + $this->assertInstanceOf(TestCollectionObject::class, $this->collectionAsset); } public function testCastMethod() { $result = $this->collectionAsset->cast(); - $this->assertInstanceOf(TestCollectionAsset::class, $result); + $this->assertInstanceOf(TestCollectionObject::class, $result); } public function testGetMethod() { foreach($this->collectionAsset as $item) { - $this->assertInstanceOf(ExampleAsset::class, $item); + $this->assertInstanceOf(ExampleObject::class, $item); } } @@ -71,13 +71,13 @@ public function testTypeMethod() /** * @throws InvalidArgumentException */ - protected function resource(array $data): TestCollectionAsset + protected function resource(array $data): TestCollectionObject { - return new TestCollectionAsset($data); + return new TestCollectionObject($data); } } -class TestCollectionAsset extends GenericCollection implements AssetContract +class TestCollectionObject extends GenericCollection implements ObjectContract { use CollectionAsset; @@ -86,9 +86,9 @@ public function __construct(array $content) parent::__construct(new ExampleAssetType(), $content); } - public function item($data): AssetContract + public function item($data): ObjectContract { - return new ExampleAsset($data); + return new ExampleObject($data); } public function type(): TypeInterface @@ -97,7 +97,7 @@ public function type(): TypeInterface } } -class ExampleAsset implements AssetContract +class ExampleObject implements ObjectContract { protected $value; @@ -106,7 +106,7 @@ public function __construct($value) $this->value = $value; } - public function cast(): ExampleAsset + public function cast(): ExampleObject { return $this; } @@ -121,6 +121,6 @@ class ExampleAssetType implements TypeInterface { public function validate($value): bool { - return $value instanceof ExampleAsset; + return $value instanceof ExampleObject; } } diff --git a/tests/Unit/Responses/Assets/CreatorObjectTest.php b/tests/Unit/Responses/Assets/CreatorObjectTest.php new file mode 100644 index 0000000..f3eded2 --- /dev/null +++ b/tests/Unit/Responses/Assets/CreatorObjectTest.php @@ -0,0 +1,17 @@ +resource([]); $actual = $instance; - $expected = DatetimeAsset::class; + $expected = DatetimeObject::class; $this->assertInstanceOf($expected, $actual); } @@ -49,8 +49,8 @@ public function testCastThrowsExceptionWhenPassingInvalidDateFormat(): void /** * @throws InvalidArgumentException */ - protected function resource(array $data): DatetimeAsset + protected function resource(array $data): DatetimeObject { - return new DatetimeAsset(current($data)); + return new DatetimeObject(current($data)); } } diff --git a/tests/Unit/Responses/Assets/FollowerObjectTest.php b/tests/Unit/Responses/Assets/FollowerObjectTest.php new file mode 100644 index 0000000..f512266 --- /dev/null +++ b/tests/Unit/Responses/Assets/FollowerObjectTest.php @@ -0,0 +1,23 @@ +resource([]); - $this->assertAssetInstance($instance, FollowersAsset::class); + $this->assertAssetInstance($instance, FollowersObject::class); } /** @@ -53,7 +53,7 @@ public function testAssetWithPrimitiveTypes(): void $followers = $this->resource($data); foreach ($data as $index => $datum) { - /** @var FollowerAsset $follower */ + /** @var FollowerObject $follower */ $follower = $followers->get($index); $this->assertFollowerMatchesData($follower, $datum); } @@ -76,9 +76,9 @@ public function testAssetWithNonPrimitiveTypes(): void /** * @throws InvalidArgumentException */ - protected function resource(array $data): FollowersAsset + protected function resource(array $data): FollowersObject { - return new FollowersAsset($data); + return new FollowersObject($data); } protected function generateFollowerData(): array @@ -109,14 +109,14 @@ protected function generateComplexFollowerData(): array protected function assertAssetInstance($instance, string $class): void { $this->assertInstanceOf(GenericCollectionInterface::class, $instance); - $this->assertInstanceOf(AssetContract::class, $instance); + $this->assertInstanceOf(ObjectContract::class, $instance); $this->assertInstanceOf($class, $instance); } /** * @throws BadAssetCallException */ - protected function assertFollowerMatchesData(FollowerAsset $follower, array $data): void + protected function assertFollowerMatchesData(FollowerObject $follower, array $data): void { foreach ($data as $key => $value) { $this->assertSame($follower->$key(), $value); @@ -124,7 +124,7 @@ protected function assertFollowerMatchesData(FollowerAsset $follower, array $dat } } - protected function assertFollowerMatchesComplexData(FollowerAsset $follower): void + protected function assertFollowerMatchesComplexData(FollowerObject $follower): void { list(, , $schema) = self::getData(); @@ -143,23 +143,23 @@ protected static function getData(): array $schema = [ 'associated' => [ - 'caster' => AssociatedAsset::class, - 'casted' => AssociatedAsset::class, + 'caster' => AssociatedObject::class, + 'casted' => AssociatedObject::class, 'value' => [] ], 'viewer' => [ - 'caster' => ViewerAsset::class, - 'casted' => ViewerAsset::class, + 'caster' => ViewerObject::class, + 'casted' => ViewerObject::class, 'value' => [] ], 'createdAt' => [ - 'caster' => DatetimeAsset::class, + 'caster' => DatetimeObject::class, 'casted' => Carbon::class, 'value' => $faker->dateTime->format(DATE_ATOM) ], "labels" => [ - 'caster' => LabelsAsset::class, - 'casted' => LabelsAsset::class, + 'caster' => LabelsObject::class, + 'casted' => LabelsObject::class, 'value' => [] ] ]; diff --git a/tests/Unit/Resources/Assets/JoinedViaStarterPackAssetTest.php b/tests/Unit/Responses/Assets/JoinedViaStarterPackObjectTest.php similarity index 73% rename from tests/Unit/Resources/Assets/JoinedViaStarterPackAssetTest.php rename to tests/Unit/Responses/Assets/JoinedViaStarterPackObjectTest.php index 10d9066..b0b214c 100644 --- a/tests/Unit/Resources/Assets/JoinedViaStarterPackAssetTest.php +++ b/tests/Unit/Responses/Assets/JoinedViaStarterPackObjectTest.php @@ -1,17 +1,17 @@ faker) = self::getData(); return [ - ['creator', CreatorAsset::class, []], - ['labels', LabelsAsset::class, []], + ['creator', CreatorObject::class, []], + ['labels', LabelsObject::class, []], ['indexedAt', Carbon::class, $this->faker->dateTime->format(DATE_ATOM)], ]; } diff --git a/tests/Unit/Resources/Assets/KnownFollowersAssetTest.php b/tests/Unit/Responses/Assets/KnownFollowersObjectTest.php similarity index 67% rename from tests/Unit/Resources/Assets/KnownFollowersAssetTest.php rename to tests/Unit/Responses/Assets/KnownFollowersObjectTest.php index 384ab00..cb25e1c 100644 --- a/tests/Unit/Resources/Assets/KnownFollowersAssetTest.php +++ b/tests/Unit/Responses/Assets/KnownFollowersObjectTest.php @@ -1,15 +1,15 @@ numberBetween(1, 20); return [ - ['followers', FollowersAsset::class, [array_map(fn () => [ + ['followers', FollowersObject::class, [array_map(fn () => [ 'displayName' => $faker->name, ], range(1, $count))]], ]; diff --git a/tests/Unit/Resources/Assets/LabelAssetTest.php b/tests/Unit/Responses/Assets/LabelObjectTest.php similarity index 83% rename from tests/Unit/Resources/Assets/LabelAssetTest.php rename to tests/Unit/Responses/Assets/LabelObjectTest.php index f581f1e..14c10ce 100644 --- a/tests/Unit/Resources/Assets/LabelAssetTest.php +++ b/tests/Unit/Responses/Assets/LabelObjectTest.php @@ -1,8 +1,8 @@ [ - 'caster' => DatetimeAsset::class, + 'caster' => DatetimeObject::class, 'casted' => Carbon::class, 'value' => $faker->dateTime->format(DATE_ATOM) ], 'exp' => [ - 'caster' => DatetimeAsset::class, + 'caster' => DatetimeObject::class, 'casted' => Carbon::class, 'value' => $faker->dateTime->format(DATE_ATOM) ], diff --git a/tests/Unit/Responses/Assets/MutedByListObjectTest.php b/tests/Unit/Responses/Assets/MutedByListObjectTest.php new file mode 100644 index 0000000..7d43426 --- /dev/null +++ b/tests/Unit/Responses/Assets/MutedByListObjectTest.php @@ -0,0 +1,21 @@ +assertEquals(50, $this->userAsset->followsCount()); $this->assertEquals(10, $this->userAsset->postsCount()); - $this->assertInstanceOf(AssociatedAsset::class, $this->userAsset->associated()); - $this->assertInstanceOf(JoinedViaStarterPackAsset::class, $this->userAsset->joinedViaStarterPack()); + $this->assertInstanceOf(AssociatedObject::class, $this->userAsset->associated()); + $this->assertInstanceOf(JoinedViaStarterPackObject::class, $this->userAsset->joinedViaStarterPack()); $this->assertInstanceOf(Carbon::class, $this->userAsset->indexedAt()); $this->assertInstanceOf(Carbon::class, $this->userAsset->createdAt()); - $this->assertInstanceOf(ViewerAsset::class, $this->userAsset->viewer()); - $this->assertInstanceOf(LabelsAsset::class, $this->userAsset->labels()); + $this->assertInstanceOf(ViewerObject::class, $this->userAsset->viewer()); + $this->assertInstanceOf(LabelsObject::class, $this->userAsset->labels()); } public function testCastsMethod() @@ -110,12 +110,12 @@ public function testCastsMethod() $this->assertArrayHasKey('viewer', $casts); $this->assertArrayHasKey('labels', $casts); - $this->assertEquals(AssociatedAsset::class, $casts['associated']); - $this->assertEquals(DatetimeAsset::class, $casts['indexedAt']); - $this->assertEquals(JoinedViaStarterPackAsset::class, $casts['joinedViaStarterPack']); - $this->assertEquals(DatetimeAsset::class, $casts['createdAt']); - $this->assertEquals(ViewerAsset::class, $casts['viewer']); - $this->assertEquals(LabelsAsset::class, $casts['labels']); + $this->assertEquals(AssociatedObject::class, $casts['associated']); + $this->assertEquals(DatetimeObject::class, $casts['indexedAt']); + $this->assertEquals(JoinedViaStarterPackObject::class, $casts['joinedViaStarterPack']); + $this->assertEquals(DatetimeObject::class, $casts['createdAt']); + $this->assertEquals(ViewerObject::class, $casts['viewer']); + $this->assertEquals(LabelsObject::class, $casts['labels']); } /** @@ -129,5 +129,5 @@ protected function resource(array $data): TestUserAsset class TestUserAsset { - use UserAsset; + use UserObject; } diff --git a/tests/Unit/Responses/Assets/ViewerObjectTest.php b/tests/Unit/Responses/Assets/ViewerObjectTest.php new file mode 100644 index 0000000..95acf14 --- /dev/null +++ b/tests/Unit/Responses/Assets/ViewerObjectTest.php @@ -0,0 +1,45 @@ +resource = new TestableResource([ + $this->resource = new TestableResponse([ 'example' => 'some value', ]); } @@ -27,7 +27,7 @@ public function testGetMethodWithExistingAsset() { $result = $this->resource->get('example'); - $this->assertInstanceOf(ExampleAsset::class, $result); + $this->assertInstanceOf(ExampleObject::class, $result); } public function testGetMethodWithNonExistingAsset() @@ -47,25 +47,25 @@ public function testMagicCall() { $result = $this->resource->example(); - $this->assertInstanceOf(ExampleAsset::class, $result); + $this->assertInstanceOf(ExampleObject::class, $result); } } -class TestableResource implements ResourceContract +class TestableResponse implements ResponseContract { - use BaseResource; + use BaseResponse; use Castable; protected function casts(): array { return [ - 'example' => ExampleAsset::class + 'example' => ExampleObject::class ]; } } -class ExampleAsset implements AssetContract +class ExampleObject implements ObjectContract { - use BaseAsset; + use BaseObject; } From 9eace036dacfd5d89f23e4e70ab65423c1a4d1ca Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 9 Nov 2024 02:19:55 +0400 Subject: [PATCH 54/59] refactor: improve object and response contract hierarchy --- src/Contracts/Resources/ObjectContract.php | 2 +- src/Contracts/Resources/ResponseContract.php | 2 +- src/Responses/BaseResponse.php | 10 +++++----- src/Responses/Objects/AssociatedObject.php | 5 +---- src/Responses/Objects/BaseObject.php | 3 +++ src/Responses/Objects/BlockingByListObject.php | 5 ++--- .../Objects/{ByListAsset.php => ByListObject.php} | 4 +--- src/Responses/Objects/ChatObject.php | 5 +---- .../{CollectionAsset.php => CollectionObject.php} | 9 ++++++++- src/Responses/Objects/CreatorObject.php | 3 +-- src/Responses/Objects/FollowerObject.php | 3 +-- src/Responses/Objects/FollowersObject.php | 2 +- src/Responses/Objects/JoinedViaStarterPackObject.php | 5 +---- src/Responses/Objects/KnownFollowersObject.php | 2 -- src/Responses/Objects/LabelObject.php | 5 +---- src/Responses/Objects/LabelsObject.php | 2 +- src/Responses/Objects/MutedByListObject.php | 5 ++--- src/Responses/Objects/ProfileObject.php | 3 +-- src/Responses/Objects/ProfilesObject.php | 2 +- src/Responses/Objects/SubjectObject.php | 3 +-- src/Responses/Objects/UserObject.php | 2 -- src/Responses/Objects/ViewerObject.php | 5 +---- tests/Supports/NonPrimitiveAssetTest.php | 4 ++++ tests/Unit/Responses/Assets/CollectionObjectTest.php | 7 +++++-- 24 files changed, 44 insertions(+), 54 deletions(-) rename src/Responses/Objects/{ByListAsset.php => ByListObject.php} (90%) rename src/Responses/Objects/{CollectionAsset.php => CollectionObject.php} (77%) diff --git a/src/Contracts/Resources/ObjectContract.php b/src/Contracts/Resources/ObjectContract.php index a2648d7..0b8610a 100644 --- a/src/Contracts/Resources/ObjectContract.php +++ b/src/Contracts/Resources/ObjectContract.php @@ -2,7 +2,7 @@ namespace Atproto\Contracts\Resources; -interface ObjectContract +interface ObjectContract extends ResponseContract { public function cast(); public function revert(); diff --git a/src/Contracts/Resources/ResponseContract.php b/src/Contracts/Resources/ResponseContract.php index abc52e6..bd3e81a 100644 --- a/src/Contracts/Resources/ResponseContract.php +++ b/src/Contracts/Resources/ResponseContract.php @@ -20,7 +20,7 @@ interface ResponseContract * * @throws BadAssetCallException If the asset does not exist on resource. */ - public function get(string $name); + public function get($offset); /** * @param string $name diff --git a/src/Responses/BaseResponse.php b/src/Responses/BaseResponse.php index ce7964b..bb57314 100644 --- a/src/Responses/BaseResponse.php +++ b/src/Responses/BaseResponse.php @@ -30,19 +30,19 @@ public function __call(string $name, array $arguments) } /** - * @param string $name + * @param string $offset * * @return mixed * * @throws BadAssetCallException */ - public function get(string $name) + public function get($offset) { - if (! $this->exist($name)) { - throw new BadAssetCallException($name); + if (! $this->exist($offset)) { + throw new BadAssetCallException($offset); } - return $this->parse($name); + return $this->parse($offset); } public function exist(string $name): bool diff --git a/src/Responses/Objects/AssociatedObject.php b/src/Responses/Objects/AssociatedObject.php index b70da26..01bbdd1 100644 --- a/src/Responses/Objects/AssociatedObject.php +++ b/src/Responses/Objects/AssociatedObject.php @@ -3,8 +3,6 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; -use Atproto\Contracts\Resources\ResponseContract; -use Atproto\Responses\BaseResponse; use Atproto\Traits\Castable; /** @@ -14,9 +12,8 @@ * @method bool labeler() * @method ChatObject chat() */ -class AssociatedObject implements ResponseContract, ObjectContract +class AssociatedObject implements ObjectContract { - use BaseResponse; use BaseObject; use Castable; diff --git a/src/Responses/Objects/BaseObject.php b/src/Responses/Objects/BaseObject.php index 3713e30..f1c92f1 100644 --- a/src/Responses/Objects/BaseObject.php +++ b/src/Responses/Objects/BaseObject.php @@ -3,9 +3,12 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; +use Atproto\Responses\BaseResponse; trait BaseObject { + use BaseResponse; + /** @var mixed */ protected $value; diff --git a/src/Responses/Objects/BlockingByListObject.php b/src/Responses/Objects/BlockingByListObject.php index 1f8cb85..7badcf9 100644 --- a/src/Responses/Objects/BlockingByListObject.php +++ b/src/Responses/Objects/BlockingByListObject.php @@ -3,7 +3,6 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; -use Atproto\Contracts\Resources\ResponseContract; use Carbon\Carbon; /** @@ -17,7 +16,7 @@ * @method ViewerObject viewer() * @method Carbon indexedAt() */ -class BlockingByListObject implements ResponseContract, ObjectContract +class BlockingByListObject implements ObjectContract { - use ByListAsset; + use ByListObject; } diff --git a/src/Responses/Objects/ByListAsset.php b/src/Responses/Objects/ByListObject.php similarity index 90% rename from src/Responses/Objects/ByListAsset.php rename to src/Responses/Objects/ByListObject.php index 878bd58..d8b2166 100644 --- a/src/Responses/Objects/ByListAsset.php +++ b/src/Responses/Objects/ByListObject.php @@ -2,7 +2,6 @@ namespace Atproto\Responses\Objects; -use Atproto\Responses\BaseResponse; use Atproto\Traits\Castable; use Carbon\Carbon; @@ -17,9 +16,8 @@ * @method ViewerObject viewer() * @method Carbon indexedAt() */ -trait ByListAsset +trait ByListObject { - use BaseResponse; use BaseObject; use Castable; diff --git a/src/Responses/Objects/ChatObject.php b/src/Responses/Objects/ChatObject.php index 79cebcd..51cb684 100644 --- a/src/Responses/Objects/ChatObject.php +++ b/src/Responses/Objects/ChatObject.php @@ -3,15 +3,12 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; -use Atproto\Contracts\Resources\ResponseContract; -use Atproto\Responses\BaseResponse; /** * @method string allowingIncoming() */ -class ChatObject implements ResponseContract, ObjectContract +class ChatObject implements ObjectContract { - use BaseResponse; use BaseObject; public function __construct(array $content) diff --git a/src/Responses/Objects/CollectionAsset.php b/src/Responses/Objects/CollectionObject.php similarity index 77% rename from src/Responses/Objects/CollectionAsset.php rename to src/Responses/Objects/CollectionObject.php index 5b40161..7763c21 100644 --- a/src/Responses/Objects/CollectionAsset.php +++ b/src/Responses/Objects/CollectionObject.php @@ -3,15 +3,17 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; +use Atproto\Support\Arr; use GenericCollection\Interfaces\TypeInterface; -trait CollectionAsset +trait CollectionObject { use BaseObject; public function __construct(array $content) { $this->value = $content; + $this->content = $content; parent::__construct( $this->type(), @@ -21,6 +23,11 @@ public function __construct(array $content) ); } + private function parse($offset) + { + return Arr::get($this->collection, $offset); + } + public function cast(): self { return $this; diff --git a/src/Responses/Objects/CreatorObject.php b/src/Responses/Objects/CreatorObject.php index 17455d4..01429d8 100644 --- a/src/Responses/Objects/CreatorObject.php +++ b/src/Responses/Objects/CreatorObject.php @@ -3,7 +3,6 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; -use Atproto\Contracts\Resources\ResponseContract; /** * @method string did() @@ -22,7 +21,7 @@ * @method ViewerObject viewer() * @method LabelsObject labels() */ -class CreatorObject implements ResponseContract, ObjectContract +class CreatorObject implements ObjectContract { use UserObject; } diff --git a/src/Responses/Objects/FollowerObject.php b/src/Responses/Objects/FollowerObject.php index 0b2723d..f5396e9 100644 --- a/src/Responses/Objects/FollowerObject.php +++ b/src/Responses/Objects/FollowerObject.php @@ -3,7 +3,6 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; -use Atproto\Contracts\Resources\ResponseContract; /** * @method string did() @@ -15,7 +14,7 @@ * @method ViewerObject viewer() * @method LabelsObject labels() */ -class FollowerObject implements ResponseContract, ObjectContract +class FollowerObject implements ObjectContract { use UserObject; } diff --git a/src/Responses/Objects/FollowersObject.php b/src/Responses/Objects/FollowersObject.php index 4032459..47fc8d3 100644 --- a/src/Responses/Objects/FollowersObject.php +++ b/src/Responses/Objects/FollowersObject.php @@ -10,7 +10,7 @@ class FollowersObject extends GenericCollection implements ObjectContract { - use CollectionAsset; + use CollectionObject; /** * @throws InvalidArgumentException diff --git a/src/Responses/Objects/JoinedViaStarterPackObject.php b/src/Responses/Objects/JoinedViaStarterPackObject.php index bb78b3e..05edf7c 100644 --- a/src/Responses/Objects/JoinedViaStarterPackObject.php +++ b/src/Responses/Objects/JoinedViaStarterPackObject.php @@ -3,8 +3,6 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; -use Atproto\Contracts\Resources\ResponseContract; -use Atproto\Responses\BaseResponse; use Atproto\Traits\Castable; use Carbon\Carbon; @@ -18,9 +16,8 @@ * @method LabelsObject labels() * @method Carbon indexedAt() */ -class JoinedViaStarterPackObject implements ResponseContract, ObjectContract +class JoinedViaStarterPackObject implements ObjectContract { - use BaseResponse; use BaseObject; use Castable; diff --git a/src/Responses/Objects/KnownFollowersObject.php b/src/Responses/Objects/KnownFollowersObject.php index dc2a030..a551219 100644 --- a/src/Responses/Objects/KnownFollowersObject.php +++ b/src/Responses/Objects/KnownFollowersObject.php @@ -3,12 +3,10 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; -use Atproto\Responses\BaseResponse; use Atproto\Traits\Castable; class KnownFollowersObject implements ObjectContract { - use BaseResponse; use BaseObject; use Castable; diff --git a/src/Responses/Objects/LabelObject.php b/src/Responses/Objects/LabelObject.php index cc82696..115c91e 100644 --- a/src/Responses/Objects/LabelObject.php +++ b/src/Responses/Objects/LabelObject.php @@ -3,8 +3,6 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; -use Atproto\Contracts\Resources\ResponseContract; -use Atproto\Responses\BaseResponse; use Atproto\Traits\Castable; /** @@ -17,9 +15,8 @@ * @method DatetimeObject cts * @method DatetimeObject exp */ -class LabelObject implements ResponseContract, ObjectContract +class LabelObject implements ObjectContract { - use BaseResponse; use BaseObject; use Castable; diff --git a/src/Responses/Objects/LabelsObject.php b/src/Responses/Objects/LabelsObject.php index cc27747..1f390d5 100644 --- a/src/Responses/Objects/LabelsObject.php +++ b/src/Responses/Objects/LabelsObject.php @@ -10,7 +10,7 @@ class LabelsObject extends GenericCollection implements ObjectContract { - use CollectionAsset; + use CollectionObject; /** * @throws InvalidArgumentException diff --git a/src/Responses/Objects/MutedByListObject.php b/src/Responses/Objects/MutedByListObject.php index 40e1bc5..9285cb2 100644 --- a/src/Responses/Objects/MutedByListObject.php +++ b/src/Responses/Objects/MutedByListObject.php @@ -3,7 +3,6 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; -use Atproto\Contracts\Resources\ResponseContract; use Carbon\Carbon; /** @@ -17,7 +16,7 @@ * @method ViewerObject viewer() * @method Carbon indexedAt() */ -class MutedByListObject implements ResponseContract, ObjectContract +class MutedByListObject implements ObjectContract { - use ByListAsset; + use ByListObject; } diff --git a/src/Responses/Objects/ProfileObject.php b/src/Responses/Objects/ProfileObject.php index cdd39d1..f31d10d 100644 --- a/src/Responses/Objects/ProfileObject.php +++ b/src/Responses/Objects/ProfileObject.php @@ -3,9 +3,8 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; -use Atproto\Contracts\Resources\ResponseContract; -class ProfileObject implements ResponseContract, ObjectContract +class ProfileObject implements ObjectContract { use UserObject; } diff --git a/src/Responses/Objects/ProfilesObject.php b/src/Responses/Objects/ProfilesObject.php index 1b5908f..00d5f8e 100644 --- a/src/Responses/Objects/ProfilesObject.php +++ b/src/Responses/Objects/ProfilesObject.php @@ -10,7 +10,7 @@ class ProfilesObject extends GenericCollection implements ObjectContract { - use CollectionAsset; + use CollectionObject; /** * @throws InvalidArgumentException diff --git a/src/Responses/Objects/SubjectObject.php b/src/Responses/Objects/SubjectObject.php index 6b4c9ae..8546c0b 100644 --- a/src/Responses/Objects/SubjectObject.php +++ b/src/Responses/Objects/SubjectObject.php @@ -3,9 +3,8 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; -use Atproto\Contracts\Resources\ResponseContract; -class SubjectObject implements ResponseContract, ObjectContract +class SubjectObject implements ObjectContract { use UserObject; } diff --git a/src/Responses/Objects/UserObject.php b/src/Responses/Objects/UserObject.php index a22b7d2..faf400c 100644 --- a/src/Responses/Objects/UserObject.php +++ b/src/Responses/Objects/UserObject.php @@ -2,7 +2,6 @@ namespace Atproto\Responses\Objects; -use Atproto\Responses\BaseResponse; use Atproto\Traits\Castable; /** @@ -24,7 +23,6 @@ */ trait UserObject { - use BaseResponse; use BaseObject; use Castable; diff --git a/src/Responses/Objects/ViewerObject.php b/src/Responses/Objects/ViewerObject.php index 646fdf5..6cfa461 100644 --- a/src/Responses/Objects/ViewerObject.php +++ b/src/Responses/Objects/ViewerObject.php @@ -3,8 +3,6 @@ namespace Atproto\Responses\Objects; use Atproto\Contracts\Resources\ObjectContract; -use Atproto\Contracts\Resources\ResponseContract; -use Atproto\Responses\BaseResponse; use Atproto\Traits\Castable; /** @@ -18,9 +16,8 @@ * @method KnownFollowersObject knownFollowers * @method LabelsObject labels */ -class ViewerObject implements ResponseContract, ObjectContract +class ViewerObject implements ObjectContract { - use BaseResponse; use BaseObject; use Castable; diff --git a/tests/Supports/NonPrimitiveAssetTest.php b/tests/Supports/NonPrimitiveAssetTest.php index feb2ea6..adb4931 100644 --- a/tests/Supports/NonPrimitiveAssetTest.php +++ b/tests/Supports/NonPrimitiveAssetTest.php @@ -21,6 +21,10 @@ trait NonPrimitiveAssetTest */ public function testNonPrimitiveAssets(string $name, string $expectedAsset, $value): void { + if ($name === 'labels') { + ; + } + $data = [$name => $value]; $actualAsset = $this->resource($data)->$name(); diff --git a/tests/Unit/Responses/Assets/CollectionObjectTest.php b/tests/Unit/Responses/Assets/CollectionObjectTest.php index daa66e0..71bced5 100644 --- a/tests/Unit/Responses/Assets/CollectionObjectTest.php +++ b/tests/Unit/Responses/Assets/CollectionObjectTest.php @@ -3,7 +3,8 @@ namespace Tests\Unit\Responses\Assets; use Atproto\Contracts\Resources\ObjectContract; -use Atproto\Responses\Objects\CollectionAsset; +use Atproto\Responses\Objects\BaseObject; +use Atproto\Responses\Objects\CollectionObject; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; use GenericCollection\Interfaces\TypeInterface; @@ -79,7 +80,7 @@ protected function resource(array $data): TestCollectionObject class TestCollectionObject extends GenericCollection implements ObjectContract { - use CollectionAsset; + use CollectionObject; public function __construct(array $content) { @@ -99,6 +100,8 @@ public function type(): TypeInterface class ExampleObject implements ObjectContract { + use BaseObject; + protected $value; public function __construct($value) From e77c0e2f7a759f4dc67b5bab7bffa96cca0196ef Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 9 Nov 2024 02:42:58 +0400 Subject: [PATCH 55/59] test: add integration test for GetProfiles endpoint --- src/Collections/ActorCollection.php | 14 ++++++ .../App/Bsky/Actor/GetProfilesTest.php | 49 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/Collections/ActorCollection.php create mode 100644 tests/Feature/Lexicons/App/Bsky/Actor/GetProfilesTest.php diff --git a/src/Collections/ActorCollection.php b/src/Collections/ActorCollection.php new file mode 100644 index 0000000..8122595 --- /dev/null +++ b/src/Collections/ActorCollection.php @@ -0,0 +1,14 @@ +authenticate( + getenv('BLUESKY_IDENTIFIER'), + getenv('BLUESKY_PASSWORD'), + ); + } + + /** + * @throws InvalidArgumentException + */ + public function testGetProfiles(): void + { + $request = static::$client->app() + ->bsky() + ->actor() + ->getProfiles() + ->forge() + ->actors(new ActorCollection([ + static::$client->authenticated()->did() + ])) + ->build(); + + $response = $request->send(); + + $this->assertInstanceOf(GetProfilesResponse::class, $response); + $this->assertInstanceOf(ProfilesObject::class, $response->profiles()); + $this->assertInstanceOf(ProfileObject::class, $response->profiles()->get(0)); + } +} From 4af03c7d95734f7ba7f65c71bc4b00bbd4068561 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 9 Nov 2024 02:54:31 +0400 Subject: [PATCH 56/59] refactor: simplify collection type validation - Remove unnecessary TypeInterface classes for collection validation - Replace *AssetType classes with closure type validation - Update CollectionObject trait to use Closure instead of TypeInterface - Simplify type validation in collection objects using instanceof checks - Remove redundant test for type method --- .../Types/NonPrimitive/FollowerAssetType.php | 14 -------------- .../Types/NonPrimitive/LabelAssetType.php | 14 -------------- .../Types/NonPrimitive/ProfileAssetType.php | 14 -------------- src/Responses/Objects/CollectionObject.php | 4 ++-- src/Responses/Objects/FollowersObject.php | 7 +++---- src/Responses/Objects/LabelsObject.php | 7 +++---- src/Responses/Objects/ProfilesObject.php | 7 +++---- .../Unit/Responses/Assets/CollectionObjectTest.php | 13 +++---------- 8 files changed, 14 insertions(+), 66 deletions(-) delete mode 100644 src/Collections/Types/NonPrimitive/FollowerAssetType.php delete mode 100644 src/Collections/Types/NonPrimitive/LabelAssetType.php delete mode 100644 src/Collections/Types/NonPrimitive/ProfileAssetType.php diff --git a/src/Collections/Types/NonPrimitive/FollowerAssetType.php b/src/Collections/Types/NonPrimitive/FollowerAssetType.php deleted file mode 100644 index fff2403..0000000 --- a/src/Collections/Types/NonPrimitive/FollowerAssetType.php +++ /dev/null @@ -1,14 +0,0 @@ - $value instanceof FollowerObject; } } diff --git a/src/Responses/Objects/LabelsObject.php b/src/Responses/Objects/LabelsObject.php index 1f390d5..fcedb57 100644 --- a/src/Responses/Objects/LabelsObject.php +++ b/src/Responses/Objects/LabelsObject.php @@ -2,11 +2,10 @@ namespace Atproto\Responses\Objects; -use Atproto\Collections\Types\NonPrimitive\LabelAssetType; use Atproto\Contracts\Resources\ObjectContract; +use Closure; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; -use GenericCollection\Interfaces\TypeInterface; class LabelsObject extends GenericCollection implements ObjectContract { @@ -20,8 +19,8 @@ protected function item($data): ObjectContract return new LabelObject($data); } - protected function type(): TypeInterface + protected function type(): Closure { - return new LabelAssetType(); + return fn ($value): bool => $value instanceof LabelObject; } } diff --git a/src/Responses/Objects/ProfilesObject.php b/src/Responses/Objects/ProfilesObject.php index 00d5f8e..0aa1f72 100644 --- a/src/Responses/Objects/ProfilesObject.php +++ b/src/Responses/Objects/ProfilesObject.php @@ -2,11 +2,10 @@ namespace Atproto\Responses\Objects; -use Atproto\Collections\Types\NonPrimitive\ProfileAssetType; use Atproto\Contracts\Resources\ObjectContract; +use Closure; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; -use GenericCollection\Interfaces\TypeInterface; class ProfilesObject extends GenericCollection implements ObjectContract { @@ -20,8 +19,8 @@ protected function item($data): ObjectContract return new ProfileObject($data); } - protected function type(): TypeInterface + protected function type(): Closure { - return new ProfileAssetType(); + return fn ($value): bool => $value instanceof ProfileObject; } } diff --git a/tests/Unit/Responses/Assets/CollectionObjectTest.php b/tests/Unit/Responses/Assets/CollectionObjectTest.php index 71bced5..1d9ec91 100644 --- a/tests/Unit/Responses/Assets/CollectionObjectTest.php +++ b/tests/Unit/Responses/Assets/CollectionObjectTest.php @@ -5,6 +5,7 @@ use Atproto\Contracts\Resources\ObjectContract; use Atproto\Responses\Objects\BaseObject; use Atproto\Responses\Objects\CollectionObject; +use Closure; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; use GenericCollection\Interfaces\TypeInterface; @@ -61,14 +62,6 @@ public function testGetMethod() } } - - public function testTypeMethod() - { - $type = $this->collectionAsset->type(); - - $this->assertInstanceOf(ExampleAssetType::class, $type); - } - /** * @throws InvalidArgumentException */ @@ -92,9 +85,9 @@ public function item($data): ObjectContract return new ExampleObject($data); } - public function type(): TypeInterface + public function type(): Closure { - return new ExampleAssetType(); + return fn ($value): bool => $value instanceof ExampleObject; } } From 5dcaa1d3a8bf73d269be62726f294e9b6b6ea510 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 9 Nov 2024 02:58:08 +0400 Subject: [PATCH 57/59] refactor: switch test asset from PNG to WebP format - Remove unused file.png from art directory --- art/file.png | Bin 119922 -> 0 bytes .../Com/Atproto/Repo/CreateRecordTest.php | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 art/file.png diff --git a/art/file.png b/art/file.png deleted file mode 100644 index 80c12813d1b687f2b6ab0f286d4c2c32e8d59456..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 119922 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�@*E@yNvc5z z#j5f(M79%Ye)U+@`1(yaKPTs-^?=^)rUze@`?w2zTru<_UaE>|tRUdWKzZvkV1Xh` z4*1;N-5b2mhGH0G61jOVIl~kcXWTKFk55lh4C6T&Z{J2egWI-SCv6Q^YyiWnLjmg0-K6&+;T zVCoB!@sEVJ+^Fz|m*OX?m!WySwtlBJww1$fSFmRq$0fnJ4;j@pf;f$}m<$ibQB|@Z zq+N2&Pdtddd7im-FWO0e>{@hw`g74fFy^AP`jIh~OewDVLE-FIZZhQyTF5|<^>ni6 zve&|%C@tdXOu*^jre;r*-3y!p_s4;<_!rx&OSEkF3s8NLPL9Z~x;=;H^g?qwMT*_lWq8WVVKaVrQ7hkS zEtFe*8mnSJJh!W`beKDU%AB~_{sJh-qN`AyMbCMvn;p`#~o z!fnC?>Rh5Lw>7qO!#t$_3VRvh+onGK>54j#gp)F3t=Bqty~c*>J9c!UvxRd56=t~%dYTO8A+9&=PSmZ zmKV7MbI+A3Hk6>3-+z!c^so0e=L(hV2EyrtkMCAf4KP7^(& zS<}nJI8wpuCguJ{LUUy3&BU}EVS#$~aivs%lAz)J=oQT6}5^+{e>r{p?K!zK7R)CDm&-*xsJJZbmBW6MHzYo(-xzZ)0agKbSOm zM<8v}`epepNQtf-7LLucsHi$9SyJe z2K>YJGZOT{a1&cabqKcZXG~$^7Pp~bZD12@@N}CA2mmmNcHMqye*>Aiu8>d-VBGCXV>Hu6-^!*VuXAhgY{i|)Yltu3hdB?mE$;CLOkR7S*(jTrX_uDaF_C(>J{ElzLO5|& zdD8^JAmYiN9dO8+Dam45d_mBY8O&QZ8z4P}ceh2tsMrE`MG*1uw9P>A8r#_n$({Pr ziNz1PcdpIV2o5j9E}MW__6!$tf9Y6$3~95`h2bKVf*+#Q;&E1%i@r?K^H9Cc zky|A`)Nd!*c^^Y0tl1%gQ`Kb;zO>xrY2`>3W!mW}?=$i=EA7?6;gL&cJ~Zvp@Z6rS zd5sHX+or>oYskSCCZvxqBfTJGZ_3z0AkBD6^7>_VSo*N_5pQrdbyA`s`<1VZl~0_m}EVEohT> zvQ`9!a>eBM@*hV>)`M2)%{^l8vzX*jM(J-%O2>@uH#Kr>$IuhpIwrQ2b`Ki`W9vRO zg=K`*&7*L_x@{+munmdu|A>8rLH1p&CaQCRM@D0lVL%j7LneE9!BNV9lFp(s zAaK5DK6CvpAR5>jodQSq4hJM0s~f*wVVK$X?GFB()mreDVs?5sB-V7guyJixARL@3 zQ+Ddg$;SJ^xX{ZJ!fBjQ*Sh(;#UnbKqP9roQh4tyByeVToj2JA=qSqhyry-stjU+s z0?*{rOJwsYL9@)whzc0App?_;Zuyg|%NT#w+x9*6QIXLcK`AGQCB+0jyVyJSHZ`6L zXc0uuRqh;5ocbWP$@y1mod%7ypCffx;1+^5CUDyj-dNVI1S#=wX65Sz3hrsZR;OtQYp`Z%e$sMkBqu6X{)G_he;o zYzN}f zKLI3tj_zWS9e!iuSFK=n@Tm;DGPRWv$9cKo5uh1{UhkEw5?s9!s+KMyc!oQ#lkGp0 z-uOBnuzSHo3FLZ4bPwEsVV%%(ia*<^|Y_puTUpYtlAZX~X+Ufr`A zmZEyA%G(sh7JPc!RYusqXFcBlJj@wD0N8Ix0hrtL`sz!4ov!u5*p(BT?ALQ|XKSzP5%W4#`-6Cg z8{-M@-xHIQlY{=KDFDPY?p%8a^y321;j#byx{$*}OlFgJ<=SeuM*fy7=HOHSU&!maVn^U+E`amJTnlGH-GzK6#}wbL2t6`S;I(%g#z(963En zCQY|;Tv42p1=*7tHhJCS?bFdwFJ7UWCM6edGzV*N!Z0NIC%1{KbaOdt?&kI`FFU`X z-@3O_@$){mg@TR+z@H!ZBdq{r&>b0wpr8BD#BCAx%RJYkbkP0m-KRcODIra33vO|jOxuk{_t4uwutodG^La(51tGMxFCvT$?2OGYJ?e*W=F{Xr(y!c@Q zeMKr8%a}kJbzn6ia=tX!^x^5mi)NS8uqs~h`y}Yylpv6DSsqp@iuuh z?TuCIUF>Sa?zwnu$GD66%DG~Ho3hpn*=D+jw7r^rY24Ay#L4u*)0J8^=X2($R}cpj zTI?$s5p}%P=H7?-4YRj33bB8Z^O9P{`@On5je+o@$lDI&TJ&Lhe73`p&U=$0Qo^F? zw{9jgFwynXS2qBBH~*S7sTdj|P<_Qj$5Pf4V&&nc@CTk6vn8G>;?QM5kiz0Apu zN3Z7UI<$3MkhfmT>w@aa`16d6WAWu>u?Ekw(pPnCWc^JgY?za-w$WBs``XRRY-htF zw3K@yiv8qatBe^Qj`!l@RFw7R^ErHH&ed;LPu#3@hP*9X=XeJX8-j?|aPU0K@9hk> zMDkk&7)^g|6x+|0HK>!+$1I|<9QbQgnfYRzo@E%#Cw%jrIq|xQcGG z?_d1n{y@Uia<*TXLR{B>V3n?|r{O0Png=g!YUUofdMSypc`{eTV+~99l9#kJ5ynpi zyjB&B&m4cBE(4ipE2H2|i3V2UZI1~OI(mAyjiP*?Q`va;g%9Wb&1gI#X>lqg!D!<= zJ-rSckZRfzXESrMy1)Cix6>)%^9}LE!=E5yw`HH3Jcg8FNuLTc{m6@hiLk=Ws^b4K;FwdVs(RHWh#gX=S% zowc5ulkfIKXOx0WWp-NdjQ-e(^G%9~C?H?^_=SNcJLr_731S1D+9 zlTg75ya+!|z91UBuAF%0HZ7!nfgMkD-NizJ;Mv7xE$=$+6_UR`vbWhVN*SQPxDR=* zJHblX`4kLJ`&#fh?Q=)fGB*G&jb5erOCXcNISlBMM@R9iC&a|4i8xKipm%y#4`^v= zwK03YQ#e#ps%=J0rC4bI?~7Vi_G`W0K?K_^MYToY(AFzGt;l~ z6tAp&LdmKl5W5mZUkv1_abB3X? zL^ZnurVgYf;uM?}kO}al_tc|DPWh2}RAz7UZXmWgxUgUxUUKvFdvI62t;v+|Ezc|VYP4-+Bli7u);D2J3-%N2 zK<7gjww|(^KAz<=2JZQC@}X#2US!u zmQFWfYfe%b@`o&vkb|uHvA7PAGh%OEs4L$5n5~N~*lG_p{6kk--Cimy zYH(m@2;<+cOFOPqD0IszUbAHP=o4yTZA_-112G_YR3``0kdjKi-aSUeO`hRle1cDf z6}-*37j3#_*=fPiMi$ZitJs8cHfJa#Hf(-1-f1z{D=N;Sy(4yZ_aJuz-JWU5KEYGw zd^s*`4VDQf>8*_gz7hZurPR3Fp8t8L`pc2>l>IV83L|weLC=v|o5@{JCSFz8GYSY6L47HT`z;#!LvGxs27|PScAVXV3IXd$kje$Ct6B z^%p(JUB68Kz~%nW*v{JZ%Ox%L=NcMsrmE=Y`Kjktvu{;9PLW$$S>Br!y)cPqRo?H* z*L>nIF|*q+JUn|zup2oq4|Kx&cI5-!4L*>$_BtEeh%7Hdw6y`y>fDqBlYR*|gZjhA zJ^#l+)*r7Y6qA+B=7XPxdkjk?{P9Pk`5ND*3fc%d95V-*RF!mWsf~4Dj>6y~gP%c) zTKBk#;$*k=og4QgPLJ2Y57fqL_Ny@)hh`?iw>1MRA4db$iFFo$`hT-)_7+p?O2_(nS$p7#tp(HFNjg zmlEoM6!nYGmpL3T4zD%sNu{?lY&!+E=QaQxw~ItoP|5z_lzE?LYYpR@#>s9zB>-IR zTuxZ2d$7HuV9jJ7bSw%=OY3JG!e>>ctwq-5EkzGRJ3XZAVK7sENb+ys^sxr?Zd^hC z?FFc>?ym%Q^%>B-&6?(Lhoq58SF*Hk%JRF55a#z!HEusDBes;xh{2YxFYyJ(0WffF z%$t9EBGj#r#bg~e-e?+iBTIMkr0RFWxl#S~nWnUmahK~eEzHQ6jI-PX**Jq{ z?6h7=oNsYEZq+dwkBoIq5%_(7@2jgZEN)1PP=iMxc$lEnfhsYoN%|pg}2LXaYaas>NhLx zTg5QELEQk2f`)1-m$%p%3FrNt$-(X{!h}KQ&@|}!o;{S7Frq63T2NXHo$T3ee5c~r zFMvD7R=o@KGDyCirOUt-42R5ADhmdkjdxcUC@17k*q;JsCYFkeU9l zTQSxXn+=0mg4sZahxVt9n8F8J#RFApXBSU#m{C%82jt~XWpOsj7cX8|E}$-N?HStd z?OfeL>YY`K0eU$*c)oocAZ~#oHh0~2(ztqLWTo$+|Yp_dI!qaD ziaNXVTuv?^GXsCV9OAb6IWe*C@OCx8NE0uRtl!am<1J`=3D*CZp|&OV%pfAQ`bG3( zBUs46uz0jpymVS|S#!*vm+XapFO?{|rzdt<&rN9&5ZJyzKjTlYOrWLD3TQ>0Y7$M? z)?oc1ZWCWLR6!H92O9h$9!=4c**mx3Wo*KGi!)=Crc>y|c>+jLin{NbtZGE))z6k} zM>Vsw0^h;lzVx~+e}zU)i|!8>;*aiLyUOoy-0a#!tGT8Od_nW(Wnbp_G7ePap3jua6C-sU_T+bdje#9C42^XBs6g)DI>f6I$RBC+j_wdr;2tL}d0mnu)W!M5?3%Mh zJXVQ~*tt1GL-}~o+`-bRZkxsqbMdkqsSs~)K|xLPHhX3|&!<_9<n#zdoKfIY}~9p*PP${W?5_R9RathDE&ep9e=Jx`Gs*URrXON_s+Af z!q;E!|E9-AD;hYyy57FLLC4IlvTAS6dRN%5)-Emzdb3t)La6JCOLQOqMrzjRE2>-D z=GVu;E8R80bQ?+RkR8-GOC4RJ#GS{{82D((YnlFEroj9;+Tk}*l<1Uw6bQK|*-W>< zQ>EY6#H3eG;vAgMs&k5yz`YmHuhVfwxf_di zkdahU?W)^p&U@Bg;6W@MCNVUtXPzphpot(PHw*(UEfy<)AR(2yoNgZ#*Y_%%#k2z?ZgmXoIUYQE%t+2JN+Qob4&m$^eFY(inMP^20JJ*IC z1DII{1tnQK)Hr&Z`4?|5Ssus^qd!NtuU55;b3dgOy|fCj?k`c7VEQ;VfJ!|{J9b{_ zO+()r4Qvp9VorYSO{ls!v0?G3e%Y$W2W@wKsd~q_*O*k&nj1d?{TwNP=uB(OQxYL- zTuKNLj15|fw4OhIE!D0mS;=;J=J5fa@gjz^S@CRq%K?qGR6!k%YJB6Qi}@#YW)#Kpd z!6*R?=O^3}0xeNPXTmP)N$dHSh|!C@S!V^cv>(ElpMK$d9k|zK#F**d z5wA$~VZMH)Mkapd7CzKG7MP=~gT_xIcV!`;^woLqW*!-p(9}L6k%|wxK9ZH%8BKUO zIf(-et!H`5f$*!8tXUAgK%FVY=l1RbB9}YR_$-P60ta9*!zzU*T9lC7nIY%Satv&_ z#~H5cW=QEgAwyNkh}@(-U*=$=x9mSVpIhOBYO}-^nGGqy7{WOa?)okcr-CRb4mP}$T-=!1{E!BZ8gS}I~U21BQeG!CL6L2xn2KJ`UYmhwk@!rJffY8m+t1U0%wS?x) z$kY&n)ivR!wpJfjBRJKrveiq{wqFLEa^mt~i0m+|c>Riuwo8AiPSOeRyw+_k(4D)W z(?`rXGPgoz&R;L{u612JD?l1(IFH6ZFcSn?{maFwmI2ey@gE;5~`f2@Bx7B15(V6fzUm;df zTBjiDIs&uYYtB$V$n8`nKQ!VyfLh*7ia`$*aRPBU6nQ9X)?}PJcBq4pV)=C)Pv9;i z*mLUW;|7_hZ)`kmN1NEr6SQ&&v5=MdH9t|+Ua!h*3a6YH8{Ef71SqzQUulQUe+e@VyMN zu`~-YxhZxU(zjFzk~<~#W*nk#U$t_^U?54Vi1hX1vEjZ%66B1 zoMdAie~M&F*y@$yux>(L-b*eZ&dki2hyVnND zq^`8G&+Vc^_2elR^hpWg0^`F}stH6^!aH?V;FNiwHwrx6V>Xs3QmM7l*%H>ct^_$T zf%27!nzX4y*DPM;mZ2@=2TsbFzs&x)GkSbTapFx?EC_8s1wE`)W|4nGzk&R9N<>vV zsOI82SeI>fpc0?GrjG#Uo@X)>3Rl9ySA`LG`6*hKX0=TY6#<;fmY6PAp+c_p%0G;R z4_wj`MEBS~dT?lPZ36)q^qvG^GF{n?wRgXt&hz)8@H@ADJ%T8Kt{-14w)gRIjz04S zaup=8D!fx6l5a5bP+l`5w~hTRpp?GEc$>rMDV!%xk*<)Q1-^#0v)B5vGuPJVy44+N zUn}405G!6Z`fqf#yEMR=pP~-%>6H3CSxD6v3Kmdsr8Y{8eg2|4rZdb^7KWSYG}YOd zwT{T5aRb#von0N#y=nQSH{y1U_w~Q48N&HZfh0s1H){vtV*ZwIU!H^By$Y*Jf<4moqjv)Ght8y8>F?5B#bZ$ zAUCi?H3qJOt*mk5kn?^rN79)E&COSmR3OWVl-KVQ#4CCfInPhXP|_*Ty?a*Z*v-{w ze*(omwLsW3UB+vHKsf2Mc~8^8^7p5CUf9B`hT0;`)BxD{FlMCcIJF1-;K5GK*=KLt z9a9T{T~5Jx|92mUyuH0`!^1OxJ6Oxc+mTV5??j1DluaAz6qBJ$Ep?xbwLnc`zA!yy zb8(ZX#XHVzot^jGXsjBoC8}09GP84}w3ed`>IvDQ++P3>>M$2z|Moe}nSIoGEq66! zDzeWmBqW|OJX~5`ZFwMXUuX1Y6FKeCrDM^GOUt0KGOUQg=jxzT?^!grl6Ti4tuj2Q z)(;<=l9on%W;I95uE7krmRYSUT4*h@dcd27RuigrI490-=isTG)$&HFk&MMQQSg9Y zizI9lpQ)xw?aW9#-XmoYG?!f-%gqkcpG8qNawK3g$no~MZaAnQAwe)F{Qb?H(HANl zH?tQP7b6GF!C(}MKvUq*q(OsjX|SuJ+3?&@O671PHLi>6kFR(3$&I+u@9LtBV~G)x z&2ps-95`#qfZ`VGdxtCHc3hbMW}75}tiPJH|J(+=R*P`71!7ilf*s4PN}ClZUZY8N zXKB@HO~<^X(_5qrSjtH)S+!97@jXMjglSBWv`hY&fv7>!8+t&oUk|ie9<|3EZksgI zEneB0A!)ZNw^=q9-ymZio~ivh7b2xnzFCPoET-N^bnl_TcIn;TVC^Bp zFIQwyY}Wg1lD@9YrIgXev|8$^DaN+kCq08J?{&Px=G^OA@5FxXSG(*e^r5;{R&y3T zLk*F9s?z?=^&MWw&=RNh1YZv-|2Fl)Vuepip{%+>-5`&-MW{nc;3m6dF?ktdD?%17o4$M=dnO_Kh2&0~rFT=+L7-0gJ zCY}kMvR?l~vlMALo|o1mEN|nvd6M<$z3hZ-(Kl||kOimq7n>rXW`Gg)C!{ubW&^>` z2ECZPB7(51y^+<*N~vz$@h-0o)P2;RSEt?f`K8ON=$G&72$pz9xYfE=*=#S$yxgnd zW};g7;D4x*46`Mq^Vo!MF5CWjB2_r7>&xDtsD zLa7kSC@$9ul{ihd%=XX4`YwLM3;U3v!FC9jw9fdC6bKuaZ z5p^-s07-qAPizmGdZ;T|!bt(Pj`^7lmmQZU`x!==tq`R;tw*~Y1m12%)B~4Y+9KWW zaaj>HiY;YV@*cH%`yyoa$CJ7-2%F1+5rhJ*YvifM$`-U~ok>RcnH5P&6L_y^A5Oyq zS)wliGY{Fd2yl+CAEDi9c6K%b*Q((VF?`V3%(`htkAmAND!=t@7t$*+G`wu(_U=@? z<_3q1fd_VN1?FKbN^2e&5Al3+pUTGSK#1gq2A_}53`Xl(9K^+s?)-%@_Min4P+?o{ zRI!!*wZI{<3}76K8!ztM*>I@eXdA8ZK%FRQ=RWsupVeYhB%yX`PAjk+$i!J2JDlfO zwWz)OaBCA3d^2%763b4Ujr0 zQ8dQ9{rs?*MZ>aU#$&(HJe3d>G7S+cIaH7abpq;`i!aQtS``{wIy>jUg61i-dqU9s zu5Xpt{tSff=qj~si?AYnd_o4wh3Q#ZuonaZU9LAXrod3>Ni0%n(dV=xTc?%>B2_qR4ahbcZzJ9rz+X?C-8;uEww&3zxok0 zRjNzs&WTFgGY`Aa{^rc1*vwnuyekLHmZ<3ye~ch)fMhlqb*uDc#rl3k@Tt?@?S`on zg_G$u5tSCb4t11>i(BL)|17L1cxKYk87>k)VLjfJWZgJ<_+u62pru z$%L{s3pjMv1ntz>w-&Io)YNRO&ViZvHSy^kSrN}KUnONX@5{B|&fcF^{U@;FuGI5& zFCd3?ZPS(hIqz9B&Qrs*@yeGw)w{vk7usJ|MOT7`06kC42WTg;XI^fm-5r7xG6@N#@OWM?G zP-h-^P7Brrz^z}jDSdH>EXtDg$9@2LuJlr$IPYt@`b;PL5-|MMGrvx-!Af{O z0a|V;+9zv%;d7pN$PIuaq)5| zZL%%}Dk!HV$*W*M(XE;vN9*;XgIyBasms4*o*qNR)W02Xvg!%iuQAuVTRmILiO^DP zEN%S{YwSNQBF`1ROJ1SHBJ38&l_TLVuT&BS$Z7G`{t0$;KZ|%dIV zAuabO@1Kb05eQI}V%vrxlpK>q2MTf@JVV?WPYB12Z+V+30z?hi$-5&bY9J2q#fU{> zUGuUsct8mjuJ1rXTybT8@<5l*=fW*g-AZ0oUxc%I2=qj`N((5I0;dQL+o3~imYYfb z0A&={=3m++c;qvbxmnP~j0JWWUX)}ls77qW!R7V~dacaFm6ny^oY9T@~FbDq=onp7jgW-d{iOFLCRNmqA>vLye&Z3~lNhR=6lM z8YF6Y)>QjOVLNl&Q6=Q#EkAs{p8CXwLJI%kLM{Kz3~t+;mx6s%X1zbuaUQ(J-GSaE zOq{yUJ@^QStrpz!GG&L(*F!kDT8h+xoQS+T((TfDbyqDt2}YWY>VsIeRT^|KUPYOaK9q`I_}Fd+rU zb^O;Md9eDJu8Lnmo7S0-g+Uf&02i>4DzUMum!Y_8YkE&_x@&I=Kv&j*6}bv^IgpYW zbA!lzB0vG|s19+awdW#+H#m~ZL$M&*jxECEuJ)*(-UtyZqG7J@>{7JhmbX*fO&(vn zmfLzZd{yq57A)M|=fdVWmsi~*mq{V?Z|Kone8ie~y{i$Y-h;PrZJy-EW?AvWceJc= zU|aDANHDn!rv6!H!7X`y7ZBq)EGsRoZwK4h;~Yi3oT=+qIx$jb1mXr_^gG-~8~i!B z?I9%QS!ce48-|-l5a{6MN2YD|mN7^-BDE}RN+FKychiaphv zX_=aEvyW#N&a~alaTymL7J!$DqLqLBB7H=V)Uz3q_shK_4?3re(|EU}xEg%{%`291 zAnp>aKnZ4xEi~<4>_QI4>TXzwYX_ggy1o8qArqkbL9cq;dO zyHD>4Bn*F=tbYc5KSrT^eqCR@MJRjU&lfHQeQvq>b@$i`RW8X3Cy_vUM4;db5Aj)V zO^}C2?;TlyFB0&|48w@K%}|Dfz8;3lfT@gfMse ze&s`F1a*Q0f#=74AK+D-s@teDpQ6 zyW;YQ0*wY20wvV1E+k^QJ~DYicgPqe>HdQ7=_Z?cwhh*Jn6vu_Dmvnw&hRAkSU#_9 zB#>WP*_ zypu^8!fy5=tb-q8?HAN*(qTYjGq$B4x&8NJeOgg(iyhB8U#i|CoiO}K$?3%(aW!@jH2xG=M{@ZJeEX^&_^wRsa0;0?W?;#YbL_^P*Zp)6o$-z~?G~Z?FIk@oJUPrW zZAf6x5iBB&$hxi07XC4>^J-_jd7SEe@6Aw)8AO3cwe`u!xv2C9 zFy+U@t{z&{zx4lmmY6aRLv*ghcgpwOvERt{ZMhpN^PTdvEPG>+OW}Oy_=4HCu14Oc z@3{SLfVDES7JPmDv{BH_6UtMUNMgD@f(}tmL zPMu=ueQMB7og0gQqVaV@!^C*}vI&dPeaC@gif>OY;iOh%-vPV^;(*p5vnOlG*mr~6 z;88}&TrEcxs`EqXcTL-kMQ>}DY)tv7&33uTm^+M7Li-cU9qP?ev5SyVMvgJL!FTo< z;3e%?cCQz1SCJo5v5--Otv6FGBBO|;Z9WLTLlf`V(&WouX8wFfz_w=Z1^W&m>Y>)%vOdQX2BmN}Io81snEZ{>#7A8>Wpi{)YS2`J6s63Z z;Fv62k-9&g5T;TZ(~F;_M%q*b!wP$d8t!G?jgP zns>7GflvBhQ%Ir$NMCu7k00dJ1^C z^ux=?MjJx&T(}HWEpZpx0zc8b1Ma6*j_Dt32KG}e+Moj;M>TD6gA#W3%ljWn!t8J9 z+fvdqwuVb|vfujFawhQc=w9GCXYNp59j<)9k77>F6a^@!UAS(kRQy~0NWHEzUQ{8o(G$LFA6g6F>3x@>9y<{PMDP3zeG;%-*Fq*^Ow|>H(I2AYGBk9 zaAT7+KhiXV;|KFV9a5t_MK_*z+w1PuoPAXJ2UwK-isxI1Bv%K{2&^?JMIAy*1}4ys^LLNhH| z1R1`__N;c@!eL}Zmz@1b{r4pn9-iBUPx$Y)y}o-zF}Em8yE4;;^Z`&BvDyz>?FaX< z)s-_S)rMS%kvn)~dmOJM9fAp15(~V9U^=DT7yo^skB8@+-4(u$}qmfR$k zB;_w2zyK~4g>}QTZ{fLymf8|_^wLg9h5|T!$~(z*dnAjq+apw-)Tdw#-k*{Uo&Syr8e7Y) zhiYj1FLzO>0GPAdx=Nlp(Ks$Q{1?$A6eQwH-RThqmP1XC&pP#Xq{E=U6UJGlYEQTL z<4hYj&#m*{#!{-_otz5tIMq;Y-K*s@*yy$ka6NVB`UNn*1+^@#E3}fp*zK&E}+27ko_YNT^IEESUcj?;{1A=DJzoTu1#3EmKj8% zbNq3(jsTUms1r9{8`{9C*R{UT4OFdgU0xjbo1lF5SWUE5rI0B}eMLEolKMS37;;J` z3#Vsmr?=UNKUFD5GIdchK|lqhXmoGjE3Dyh=zYEv(6y3}W})z1ExIM{i|lh11mmS$ z!mwA%&q&c&cKpVtxt7^2+h$wBWgCfM8sxR@R5Chc_}}7LNpdRWFBKS%C1S_vBkz)v zhl`RtE~zaY|G|mCzh{rTZ6~(2rC;OI7U^fspriVId)rx9AVH~9#Th&*Lg2;%;8!kN zy)UE(nJmQy&q?=q(a*&bIp=j*rhSk@!`rFiZ3)V^-}3ycv}$jE_3Yhvb3iuzS^+Pn z%^T86wQSQpIhUrCSz%!3^m|Wr@Z4FHTagS22rc1IS zykY-iubxfa=c&$-zTF`&d6n8L$xPHU>8>IoviZ`zsZL97Mj>6Vc{O>qHX{2y4fZ=S zN_t*P;AhToE}8rv;Vy5S`re4nPEVx>@Tm}|MGYkop}MsDhbG3((2j{$G6+hZn?YGK zavz5B5iY+MeCtpBKK|d!9yFrMQ|Z)hUM)`!{~G@8;%_a}-*v8lzBBU6PQyoo)n&Gk zwy1^|7nPU7Zl@fpAj<;tIzHETb!0w$ScurC-X>9WrcLsLG@+Yi+8MEJ{XRF+f5bNO zlh6m&D1;YF0&SNkf=NDMg9^Js4U77aQTR}$p@dN)kd$v>G+#8MY)py`p3ibYVACgr zW|Ap0QAIbl6EPl-8h|hUn)%}olJ}IjNMcv^O2?<5ez&Vic)AFNVPp36-kE%0CuQ(J zxO7J8Q-cnn7IqcVPW)SpsNwlKxNo#m6tXL>ok+4ltcVu_g2u5PB)`9!(LPis zg+1Y?x(RUDJtdLIr3ywy}(_$)V?&p#JnUm znZeJ#cI7_#03<#M2`Z(YsKbd@OJP)i(m^}~m~-~PO~;z=82bIt4ck|AUH(vw!oU~URja>O*y+xvyE-J0W(SPSQnsuZ`eA32Nhz*t zX5*^S*JV31m(Rma$!mgyo#K9eHBy)v*j0U4b++x_*)xbgoX4sDdg`4=dk0^Iw*bnn z$~(CwQhGMQ)=}Ev<6+e|!%kM$To8ZEN+snpI(^PqpJeK3uKYciHDXkF>gA0N^oU$d z3PoM>F}mQN1n`Frno4#~$_H{rR`oZ<$v>zz)vYe?Tmx18%OvjP5;>E#q&V#2-QRBav6!3}W7U?blyZydRB^SG8Hp{xtvZGd7DG2L*t;OEWZ!v7W|`WxPo6Lscx~bc zphWC{J**tYWUS(K%V@+F!|8w|5I&e`l9YkXe9lR;-=(FH)0bW+cG`r7@i?u1(R4g( zLfKX1Z1(qBtl^LNkMQ@dFVpGh5Xm(HRy11VV!5gnnPTPie7B@iYBeTsUk|B&2H|`P z_`wZ96%)VZy^4K9gWVpbzx`VhJ&k$3t0SdH)G(>kuEI)NqM_pVhO2;yatSgLk9|na zLNWuRl`B|xpP7Rc!x0aScW;G|MQ?W7c;kc4Ye`)5GAjI^sW|t7z+DBD-3vXF#bTe& zTzb}0Wp~(j4I_qJ9E*C7y2Yr={ecAci#*(;b&&q`$v}!{K-J4aOncUe~KD;V6Q&8TtDenxO{IDoegX7DXYL9ZWzy^F^EZ}bOSZ`D^l_z z8)0+qg&|;x$_j{1zUO-4>taXtg`{6PW4#9S`*3&&s5~J$zTRD{fRLSul&=ivntukn zy-1IbQeMd!1@Wd-lH#lqwo9SA_oxE1kFDsP&5iOq4Ii(MD~|qlXb`yEqNKZ^%GULk zCEu(0p@!$c6S!&2_<&H-patYAY7?U)(;aGv)=&l6I}s8vGMvdl(qWeSj8+|-Pu3YH zTy$i+AmZUw&i}Vi+jpk;-$HG6?7y`z-`?HTA*C$+vAX=(fv@#ZC<5wh$+Q(Cyb$m^ zt#~|pq;}(0MmySxaJI*jFQ9I)4%UpHY*%vr!fO*BUT^jP!YAjY4A^>yReh5C zPUmu-*Rh~+*aEc z5-?gR(B(ns*O>*F=huxhgdmYH!0Q3s#csUYDeHK;hk=1KZ%y`AGof0C;6-oLL6Wjs z5CNsDITKsDD>r8T?2kv%HU7P8Ho*|;OWoKi`t~_&6@4Qvu~u8My_W&{CxC3yjw?;= zP3vognuLx^ot#ID%L9aKK0^r8c5k0SS1G9$?pzJWp#B@>-WbUS+{&gVqU}V<;@IVSTq-{TQ?y0kKo2mn0;f~KChk1mMT6#@x1>O_(dG8gpd^cbDVo=bC=&dACC%p8I zzCc4;`@PICR*Nd}xTxo%D#Z@yu-4BOZb660nPWs14JB{E`h$8C+!zJ2H>G@E6a>_u zhxPkj82ZOsx0Na2S6#)Ak~z%1Np2-~?B^y9q=Ve8aUjvrwZ@NIm_FA=7|Z>xRN@b% z!V1^7{CAf2Q>mv_od2^YSr7jwYxlpEh9BPV+2z04Y}d?fa$~wUp0#S@nz|aIn^XaJ zd5Y`+_+9w>ux@{%TdcM6N1-V9qfo?iFU&~a&jPj_jKo*BN;pcIWj-0sb5Dw!nFk@tt3v{V8jhgaMGCY?b+Wl^51=0o5G650pv(>X#kx z2S~r-&7D{FNXI9h5RY;z(_B0plMSR0kV8P{(GT#&*p21J_C(Nr;_`Qcd2xQi>UOyd z)*UjQ`y&djZyx)Pb&M7+^f@cq&61<>TEw~t5?I>UPzltHuOy^5Q)E$|nEi zlkBcRc1;~}8+=E=4gVJK=2mVsZ%=dU$6cNOx7PQkI{q(M&72}_#GaAT9S;w;M;?xO zdB*u0y6)!spfA$f6c4WVh}`&Dn8)(Mk@3LrjOE6tFvEI^S!o~2nxUtb3x-ye zNm@dIoa^W0JH(?x?>ng}fz@;DlGf%4%Q&MJ&83GXB`jB7HmB?3ph$ubL#PyJ1b)oX zRBU13Og94~UYsCO)$vxt~^e*ll@(DqTVC4=dzDE-a=iqfsm_^ihzL{e59ndYRFqD18O6%ds3Xq zEB6Q*v1!mG+qb$HDJMv88gEb(D>RxHS*TL*YvyvB{Wr-}j7@Rrmoi5Qo#IHaFA*Yr zCX83dC3<{;$3RABR92ahGpJaFTFhxK1P0Pjgsfw?%N@?YOckO-sCUlf4W4y zgQvDJYA@QS1~Y>cfXVw2xSkR{mhuy;KIhP%RG+~<*e0gansy~@3}7;qtWPa48S5e8 z8WPZ{@?%iiv#5!NpSv7Bcmv^Zisi!DNcv5-m+9H-NN#5J>y2XnndoNlSb8UY0@%X! z!3PS0)u39UcUajRhzxnXJKTHsJ6e}$rXOK~QfHkx-@jCP1i0nLP8UUc2f{I%73R~z zlgPXHC*J-BI3w#-dRa}?vUc~NL`Xye0D%<5#Qh2mfn&6G246AzuxLUVqKyR9Z!oVem9P$99 zX_AW({GK_LF4s<>^x4?Q3T+INYWzG~fz##nKy&lHHlW;DW+>XIs`;oaN}BqslMHko zcb@&aWZn+gJ7=?ZPw?wFR;etb^!OoMPpqUpI@A&Kg2j@aAx;;GcpBP&9bk^;zFH0| zwD6hGttLOQfawI2uW}YMPO3uQ29sYNQ?1pfH|<)i3wAJeG@Wz` zN|Fzc;ni>f=2XRcpy_w>e6s$Uwe5nHk-qWa&{$fHYW%3R@M6s}cV$L5@hL`N9L|xg>QsqM7baU;XPM=MJr~dp-OM)I!9u-PX?bE0<>5p*P|+r z%M+*n)_a85Z*O(S&G|Yp8LoMD=_;`KoUvxA$RtgeJ(D|Rhhu{^8_v7!Y?tUvqkyeT znSCFvPNc4Bqohp4mT`c&RNBT+a+_b)sqBG~%d$PpVtGPdnTW{ZoW3BV#k7DW0lgmD zelal&#f!VuAhed>02eFKdeURO>#W)P;l|Jo-bzIr!$+ zwEdM$9&p_8#5M_{j`WNdHn$ytZ7(b~6$_a`?)nNit?WWW@TQRhFFTW4kA^PZkLKa| zoyXwP1#3^+c6XaOPr$IE&mDc9_+Y}nlVET>iv20+?KdW9(-IX6HsWWt*VbM`YFC)6 zD=77s_L9e!4eRBJe}IR*;YP-X+Z|+jk`!B^s8XTJwNxLQOv^b+O+A-=;}s|S6BW2S zilg~O#A@c7;?Agn97!2$%X_}9OoHvNM0sd>5;Q_TXspvMw(!j^NdiAmDJQyr(b@Hn zJ?x4~rm~$#xN6fUK<5z~y~oYkPW90j@Dv%q&d|Ag&6VKFYo~RK2`*@3j!rS@_ zfz8Ez91`8ux$gOS${}I%3muk0`p3}T>1N428+$H)jo2{Z>)57-yMy1ZTH13Hwe||t zOB}S-=8jm5uEh5{Z$_?J9ml#%YGCiQ4858?oM&URRHuN}If9FijkQXmO}ht9WiXU7y zmJFY%`p}zX#lU8yB*d9SKI> zqjg?i*T|TzLrb0UrC&=mT+s&DaVTay4pw$(&MF|O+bk5yXJuB}S}T{&t6;G+Y8<6( zbaLJ>Y1rR~!uYbh>ONC?4wsh}8WP-60~=2WZL2NQYtjWsMx2ODE7NmE1cNxmx}YG# zuSn$RaKGyQpL_l~{B;dh#OaCyA1u_%D#BazYGkKnyMf+yu*nsblXWr8o6lQ+zusN# zWqS~p2&oK>@Bv@5dFK)@)BQ=e$WmUy&T-~LgajQ?U*UB&!Fe;xX%zN*>2E9-o0KC5 z_o&u&(E)u&5H>w1eVzDH!HrFLWoUQmtW>?LRu%B1hLPPT?wv5NKY!!nEzg73#rE7& z_DoZ|>Fgu=9$K^KG z?|TQl93yq<6a}~XO*8dUP7$X`A6}WyH?y&w6x^7W<4`#%^9+Y+lGRR?HfyKq08V;HxDPN{x_H_sOkd4}_OLvs>F+ujUp#v-fwA{CJ zPGjk5jkmh6gA3?jaiyw_)zlfKQ$K_1>1^Mm!F}gaKhHENSmF$82~qDird(Hpg69wH z%s}Pjr`7KY#vd0jKvXof$yeM9*2mp>?jK2rtULz&3U&sD!g1o%=`n?~Hvy|=s973Ht3sJhFQ^DH$=(Fo_ml=q2Q4V^s~ zqdQ?m1QC}okEI)fDWLeo-eVP&yFJMX&kv2dHs6YIl$#IZgO2%@AWrCju`kL*XFTn3 z{LU>ypEZPY9=*?>Z#cQDV|+cpP84r5-ZelN7H-DWIQN`aLWOppK2g;B`Q>iK2gd^2 zKCEDVQjAKq!GJ&fQeyh8n)FCFf8BKF^@&1RoEIOWg7??d(;Ih=v>`V=jUyJwp*pnC zmXA?t73s?Z3iESb5hczbbo13T&P!9hv1(gDH0!T*-s8*9CqQGH=fpf&I^2i0oaJIz zq+XS7NTnT86PM+QsAx)J6)18sdlY$_!Pi zikTy+l1Fw+rKak(ly0`&_i1+VakpQgj)m$aKl%i-3JUX_)*0g7Znb8wp0{56be%rV z+kKSwYx#-ly+KXM=51 z!ks!#dq@afhGu2(Y4{D)S}M*pHl_LR2zqa5QN8vra2fvMjAD+vw?g59!=u;bMbM2;EJcbJm zIq5DUnC2@6hPYTf-~B+Xx8(MMkti!dVLAXoEFil-Wz-$e?JYTtUwn12*|*S9UBMKF zM8eQ|)J7H5n-@JEO~MF5y|$S|>}4(^-DPBC)8&nHvLRf|5H9g>)IEr`0K9(`8fquv z5MqLhoC>_9wnxGcDyW))$x&ba64c_6C^WQJ)m;+cvVz$m#mlY(dmSU&A!Rd{ZpP`~Y`XX5A z8_~+B&3ox#$y=Y4aqo$x$^a9=vi3mxdynq)=7$Z4DergCP}kqvd@&x`Hulm_KgnZA z+&|7c#N@nOVGF$jBucvNV;pY!_=qhrsbw*^IQ;gy%d!u@4@B>p@Z+V3h4(kf;|lDt@MW%zpGzR%!2$4`Vi`UcgCZ<+EV|a~w{e{+?jcS!8x- z$)HbPL~Eq;;|+2b8$Se)E=#^a5jkt?7i1k$VUaR#ud0TThvPb8m%Vu=F3wV zI%O-VK|w7pEt_rdT~;tm|3mjDbtDW@cIe(o<*~i@r$+--%3}%slry%bII3Hn#9pRp zy~($RT)%-QmMyX?ei6YoahcI)W;Nxj9X`#bAj@_a*UM>_m+U3At2>0O?nA*^tFJa7 zMH9RHi{JX#sJJgfCVo5QAcoww<}QLaHYv{3S*5oE1aGz$AX_Mi%?~ceJTz<_cw-o= zn7%li$^RfxuLL8Ur4{U6V5P3WSbSu!k#tntKVcz{DPbTB0isLhXkEm416*T<-PGXm zGQ$%FOjH0?nF2YXGe0YSA>_)ZyTfkkwXlGqL!*&=6aZB1)N(Kg5Y0~MLw|wtc&Xo` z($deeh|ZVe0yo;4mDAHxCMqHiE-WjZYONjTPx|SdSBvt)X1jbc=}JQC3w6#pd%E%m zw6ik*2yCm>d5)Lx`dqq;l$;wiu|Lh$7~=CTxM5{{?nPnRs4ybFo2=8uT1k!vUX)z1 zb-B-e{r!w$30RR{|Ch;tC;F}f9+GrYZ(GjU6DhNJT30-|nX67;X<&*+_k@;BFUTR^}AKq#=(6^kR)>u>D2?<#xSu@)ma#LNvC#^6rIdNP6Z z*Nnq09`@b)!7X_%RcogDutSS-@s_a8KI~Xg+I$b!Vy2E)h>v-I^2E34q=P(ZGKbWd_G-Y;V zruMKygI4ijxFc#vRd4S_E3%Lt5yMM$GC99V%?INbJ4@wX`9$4w7TTyV!A$`7#&2|^ zfU26Tnb{<@9BYZxCwSo?NS^@-_@!{g$C{mI{K|xSA}%tK4mZu7lvP(s6}2fIr(QEq znwI)!0%^$kLetV*nxJXD_9~XLGnm6mcc@W#Zmv?fcryzK`CA5Rp2N{~-VprZ8q4W$ zqFcDv&lD#n>x|o|cH!}((;_w+^bUb{d+twXGTTNID-$FdUtD`!n%z8Ia)oolb^TQu zCH_XbaxS|57+BN{;n;r|)FsZr9+0z_=fPglqh^ZDnQlbpPPK7KZv2 z+H^AT#KG<1J4){Jh#fyEf*@VRFBp8%T%Y(D`e7E`)@-lu@Uq!6(=q5H@M2E>%Qpw6 zh9BmJHRx@wyO&L9J!{gjKeJ&;28n4T76${NMWcMc2+zyL6lD$Wzs~r%58-#MSG!JM zx!-&2oz&%SC!QyFgQS~lyCiM>Ru@?N_WZO;=e>g+V!!?N2bkx$=lJsERlA?Myj%O~ zN$`)o>OaoyjOGy&yE~hlQ!RF7^dqm{nNc?0v+U;rE>BVNjoo+sM2M0=lJ_o{dHaZI z(zEGT3zBnl3pMkz-I=e`N8Qv_%05;8V#aWrexM;@qEv?nRyEbw`(7g$6Y{QkzWHVC zGYW>N);`cb;X4e5_t02JD!IsIjGV5}PpL+{uQ7Pq8J~_o$pgQv&aac|ZoucCBJ8JX z7F#6GAi|l=BA1nGJ$$_?Z4l#@vId%@*j+>WJ|*;ei~a}b`ebI4joOm(Rq=fF9t&}^ zIcmgl)Dx1$auD3}Gg$XmT4XEMFRCD~ZKNE{%^5*!d}Q(9gwwo^uwK zJR6;8r9pl7NLo>^ID0^tyF)U+PFd05QC9)OJoF!h(wPpvI4h*0j#q})X2|b)b=}!# zktUm`yA+UJrId32LyVQY@xhxdeW=i~7doX~YV|^;Q7R`ZDm6rAveieV(x8())477v zq_85#$k>~|bCi?km4~Q*ynyR|QiLq@$pJmC!OA+V=;qnidBozA+oqk|e^y@kE)swZ z5X4k>O^-T=N+jqdXx_W(l;y3LlQDHt0-s&UWoJW=+|K7@5p zqAvC(d)(2l(?$2dh;F-SaC05xv?Vbh;~bK>w0Tv{>xl`|S^bKeEq! zhiMrM6jScuxs};ui+f$A5FfpW+v$Hip7IXR+#mKS!&THQh-r`1LY?~DH8+2Pn?tGh zB;gQbE7pUq!!%mty$fj+T@Y&N*F}6;7H!rj%0c^eLA|+?l0W&gP;LOR%Bf=L7-#yA zBhIQyb}2h;HOgZI)%58&@e3SxXqjLG4W^rBdWRSZ2N%yJ6#GZ!Ux?gD+>uk1l@{h! zcNGn9fu27KFP&WvYK})GdIrFO^t()CesRTKqx75o(7|{g?7gS3wssp7dGTN=Y>gkG z+kVQ<>QOIZm?`ui1k)_iR=s*{g!5DtHXQfJV_#~3)mS*Y&%|6uREqnc{lcR>^t3xWzLNK*k3lp-L#*yv5^z4zWb zL`6ZQ2}p0!rABIi00EKSdneKfEmA^gN#^1GUf=Kg%{p_|oLMt-)|@r_4}~OT?|nb_ zbKTc<-FrW~u+OLs{Zt%UbQ2X+UN7t!tvX_A$G$%~JWDeqLh}4*;vvVRANR0j{jmt% zXcn3=YuMIt;qHf@l5IbB`CD?=OGkam)|ofGO;YaXJ>Wp{P8O8WNSck>a+Anu-T zblgA<&y^%Eflem@H9^A$~4s1 zWlo>UlyI;?UsnP$a#Xp5J$mGxy*Ras;O(*L%Xt9-?os$%l$2QAU2o-1#^OiAEJHJ3 z+S5xnv-iQ)2)fz{^V%|ozC%qGQ2`Luke!*Eo7>WS<&j5r;5#Ck`xEpEdrMr{f$w^_ zu%GWoFxpWm%a22A=$nka5QuwlJ03(vO{}%V_U5S(G|CiquqA!jeaSBJ-s4?NQ4n8g zdx?6e>y!GQCCLGr$aP!4xgVlbD_UwPrUL~m2Dp`>a9mp654zBjr1-2?%(J#e{KGrM z&LAZeU@!?#@EHzPOnRtonK8|-<#1&>DK35#PCXb)z30&qP#ovDR~=~T@#6cL9jCN=aRO(oT}twwHf?V$k4 zL{&NvgQ+W;PMqIo)QWy80nL1j>YQLud{kx)QGI#U-B|HK1?q&2lOyfdusW7uAmS)3 z{TWyh=F8;qlkua!JZy<+U#;S%w;bg-z-s`DwXv=&lqRaPE9-i+`^<%)U|t8s-}wpx z;XX^L8A)_-VxyV)r?+;8H;9sm;HBs~zY^^5X!PASj_a9rn&n8>RN&FH=~ci%j>b>u zX1pce_NFd=Y2l^}#Rj~XvWW_CQO^Jcy%G@L2K>t6wLX9fh>{1NNACGA0{}dz_If9e zHI$Gt)Q*B8{I0!nZA?k@*m@bynYg9#h+)h=@}SZ}_h}GV^yNep*ELwzlooAl>sUKp z90Z26Vut}C{QR-Lcvmqa^wHYu^k9LC^|`B?I^q-g<%OR{7(T5V5?0Z>)u*`t4t4?U zv1+S)NEMQJw+o_g@byYwEnnq$iM{?^S&*iY-8(YT4&L9zOS1uH=sO2VCzE5uvbJPw z)hxrr_IIE9`9*1R!ZW1#E67)c^5Ymkg2HBAR!_W*zIDiFTBIMWn_0UVmF0u}JaH>t z!L4A}R`D`~HL?`N+tuTNn_|Qr?H*es?0>l8cQjmM;BPPaHVt~|evgd+DQd@XIKM$G z*At(m7jzdl10^d27Dl*XN&goU-*k6c8A#XS;RaMZkRCydtBcN77S)2bK2?Y+QQfGJ z#o|;;Z)yl*BCojYZ5r$|D=!`7Hy8%yE3;oX^;xgz2G&}aYF=rB1Ku5iOYGV$D&VYo zcq0bz@%x>5!n1_3oKsA*ne)nXNe!A5?pe@WoKn-b(*$jgB>QuXf65LUPWck8Me21_ zIYhAF>Dbj(MUrk>0;sj3@dGQn4Yl_S=4|2B zNGtz$bJbg`mmw^u0nbTypoBj6mI&X}YH@$Gkzn9sr#%HsDi_ya5Bg1C?G>oR$Twu& zL~eThV5YBuEefV2L>5{i!Yf_sRo7-$HTNK0DGWMCV+gDU&0c=uE}&w*T!gT=O3YAL z?iz?cl-3BXDCIwW2~|?bVY43A1+0!aKFvYbeZH3!u*aDZuM`Fngop#i20jWiS-|!7 zXV=qv@%#z9LZ6RFJx+3Wyw#BoLB%(lU%hS1^n$YGrZ?RLU+1A|dG8-5I@{0!{apT2 z%fg!ImI1_9&^t)o5m+pmV_eNmKA;M1g3J9;<$-6aDC(U(OvWL4DX zN|%8Z76&2p|B9cZ``A<=FoU`DGGb(|no&Ai+&S^?t9hieHFn@z>!OzWYo`qy@F*~g z%VanlU1Wn_sq$EAV&MwRW3|QpS$#be?XSYZ`|(iea``6MzcfI!{Q*t+amuW% zW52_o+U8_JcFSjKEeq4S!&J&Qb(O3fx#`A$HY)GjNYWX^hRv#Fmm{v05cUkyn<9{E z;?U(t*VWM_@l<`(>h)Hg<8Qvg667SR>24t{3p(XbzMVRrD6@Ej3FF>(0BW_4?&-VPW4OeQNEu-w)HOl`ks(j~c%v3V_=EJXp(bH08g~pb33@5aP#K?k8Ps zIk9Y{7OgK7k-=5XJ$aj zj9b2jb4_uK;nap=mFK#TPHkL1VB@x*)VIx+*NevsnGTY^3%01hz|_`adbf{JIkPXv zef$V1Dxy4uP+EITRQ4G5{qXHD+d=B&PJl*_D!LZF1Jz_?!i99mkMNZBf$?Wg=6(d` zjxd1LmKuH``{mgmj|hK?QKEZiHm@O0HOnSBE9sThkf*rH_yQKq$kQBrNYxhSAeJ~2 z;F>?0&pQ1XrX8G7rp-+Gq%X()&3ZXiB_3fJ@S-$riC{?$-ptC1T}X4IDE)KRf*!4h0j+W4l6a5&UFMqkH&N=vM^;3Y+< zPx{P3Q`9RH>z(pKz4|yI1I5&d*+lb>HrJ`EitIeJ`oOR4GA6G=ODhi)<0z5+@IP%FjIdcd~zDPGxfm)wA=8yZHXS6kaZCdD;R zr~K?$b%5FlV4vz4Y>vKjUJeL$fM*bP=hEiJPXQht}6uGTM3Wc$FrYj@FEYY|wwv8gyTiyu$mrXwm^&OeAJfF!eOf=+d} zjgeM0_JfALt3?EJ$57kgGK%NRQTFcc+=tuc%3P@%gF80yApe07LBiGAd|_KeUWaD! zy~*<`f}Q^~OSo_NC@z&GERE*yvipeQH%QLljw$dz5r}`;uB6_B)UL}@oi#!_{q8XY zOD>qzR8&TfkH~E6kU=5CK8=DZY29sAv)#wMcG)GlaY4qKf<{9b)+`!}L z=y-43OnA#DclzB<_lc$8y#bDg`GoyCt}U3AK$v;Ks|cHT(Jomdd9=V0A$i0`{3YAWY+;P9B z!qLGxQ{F4`b^d)kbxYIJC1VWZvju=x?f+aROVvgVgT$*f(TFgU2~K7@TCJCk?o3r$ zco?%;sS}wZem&~XwE!Qi>x;%yeivIN^JNW}fF=!)4w=hF3N>V*MK@iA7FKM|7HnQ# zw^{p9iw@T~?YseIH@LYXxmZdI5Qr*V7U`G!nm!|AGdKnG__2oyIpQy95X9TRbnSI` z2J%C5YcMOy@ddyDIKPlGIdkoGh^CGfs{g#|j804jx61RO&HxGUBf!U);|>clZZ&@4;7&ew7W*9VlN-pUJ(ycspV~U_mu-=2eIw`g7?qFQcZCepsS+UomZM84v7S8{0KJBnv$=gSWy0S0eydZ~nz9z%tE)e9o zy5#?98ulgd*n>rBKRW~2>+>tf1pHagvB9Q{rZoBVt0GBtrI~HDEd2LU|M9MWEWHaQ zjiv)YeuGAh)r2y6eyOPy@6aCyE~}5NoP_%`C*aSuao=o`?*SIu_C1~?$OIJ50S!jd zlo^{<+IXsVVE#oW3t#;EW@(~3@{ftBnM^8rh~k*@6xTLtB)7lE{_|IRJpd}3wQFi! zBBWL2Hztu*TWr&mcm6C3^~P>^ouTpR{h#yB^g_mlsHiVC36cjN6mHw&>?=k{{6k;n zO!Z+qjAIQZ8TujOGcOPRzK@znJM7fo&@=lh9> z+Hd@$N5G4pSuVc$6Hfpz^4I?3R_v#7|D5&d4j=vZcKE*dIC%R!|BR=;bNcxg0|}f2 z^VA3FX*($X$w_(dPSv!XFA`#1|K!5M>qJwl< zy*ty>mi`rwjuclgh+{oj>%|9?pTzY$X7 z_4QlwTeVCvn7@x#Ngj$k+KehW#k8`Ey#ED-_JoM5X^~I=Hcf*VAN~e#aECtO%pdsz z{zf|!Nz>>@as8juCsMlF{RgGNi*Ft^F*KgODV4ev`>~qgOc3M36>2dp?^A_~Y9uu} zeeYk}|1*&IKaiP9>qO&Qn0DS0x9q~dfNwR>xbmAD%8#liJs|pd5M6`blG1}dy_Ppa z*A`CCSn$a>s0K_e0y%QL1dLD1bK_#d3At3QFk9((vCbX@0ao|7$BO0#dD=*Ql{!7k z&kQ+TH?8JJ9)jzCajR!4x2BO~&7RZu(r=!OSw@Imi+ngqqw>8$R#Deu ztv?BK8RkJcwVtEOH*Sr;7Rh+*eX%-CE^;loL~UEUT#W$3RxFGuN4U9P_^>@L(aHpl zz{%zpbdB#f0FMyG^vwA1*OuO7AkRoEty^w3LBIfE;#4gZ({ z4E-Bb;xkUSE4HSgGQLl%=wLe*N-DpW7KsEQ%gnMWyS=ziZs(*jKY>GOv~F2*xsR9X zD`t;}WC<-if|aR7RlTvRanMff4Tx~DncPm!8+Ps|wGmxnNjjXcHODU5%s+{@=~505 z`B6!mu$&|Y=!KCM_vwC#%zk{k`sQkAMj3f32QnS}R4+p2A!siyPWWkR|x zi!9`LK9Ul3@xE9e$Q5=@U3T<)9vY8iVKTabrWJ!2LUrwFHf5!bEUC34AGPF67nqg@ zxUzSR1pNB$H^kbxwnjsAD^*C<{8kEw@&vW4$}wJ;Uawv$T-O8R7e!ejecurB0gOxX z0gR)qn8kA@>Hu4H%%It91+q&Y#6k6wx+++jZZrMn06%&PuKAtw)Vqs2Vq1r@@+HZwrgwEEQG3T-E$`urcvT z+SF#sQzpo`U0o-;?8>bU)z_IE1auPv)KrTj36n!>j;*E=oY(9nLc+#vII#ZCCUavH}CKt=8|G9!SvS&dBU<0!(zFV#9}zSW$9qJE{yAy^TCo3v$N``Jjna~CIAE>c@x$(uD?bv?pAni;tl z65Wy+5hzV5Iw`oipPSsXFq#cH`fgTgwLSxp(K}gIO#aI2fKXCrglk=;wbA-<`!sRW zMjQkXU)b{9$Iei7r?G>)*K0YyY$`%76K&VlL*x<3F?i4wFB@Hj#J-Pcz%(%D?{t#q zo3sceIbm{k94FHfTH@9)GBbtf)A3T{c8%W2Rb#(RYCTmSnTH}}c8#Z$PD>Bt=%De-=>-&e{fczuW<2Co5ihf>4boR8 z+B?GpQ+;8kSIbg{Ll-L-y<@4MAUa!5KyQ~r;5oyyd=GWF_4y-pi$$=k{<}5i>kO4! z!gmU1N=|t1n^!1-Luxc)w%Bi-x0x}=`)?MrYdV-GW;T5mwps6XX*=I&db2nB(ap-O z62r}jjtO|ZFgw=OV)){2o1y^xiXULr)>d3(6zbfZZ{2@(aX~MnKx$Iyt zZOo{}KKXz^&hISksB3o4%ajy2KR=ZDs)|`hs&3CuBsq;p-iG^SoFv;UA}bWZsbTfg zy&hRdrh1L3^el&F_m>zP2-vIQX<}j}Or!TDFlE`M$Alx~W(}(%`=%mny1+$tS-_Un zZzDPz3F>6@mxRZsFdk+RS7Ib^Gc;NLgOq7!;ww(eXK9mI1~oo219GjIwzI!dwppUP z#RMX6U6G_LvVEnbo+!E?myJk_BtP4sQcNc|Hnq!~Hmgx9-F4cHCQ1BT=5es+U5$7tsc??u9M{V>M`cW$Q#C zQoGT6=)TpmWl1ccG;^u9pVT8>%#FVJbW=ffJ5#8h!55^U%K8>DySAuw4aGyXfB}hBjV2haq9vK8X^cCZuj&=B-z@;IJ~;V{)IPAs|s0>S-YrpZB+PU8{f>tlR#XEA#W6S6Q1GL@;-u6MP4{(IMV>Q8O+ zW!~M0V7TUGVFTuCd|e``vXPNh>roDAWhSyJeB=2M#O^kJkYF-#gt)c$utJ4rcCWH$ zqoeaUi9cycWRPaT3NP&zxtr;qv!|}~C5qH_c9@@cm=cR*VZeU5g*i;|dQ?(dNvj6) z2uwNe&7LBmzZ9Rs)K{&dNEmM0>xBixRt&$c{g6C0T!sD&QgJC+{I!Q8Fk2DczL7qp z-~=gA++6I+MW@8osQ_g#StH_QHRJx&v`<78Kvq?KB^a{hM^8c}HNcZBBfe%fmAo29 zt$Np$INkU8fsfAG^vRye6+yplYhq&MhzZ$`7(OhmrPIfdlO0j(F&;EZ`s)mWIS=S!M*^?PU_`Ig8T?7|3o~GG~>jiNSLaa zkSEQ?K^WwG7l^ALgccTtMfr&~j~>HJruG@WFaxlRY2mj){Iw3&UZDt4geOTOn9LTz zm79+VTj6f!IY&zxbLwpkL3ZZIwv`EP?0mVIWWLY1wfls`N9@MtB&}*(R6SppnlFiN zKtJgVA${MJsp=4)r6c~;KTm)E`CqIBxU z*sEwY=MIwz|C&>}u(1?cJ=j>1cM9G4X?+SajDM!4-D4XByZ1pX4CL3d53YGOTVoRW zGV*X2)=1Uwy-=_2hmII8P>c#%dzK|%bkyvsS2*QxDeBeNR`3b-6 zE*6sdU`QLS`Mu;>$>6=7BN#2E;Pf43@6vbDv|#Ne^HSEnb7U|NE1)r~*1|Ol%z*ac zOvvaBvZ#^2Z00Nsu&N&{B|;Tx9?)@mzwjNZ(UN)|Q+?hyO!j9sedlwsc?OhrJ!hW1 zXnQM6@8?|K$)!`2U#P+U+jFI3&8dB^E7s)kCLUKVd+bWK@54Mio(Z&(8Xu(AA^`96 zAxX#F6>c{EX!0Q3@LPGQ`GK+w7r?xxl=P4>z5DR7ln<*6wp+W}WnK|=F+JCQHxAkQ zKFXmD{mF-#>{9c|~=ML;D6@*^{#i z^xCl38o~NGE2(Uj4a#$I8t!yO0`bM45^vdWYw78t0~9ErHEK1nrb`d@^6?nON$l{$ zq}>r+fc)#u(?PJR)%sQqb-gmJ(_OMLW&yEwXy`9p3DItAp#(#qbSXrP6@K>D>`M_5 z7TKx=i=<85Lg;0>ZQG5q^FTjnJfhQZ?Qx*@+E=f%L&oo&Wave1c>QAgaMHt`Afg^2 z3Vp7~VKZ4KzpZ6~XiVzeCtE(D>`r=QnZbp64B*kbHBr8Ow0PzYy+VM}dM{3R=SV6X zZkI@RXaEFTf$yO`z0R&h`X)m==F3maz*Cx_yIJw(-p%@jDxlr!XR-6|NE4fOR(RLr z)$0IdVU|7h9l|tDxeO7o1qOj{8O9JM4SvgR{x_ z@yCY=yJN8kgqV>aFx3Bq3Aj1ueE<4#YWMLhN*Tu1Tiib1&z>rZL2!ldzd!ar-2qH- zIrF{6Sk+-kYrE)=rm3HwNbspQ$@o4B!IR`oZwK3C_el^DQG1_={k6Bo)u?SELC48+;e_8j zOB%6kH$w`{kHt5AUBb@>(<+55@%gh3XdDO|pFRwlnlzN9L7i=z6(0 zpkztAXaUqJ065%iTkvcnMYaq6YM~^H{H=~y0FVpMz#z$Ae4aZBk-h7b!f#lyRhCG-52Nw8M5X}g@9+=Ocg=k&t^LoSY&Cp z4jkS2!L#r#uP6*uC>wIf3ZIO@DvqN@qO)bk^m7Muu5`1uEqppBOjU#}wC>^@sXvK( z&ao+GVEP?G$a`^raw;4_;^apV+MD;;^xpXJ6I~#I_hdKVlZG?+Xt>)7eEXN)b9vh4 zzaoKF`E8Zuo$_COq_jQn{C#j(V3P3DTzzxJLTRvKswB#odQI>ZD=64N6P-pI?N1oH zJbWyT*(NEpM?d?ZD;&*WJ6%^VEd)MuYL1kyaD6eR#xV%YEUCUOou^YbIJ^s%@H|h( z7@K6`5>I8jv2&h1g;z2e0ZD)Ug0yE)n~?DUzG?>+k-Rl#i*^>5x!KRmU1R zzyOl$$33-xu=;StVih<%zLe0~wR^FK)iPjB&iet{u{JDsm;Bf1ZMRudwY|P*2sx3-oin|=3UZm>wBw2g%8GxgZVEcpKk1Ryq2j3#MQ&Y`y=O;^gdz`;efs!xX9F!u<&3u%a1@!-m%@yRJAE?dT^l znSvj8MNRJX9+O;@xyDNO`JDM0R=cZ<@_S$FY~O^S=+D4<*vCib;cZmMLRkfTao6Oa z4{8d!_l{xxsbG5;vDZrsyKZZ;EM3I3(St@bBnhSN1pw{dl?+QRd4NP1y~L`Y;9= z_wQ|lRuE3;e|5sV3dNmSEgOzDFuH`Z6X` zVZNdprp=XI$ea{oz>sf7sHPiI%{{z z*baY_b$4*HHtbz(<@7xB>bogLp#<(RyMzx3J(R9BB_&M6A^!+IydX5O3s)?1FTClvX2;UuOHlnVI zor-kY<7#+rW#%{eIAj{lQK^Q}#1gwyHa2qi+A(TlDoImp5=s@2+q--8h#h=;0n`|W z1}i?mA^X^CWL(ws9TAZ0r(0C)J@ZSyifY;QyeAHhgRCG++}93s9Y@#Xh{^;??)JoA zm%kyI&P8b7q}&*P)12HSqe`0!?K#PK&VwO7&`^Knfi&EG_Y)I-!ltSr?bRf)$OQ!99WL#sqHC25w zm{I-ShG!GT6Hv5|wHfI69wf9YFdY-+sVYV!=9vDGBi1hIe(|v(6JZ(SRVcqrxu}#p zVo>dSg+DJaW4GrLhi`-0HY{g?t=>|AD7>%k8nyw>e|Y7yK~khKb(kl%H9C?Aq+9vX z5M%SQLUUr`_2cD$GMxU}HtDaXiVFR4mSc99g0bY%!;x|G!1Ur=I-n0haK%Y6y$9QFq};e zkCKmn{37PSrc>+qP}=Hb>xvtL=Gr(Q5U zbE;~tyKA`{DM9fte)zER6=DRRdk+tS-PJSaapOA;si9pHtoAZ#AfKaf?<1}mNBT(S z_cyd|@bmFMLvELYx$$y8XmXwZOiDCm&?xl#!6Xyo}eS^M9;ife;c@Hr}x4~_RQAFhSp)x(QOylsADu5)$k&+mnLP%f7gigcH!Lk1 zc1W8BoVz>Pqt&YTMAi|%sT)RODK}@NhRV5m$FcKMu-CZu89`USb3P;-HncyVQF^Htvg>`J?}_3=O-z?()a%zTzd}BiD@%0AxRMZQ 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 Df@SV= diff --git a/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php index 3b16764..786eb37 100644 --- a/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php +++ b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php @@ -111,7 +111,7 @@ public function testPostCreationWithoutBlobUploading(): void ->text("Testing post creation without a blob uploading") ->embed(new ImageCollection([ new Image( - Blob::viaFile(new FileSupport(__DIR__.'/../../../../../../art/file.png')), + Blob::viaFile(new FileSupport(__DIR__.'/../../../../../../art/logo-small.webp')), 'This blob not uploaded during this post creation' ), ])); From 17e985f9836b150e6a79fd2a60db7f3a109c5c68 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 9 Nov 2024 03:27:08 +0400 Subject: [PATCH 58/59] docs: enhance README with detailed examples and structure --- LICENSE | 21 +++ README.md | 517 +++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 452 insertions(+), 86 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..895d76d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Eldar Shahmaliyev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 5167fff..2ef5cb1 100644 --- a/README.md +++ b/README.md @@ -2,158 +2,503 @@ Logo

-# BlueSky SDK +# BlueSky SDK for PHP ![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/shahmal1yev/blueskysdk?label=latest&style=flat) ![Packagist Downloads](https://img.shields.io/packagist/dt/shahmal1yev/blueskysdk) -[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) ![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 +## ๐ŸŒŸ Overview -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. +BlueSky SDK is a comprehensive PHP library designed to seamlessly integrate with the BlueSky social network. Built with developers in mind, it provides an intuitive and powerful interface for interacting with BlueSky's features. Whether you're building a social media management tool, content automation system, or just want to integrate BlueSky features into your existing application, this SDK offers all the tools you need. -## Requirements +### Key Features +- Rich text post creation with links, mentions, and hashtags +- Media management (image uploads and attachments) +- Profile and follower management +- Robust error handling +- Type-safe collections and responses +- Comprehensive testing suite +- Modern PHP practices and standards -- **PHP**: 7.4 or newer -- **Composer**: [Dependency management tool](https://getcomposer.org/) for PHP +## ๐Ÿ“ฆ Installation & Requirements -## Installation +### System Requirements +- PHP 7.4 or newer +- Composer package manager +- Required PHP extensions: json, curl, fileinfo -To install BlueSky SDK via Composer, use the following command: +### Installation Steps ```bash composer require shahmal1yev/blueskysdk ``` -## Usage +After installation, make sure to configure your environment variables for authentication: +```env +BLUESKY_IDENTIFIER=your.identifier +BLUESKY_PASSWORD=your-secure-password +``` -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. +## ๐Ÿš€ Getting Started -### Authentication and Basic Usage +## ๐Ÿ—๏ธ Architecture Overview -First, instantiate the `Client` class and authenticate using your BlueSky credentials: +### Lexicon Structure +The SDK uses a well-organized namespace structure that mirrors the AT Protocol's lexicon hierarchy: ```php -use Atproto\Client; -use Atproto\Responses\Com\Atproto\Server\CreateSessionResponse; +use Atproto\Lexicons\{ + App\Bsky\Feed\Post, // app.bsky.feed.post + App\Bsky\Actor\Profile, // app.bsky.actor.profile + Com\Atproto\Repo\CreateRecord // com.atproto.repo.createRecord +}; +``` -$client = new Client(); +### NSID (Namespaced Identifier) -// Authenticate using your identifier (e.g., email) and password -$client->authenticate($identifier, $password); +Each operation in the SDK corresponds to a specific NSID (Namespaced Identifier) in the AT Protocol. For example: -// Once authenticated, you can retrieve the user's session resource -/** @var CreateSessionResponse $session */ -$session = $client->authenticated(); +```text +- app.bsky.feed.post -> Atproto\Lexicons\App\Bsky\Feed\Post +- app.bsky.actor.getProfile -> Atproto\Lexicons\App\Bsky\Actor\GetProfile ``` -### Making Requests +### Smart Builder Pattern -BlueSky SDK provides a fluent interface to construct API requests. Use chained method calls to navigate through the -API lexicons and forge the request: +The SDK implements a smart builder pattern using method chaining. This provides an intuitive way to construct API +requests: ```php -use Atproto\Contracts\ResourceContract; +$response = $client->app() // Navigate to 'app' namespace + ->bsky() // Navigate to 'bsky' subspace + ->feed() // Navigate to 'feed' operations + ->post() // Select 'post' operation + ->forge() // Initialize the request builder + ->text("Hello!") // Add content + ->send(); // Execute the request +``` + +### Response Handling + +The SDK uses type-safe response objects that automatically cast API responses into convenient PHP objects: -// Example: Fetching a profile +```php +// Getting a profile $profile = $client->app() - ->bsky() - ->actor() - ->getProfile() - ->forge() - ->actor('some-actor-handle') // Specify the actor handle - ->send(); + ->bsky() + ->actor() + ->getProfile() + ->forge() + ->actor('user.bsky.social') + ->send(); + +// Access data through typed methods +echo $profile->displayName(); // Returns string +echo $profile->followersCount(); // Returns int + +/** @var \Carbon\Carbon $createdAt */ +$createdAt $profile->createdAt(); // Returns Carbon instance + +// Response objects are iterable when representing collections +/** @var \Atproto\Responses\Objects\FollowersObject $followers */ +$followers = $client->app() + ->bsky() + ->graph() + ->getFollowers() + ->forge() + ->actor('user.bsky.social') + ->send(); + +foreach ($followers as $follower) { + // Each $follower is a typed object with guaranteed methods + /** @var \Atproto\Responses\Objects\FollowerObject $follower */ + + echo sprintf( + "%s joined on %s\n", + $follower->handle(), + $follower->createdAt()->format('Y-m-d') + ); +} ``` -### Handling Responses +### Authentication +The first step to using the SDK is establishing a connection with BlueSky's servers. The SDK provides a straightforward authentication process: -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. +```php +use Atproto\Client; -Responses are returned as resource instances that implement the `ResourceContract`. These resources provide methods -for accessing data returned by the API. +$client = new Client(); + +// Basic authentication +$client->authenticate('your.identifier', 'your-password'); + +// Or using environment variables (recommended) +$client->authenticate( + getenv('BLUESKY_IDENTIFIER'), + getenv('BLUESKY_PASSWORD') +); +``` + +## ๐Ÿ“ Content Creation + +### Rich Text Posts +The SDK excels at creating engaging social media content with rich text features. Here's a comprehensive guide to creating various types of posts: + +### Marketing Campaign Example +Perfect for social media managers running promotional campaigns: ```php -// Retrieve properties from the profile -/** @var string $displayName */ -$displayName = $profile->displayName(); +// Announcing a special promotion +$client->app() + ->bsky() + ->feed() + ->post() + ->forge() + ->text( + "๐ŸŽ‰ Summer Sale Spectacular! ๐ŸŒž\n\n", + "Get ready for amazing deals on all our premium products! ", + RichText::link('https://yourstore.com/summer-sale', 'Shop Now'), + "\n\nโœจ Highlights:\n", + "โ€ข Up to 50% off on selected items\n", + "โ€ข Free shipping worldwide\n", + "โ€ข Limited time offers\n\n", + "Questions? Ask ", + RichText::mention('did:plc:support', 'our support team'), + "!\n\n", + RichText::tag('SummerSale', 'SummerSale'), + " ", + RichText::tag('ShopNow', 'ShopNow') + ) + ->send(); +``` -/** @var Carbon\Carbon $createdAt */ -$createdAt = $profile->createdAt(); +### Tech Tutorial Series +Ideal for educational content and technical blogs: + +```php +// Upload tutorial screenshot +$tutorialImage = $client->com() + ->atproto() + ->repo() + ->uploadBlob() + ->forge() + ->token($client->authenticated()->accessJwt()) + ->blob('path/to/tutorial-screenshot.jpg') + ->send() + ->blob(); + +// Create in-depth tutorial post +$client->app() + ->bsky() + ->feed() + ->post() + ->forge() + ->text( + "๐Ÿ“˜ PHP Best Practices Guide: Part 1\n\n", + "Today we're diving into modern PHP development. ", + "First in our series about building robust applications.\n\n", + "Key topics covered:\n", + "1. Dependency Injection\n", + "2. Service Containers\n", + "3. Repository Pattern\n\n", + "Full tutorial: ", + RichText::link('https://blog.dev/php-best-practices', 'Read More'), + "\n\nSpecial thanks to ", + RichText::mention('did:plc:reviewer', 'our technical reviewer'), + " for the insights!\n\n", + RichText::tag('PHP', 'PHP'), + " ", + RichText::tag('WebDev', 'WebDev'), + " ", + RichText::tag('Coding', 'Coding') + ) + ->embed(new ImageCollection([ + new Image($tutorialImage, "Code example showing dependency injection in PHP") + ])) + ->send(); ``` -### Working with Assets and Relationships +## ๐Ÿ‘ฅ Community Management -BlueSky SDK allows you to access complex assets like followers and labels directly through the resource instances. +### Profile Analytics +The SDK provides powerful tools for managing and analyzing user profiles. Here's how to build a simple analytics system: ```php -use Atproto\Responses\Objects\FollowersObject; -use Atproto\Responses\Objects\FollowerObject; +use GenericCollection\Collection; +use GenericCollection\Types\Primitive\StringType; + +class CommunityAnalytics { + private $client; + + public function __construct(Client $client) { + $this->client = $client; + } + + public function analyzeTeamProfiles() { + // Get team members' profiles + $teamHandles = new Collection(StringType::class, [ + 'lead.dev.bsky.social', + 'frontend.dev.bsky.social', + 'backend.dev.bsky.social', + 'design.bsky.social' + ]); + + $profiles = $this->client->app() + ->bsky() + ->actor() + ->getProfiles() + ->forge() + ->actors($teamHandles) + ->send(); + + // Analyze profile data + $analytics = []; + foreach ($profiles as $profile) { + $analytics[] = [ + 'name' => $profile->displayName(), + 'followers' => $profile->followersCount(), + 'posts' => $profile->postsCount(), + 'engagement_rate' => $this->calculateEngagement($profile) + ]; + } + + return $analytics; + } + + private function calculateEngagement($profile) { + // Custom engagement calculation logic + return ($profile->followersCount() * $profile->postsCount()) / 100; + } +} +``` -// Fetch the user's followers -/** @var FollowersObject $followers */ -$followers = $profile->viewer() - ->knownFollowers() - ->followers(); +### Follower Engagement System +Create meaningful interactions with your community: -foreach ($followers as $follower) { - /** @var FollowerObject $follower */ - echo $follower->displayName() . " - Created at: " . $follower->createdAt()->format(DATE_ATOM) . "\n"; +```php +class FollowerEngagement { + private $client; + + public function __construct(Client $client) { + $this->client = $client; + } + + public function welcomeNewFollowers() { + $followers = $this->client->app() + ->bsky() + ->graph() + ->getFollowers() + ->forge() + ->actor($this->client->authenticated()->did()) + ->send(); + + $newFollowers = $this->filterTodaysFollowers($followers); + + foreach ($newFollowers as $follower) { + $this->sendWelcomeMessage($follower); + } + } + + private function sendWelcomeMessage($follower) { + $this->client->app() + ->bsky() + ->feed() + ->post() + ->forge() + ->text( + "๐Ÿ‘‹ Welcome to our community ", + RichText::mention($follower->did(), $follower->handle()), + "!\n\n", + "We're excited to have you here. ", + "Check out our pinned posts for community guidelines ", + "and ongoing discussions.\n\n", + "Feel free to introduce yourself in the comments! ๐ŸŒŸ" + ) + ->send(); + } } ``` -### Example: Fetching Profile Information +## ๐ŸŽจ Advanced Content Strategies -Here is a more complete example of fetching and displaying profile information, including created dates and labels: +### Content Calendar Integration +Example of how to integrate the SDK with a content calendar system: ```php -use Atproto\Client; -use Atproto\API\App\Bsky\Actor\GetProfile; -use Atproto\Responses\App\Bsky\Actor\GetProfileResponse; +class ContentScheduler { + private $client; + + public function __construct(Client $client) { + $this->client = $client; + } + + public function schedulePost($content, $images = [], $scheduledTime) { + // Prepare image uploads if any + $uploadedImages = []; + foreach ($images as $image) { + $uploadedBlob = $this->client->com() + ->atproto() + ->repo() + ->uploadBlob() + ->forge() + ->token($this->client->authenticated()->accessJwt()) + ->blob($image['path']) + ->send() + ->blob(); + + $uploadedImages[] = new Image($uploadedBlob, $image['description']); + } + + // Create the post + $post = $this->client->app() + ->bsky() + ->feed() + ->post() + ->forge() + ->text(...$this->formatContent($content)); + + // Add images if any + if (!empty($uploadedImages)) { + $post->embed(new ImageCollection($uploadedImages)); + } + + return $post->send(); + } + + private function formatContent($content) { + // Transform content into rich text components + // Implementation depends on your content structure + } +} +``` -$client->authenticate('user@example.com', 'password'); +## ๐Ÿ› ๏ธ Error Handling & Best Practices -$client->app() - ->bsky() - ->actor() - ->getProfile() - ->forge(); - // ->actor($client->authenticated()->did()); +The SDK provides comprehensive error handling to ensure your application gracefully handles any issues: -/** @var GetProfileResponse $user */ -$user = $client->send(); +```php +use Atproto\Exceptions\{ + InvalidArgumentException, + Auth\AuthRequired, + Http\MissingFieldProvidedException +}; + +class PostManager { + private $client; + + public function __construct(Client $client) { + $this->client = $client; + } + + public function createSafePost($content) { + try { + return $this->client->app() + ->bsky() + ->feed() + ->post() + ->forge() + ->text($content) + ->send(); + + } catch (AuthRequired $e) { + // Handle authentication issues + $this->logError('Authentication failed', $e); + $this->refreshAuthentication(); + + } catch (MissingFieldProvidedException $e) { + // Handle missing required fields + $this->logError('Missing required field', $e); + throw new ValidationException("Post creation failed: {$e->getMessage()}"); + + } catch (InvalidArgumentException $e) { + // Handle invalid input + $this->logError('Invalid input provided', $e); + throw new ValidationException("Invalid post content: {$e->getMessage()}"); + } + } +} +``` -// Output profile details -echo "Display Name: " . $user->displayName() . "\n"; -echo "Created At: " . $user->createdAt()->toDateTimeString() . "\n"; +## ๐Ÿงช Testing -// Accessing and iterating over followers -$followers = $user->viewer()->knownFollowers()->followers(); +The SDK comes with comprehensive testing tools to ensure your integration works flawlessly: -foreach ($followers as $follower) { - echo $follower->displayName() . " followed on " . $follower->createdAt()->format(DATE_ATOM) . "\n"; +```bash +# Run complete test suite +composer test + +# Run specific test suites +composer test-unit # Unit tests +composer test-feature # Feature tests + +# Run code analysis +composer analyse +``` + +### Writing Tests for Your Integration + +```php +class YourIntegrationTest extends TestCase +{ + private static Client $client; + + public static function setUpBeforeClass(): void + { + static::$client = new Client(); + static::$client->authenticate( + getenv('BLUESKY_TEST_IDENTIFIER'), + getenv('BLUESKY_TEST_PASSWORD') + ); + } + + public function testPostCreation() + { + // Your test implementation + } } ``` -### Extending the SDK +## ๐Ÿ“ˆ Performance Tips + +1. **Batch Operations**: When possible, use bulk endpoints +2. **Image Optimization**: Compress images before upload +3. **Cache Responses**: Implement caching for frequently accessed data +4. **Rate Limiting**: Respect API limits using built-in tools + +## ๐Ÿค Contributing + +We love your input! We want to make contributing to BlueSky SDK as easy and transparent as possible. Here's how you can help: + +1. Fork the repo +2. Clone your fork +3. Create your feature branch +4. Commit your changes +5. Push to your branch +6. Create a pull request + +### Development Guidelines -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. +- Follow PSR-12 coding standards +- Add tests for new features +- Update documentation +- Use meaningful commit messages -## Contribution +## ๐Ÿ“ License -We welcome contributions from the community! If you find any bugs or would like to add new features, feel free to: +BlueSky SDK is released under the MIT License. See [LICENSE](LICENSE) for more information. -- **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. +## ๐Ÿ™‹โ€โ™‚๏ธ Support -## License +- **Official Documentation**: [Official Docs](https://docs.bsky.app) +- **SDK Documentation**: [SDK Docs](https://blueskysdk.shahmal1yev.dev) +- **Issues**: [GitHub Issues](https://github.com/shahmal1yev/blueskysdk/issues) +- **Discussions**: Start a discussion in [GitHub Discussions](https://github.com/shahmal1yev/blueskysdk/discussions) -BlueSky SDK is licensed under the MIT License. See the LICENSE file for full details. +--- +Built with โค๏ธ by [Eldar Shahmaliyev](https://shahmal1yev.dev/about) for the PHP community. From e3470c9120a0c452d63f36f788c9b32b8a939214 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 9 Nov 2024 03:40:19 +0400 Subject: [PATCH 59/59] apply php-cs-fixer and import optimizing --- src/Collections/ActorCollection.php | 4 ++-- src/Lexicons/App/Bsky/Graph/GetFollowers.php | 1 - src/Lexicons/App/Bsky/RichText/FeatureAbstract.php | 1 - src/Responses/App/Bsky/Actor/GetProfilesResponse.php | 2 +- src/Responses/App/Bsky/Graph/GetFollowersResponse.php | 2 +- tests/Feature/Lexicons/App/Bsky/Actor/GetProfilesTest.php | 1 - tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php | 4 ---- tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php | 1 - tests/Unit/Responses/BaseResponseTest.php | 2 +- 9 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/Collections/ActorCollection.php b/src/Collections/ActorCollection.php index 8122595..a4cf374 100644 --- a/src/Collections/ActorCollection.php +++ b/src/Collections/ActorCollection.php @@ -9,6 +9,6 @@ class ActorCollection extends GenericCollection { public function __construct(iterable $collection = []) { - parent::__construct(new StringType, $collection); + parent::__construct(new StringType(), $collection); } -} \ No newline at end of file +} diff --git a/src/Lexicons/App/Bsky/Graph/GetFollowers.php b/src/Lexicons/App/Bsky/Graph/GetFollowers.php index ffb0aa7..2c62509 100644 --- a/src/Lexicons/App/Bsky/Graph/GetFollowers.php +++ b/src/Lexicons/App/Bsky/Graph/GetFollowers.php @@ -9,7 +9,6 @@ use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\APIRequest; use Atproto\Lexicons\Traits\AuthenticatedEndpoint; -use Atproto\Lexicons\Traits\Endpoint; use Atproto\Responses\App\Bsky\Graph\GetFollowersResponse; class GetFollowers extends APIRequest implements LexiconContract diff --git a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php index ab71af2..72e83a6 100644 --- a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php +++ b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php @@ -4,7 +4,6 @@ use Atproto\Contracts\LexiconContract; use Atproto\Lexicons\Traits\Lexicon; -use Atproto\Lexicons\Traits\Serializable; abstract class FeatureAbstract implements LexiconContract { diff --git a/src/Responses/App/Bsky/Actor/GetProfilesResponse.php b/src/Responses/App/Bsky/Actor/GetProfilesResponse.php index 05a55b2..617d0e4 100644 --- a/src/Responses/App/Bsky/Actor/GetProfilesResponse.php +++ b/src/Responses/App/Bsky/Actor/GetProfilesResponse.php @@ -3,8 +3,8 @@ namespace Atproto\Responses\App\Bsky\Actor; use Atproto\Contracts\Resources\ResponseContract; -use Atproto\Responses\Objects\ProfilesObject; use Atproto\Responses\BaseResponse; +use Atproto\Responses\Objects\ProfilesObject; use Atproto\Traits\Castable; class GetProfilesResponse implements ResponseContract diff --git a/src/Responses/App/Bsky/Graph/GetFollowersResponse.php b/src/Responses/App/Bsky/Graph/GetFollowersResponse.php index 197ed3f..7949368 100644 --- a/src/Responses/App/Bsky/Graph/GetFollowersResponse.php +++ b/src/Responses/App/Bsky/Graph/GetFollowersResponse.php @@ -3,10 +3,10 @@ namespace Atproto\Responses\App\Bsky\Graph; use Atproto\Contracts\Resources\ResponseContract; +use Atproto\Responses\BaseResponse; use Atproto\Responses\Objects\BaseObject; use Atproto\Responses\Objects\FollowersObject; use Atproto\Responses\Objects\SubjectObject; -use Atproto\Responses\BaseResponse; use Atproto\Traits\Castable; class GetFollowersResponse implements ResponseContract diff --git a/tests/Feature/Lexicons/App/Bsky/Actor/GetProfilesTest.php b/tests/Feature/Lexicons/App/Bsky/Actor/GetProfilesTest.php index 4b55db2..767d70c 100644 --- a/tests/Feature/Lexicons/App/Bsky/Actor/GetProfilesTest.php +++ b/tests/Feature/Lexicons/App/Bsky/Actor/GetProfilesTest.php @@ -4,7 +4,6 @@ use Atproto\Client; use Atproto\Collections\ActorCollection; -use Atproto\Lexicons\App\Bsky\Actor\GetProfiles; use Atproto\Responses\App\Bsky\Actor\GetProfilesResponse; use Atproto\Responses\Objects\ProfileObject; use Atproto\Responses\Objects\ProfilesObject; diff --git a/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php index 786eb37..64a9c42 100644 --- a/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php +++ b/tests/Feature/Lexicons/Com/Atproto/Repo/CreateRecordTest.php @@ -5,14 +5,10 @@ use Atproto\Client; use Atproto\Contracts\Lexicons\App\Bsky\Feed\PostBuilderContract; use Atproto\DataModel\Blob\Blob; -use Atproto\Exceptions\BlueskyException; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\ImageCollection; use Atproto\Lexicons\App\Bsky\Embed\Image; use Atproto\Lexicons\App\Bsky\RichText\RichText; -use Atproto\Lexicons\App\Bsky\RichText\Link; -use Atproto\Lexicons\App\Bsky\RichText\Mention; -use Atproto\Lexicons\App\Bsky\RichText\Tag; use Atproto\Lexicons\Com\Atproto\Repo\CreateRecord; use Atproto\Support\Arr; use Atproto\Support\FileSupport; diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php index cf07f59..f041b18 100644 --- a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php +++ b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php @@ -3,7 +3,6 @@ namespace Tests\Unit\Lexicons\App\Bsky\RichText; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; -use ReflectionException; use Tests\Supports\Reflection; trait FeatureTests diff --git a/tests/Unit/Responses/BaseResponseTest.php b/tests/Unit/Responses/BaseResponseTest.php index bf0d116..f6bb926 100644 --- a/tests/Unit/Responses/BaseResponseTest.php +++ b/tests/Unit/Responses/BaseResponseTest.php @@ -5,8 +5,8 @@ use Atproto\Contracts\Resources\ObjectContract; use Atproto\Contracts\Resources\ResponseContract; use Atproto\Exceptions\Resource\BadAssetCallException; -use Atproto\Responses\Objects\BaseObject; use Atproto\Responses\BaseResponse; +use Atproto\Responses\Objects\BaseObject; use Atproto\Traits\Castable; use PHPUnit\Framework\TestCase;