diff --git a/.editorconfig b/.editorconfig index ac76fc5..ce78596 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,8 +5,4 @@ end_of_line=lf indent_style=tab indent_size=4 charset=utf-8 -trim_trailing_whitespace=true - -[*.yml] -indent_style=space -indent_size=2 \ No newline at end of file +trim_trailing_whitespace=true \ No newline at end of file diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..34cadb3 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,49 @@ +name: Test coverage + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + php-version: [8.2, 8.3, 8.4] + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run static analysis + run: make analyze + + - name: Run test suite + run: make coverage + + - uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + files: ./build/logs/clover.xml + #flags: unittests # optional + #name: codecov-umbrella # optional + #fail_ci_if_error: true # optional (default = false) + #verbose: true # optional (default = false) \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7a2d0d1..c2c652c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -.idea -.phpunit.result.cache vendor/ build/ +.phpunit.cache/ +.vscode/ composer.lock \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5714b70..0000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: php -php: - - '7.3' - - '7.4' - - '8.0' - -install: - - composer install - -script: - - make test - -after_success: - - travis_retry php vendor/bin/php-coveralls \ No newline at end of file diff --git a/LICENSE b/LICENSE index 9807294..ae616bf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 nimbly.io +Copyright (c) 2025 nimbly.io Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 45f1a68..a52aa4e 100644 --- a/README.md +++ b/README.md @@ -1,787 +1,431 @@ # ActiveResource -Framework agnostic PHP ActiveResource implementation. -Use a RESTful resource based API like a database in an ActiveRecord pattern. +[![Latest Stable Version](https://img.shields.io/packagist/v/nimbly/activeresource.svg?style=flat-square)](https://packagist.org/packages/nimbly/activeresource) +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/nimbly/activeresource/coverage.yml?style=flat-square)](https://github.com/nimbly/Capsule/actions/workflows/coverage.yml) +[![Codecov branch](https://img.shields.io/codecov/c/github/nimbly/activeresource/master?style=flat-square)](https://app.codecov.io/github/nimbly/Capsule) +[![License](https://img.shields.io/github/license/nimbly/activeresource.svg?style=flat-square)](https://packagist.org/packages/nimbly/activeresource) -## Author's note -This project was started because I could not seem to find a good, maintained, and easy to use PHP based -ActiveResource package. Even though it's still in its infancy, I have personally used it on two -separate projects interacting with two completely different APIs: one built and maintained by me and -the other a 3rd party. +Use a RESTful API in an ActiveRecord pattern. -I hope you will find it as useful as I have. +## Requirements -If you have any suggestions or potential feature requests, feel free to ping me [brent@brentscheffler.com](brent@brentscheffler.com) +* PHP 8.2+ +* ext-json +* RMM Level 2 compliant API (see [Richardson Maturity Model](https://martinfowler.com/articles/richardsonMaturityModel.html)) -## Installation +## Install + +```bash +composer require nimbly/activeresource +``` - composer require nimbly/activeresource - ## Quick start -This quick start guide assumes the API: - -1. Accepts and responds JSON (application/json) -2. Uses HTTP response codes to indicate response status (200 OK, 404 Not Found, 400 Bad Request, etc) -3. Is resource based - you interact with nouns (resources), not verbs - -If the API you are working with doesn't have these assumptions, that's okay, just be sure to read the documentation -for full configuration options, custom `Response` and `Error` classes, and Middleware. - -#### Create the connection - -```php - - $options = [ - Connection::OPTION_BASE_URI => 'https://someapi.com/v1/', - Connection::OPTION_DEFAULT_HEADERS => [ - 'Authorization' => 'Bearer MYAPITOKEN', - ] - ]; - - $connection = new Connection($options); -``` - -#### Add connection to ConnectionManager - -```php - - ConnectionManager::add('default', $connection); -``` - -#### Create your models - -```php - - use ActiveResource\Model; - - /** - * Because the class name is "Users", ActiveResource assumes the API endpoint for this resource is "users". - */ - class Users extends Model - { - /** - * The single user object can be found at $.data.user - * - * Sample response payload: - * - * { - * "data": { - * "user": { - * "id": "1", - * "name": "Foo Bar", - * "email": "foo@bar.com" - * } - * } - * } - * - * - */ - protected function parseFind($payload) - { - return $payload->data->user; - } - - protected function parseAll($payload) - { - return $payload->data->users; - } - - } - - - /** - * Because the class name is "Posts", ActiveResource assumes the API endpoint for this resource is "posts". - */ - class Posts extends Model - { - // Manually set the endpoint for this resource. Now ActiveResource will hit "blogs" when making calls - // from this model. - protected $resourceName = 'blogs'; - - - /** - * A blog post has an author object embedded in the response that - * maps to a User object. - */ - protected function author($data) - { - return $this->includesOne(Users::class, $data); - } - - protected function parseFind($payload) - { - return $payload->data->post; - } - - protected function parseAll($payload) - { - return $payload->data->posts; - } - - } - - class Comments extends Model - { - - /** - * A comment has an author object embedded in the response that - * maps to a User object. - */ - protected function author($data) - { - return $this->includesOne(Users::class, $data); - } - - - protected function parseFind($payload) - { - return $payload->data->comment; - } - - protected function parseAll($payload) - { - return $payload->data->comments; - } - - } -``` - -#### Use your models - -```php - - $user = new User; - $user->name = 'Brent Scheffler'; - $user->email = 'brent@brentscheffler.com'; - $user->save(); - - $post = new Posts; - $post->title = 'Blog post'; - $post->body = 'World\'s shortest blog post'; - $post->author_id = $user->id; - $post->save(); - - // Update the author (user) - $post->author->email = 'brent@nimbly.io'; - - // Oops, save failed... Wonder what happened. - if( $post->author->save() == false ) - { - // Looks like that email address is already being used - $code = $post->getResponse()->getStatusCode(); // 409 - $error = $post->getResponse()->getStatusPhrase(); // Conflict - } - - // Get user ID=1 - $user = User::find(1); - - // Update the user - $user->status = 'inactive'; - $user->save(); - - // Get all the users - $users = Users::all(); - - // Pass in some query params to find only active users - $users = Users::all(['status' => 'active']); - - // Delete user ID=1 - $user->destroy(); - - // Get the response code - $statusCode = $user->getResponse()->getStatusCode(); // 204 No Content - - // Pass in a specific header for this call - $post = Posts::all([], ['X-Header-Foo' => 'Bar']); - - // Get blog post ID=1 - $post = Posts::find(1); - - // Get all comments through Posts resource. The effective query would be GET#/blogs/1/comments - $comments = Comments::allThrough($post); - - // Or... - $comments = Comments::allThrough("blogs/1"); -``` - -#### That's it -That's all there really is to using ActiveResource. Hopefully your API is mostly [RMM Level 2](https://martinfowler.com/articles/richardsonMaturityModel.html#level2) - making configuration a breeze. - -## Configuration -ActiveResource lets you connect to any number of RESTful APIs within your code. - -1. Create a `Connection` instance -2. Use `ConnectionManager::add` to assign a name and add the `Connection` to its pool of connections. - -### Connection -Create a new `Connection` instance representing a connection to an API. The constructor takes two parameters: - -##### Options -The options array may contain: - -`defaultUri` *string* The default URI to prepend to each request. For example: `http://some.api.com/v2/` - -`defaultHeaders` *array* Key => value pairs of headers to include with every request. - -`defaultQueryParams` *array* Key => value pairs to include in the URL query part with every request. - -`defaultContentType` *string* The default Content-Type request header to use for requests that include a message body -(PUT, POST, PATCH). Defaults to `application/json`. Some common content type strings are available as class constants on -the `Connection` class. - -`responseClass` *string* Class name of the Response class to use for parsing responses including headers and body. Default -is `ActiveResource\Response` class. See Response section for more info. - -`collectionClass` *string* Class name of a Collection class to use for handling arrays of models returned in a response. -The Collection class must accept an array of data in its constructor. Default is `ActiveResource\Collection` class. -Set to `null` to return a simple PHP array of model instances. +### Define your API connection -This option is useful if you're working with a framework, library, or custom code that has a robust Collection utility like -Laravel's `Illuminate\Support\Collection`. +```php +$connection = new Connection( + host: "https://api.example.com", + headers: [ + "Content-Type" => "application/json", + "Authorization" => "Bearer {$token}" + ] +); +``` -`updateMethod` *string* HTTP method to use for updates. Defaults to `put`. +### Add the Connection -`updateDiff` *boolean* Whether ActiveResource can send just the modified fields of the resource on an update. +```php +ConnectionManager::init(["default" => $connection]); +``` -`middleware` *array* An array of middleware classes to execute. See Middleware section for more info. - -`log` *boolean* Tell ActiveResource to log all requests and responses. Defaults to `false`. Do not use this option -in production environments. You can access the log via the Connection getLog() method via the ConnectionManager. - - -All of the above string options are available as class constants on the `Connection` class. - -##### HttpClient -An optional instance of `GuzzleHttp\Client`. If you do not provide an instance, one will be created automatically -with no options set. - -###### Example +### Define your first Resource ```php +class Book extends Resource +{} +``` + +### Retrieve a record - $options = [ - Connection::OPTION_BASE_URI => 'http://api.someurl.com/v1/', - Connection::OPTION_UPDATE_METHOD => 'patch', - Connection::OPTION_UPDATE_DIFF => true, - Connection::OPTION_RESPONSE_CLASS => \My\Custom\Response::class, - Connection::OPTION_MIDDLEWARE => [ - \My\Custom\Middleware\Authorize::class, - \My\Custom\Middleware\Headers::class, - ] - ]; - - $connection = new \ActiveResource\Connection($options); +```php +$book = Book::find("123"); ``` -### ConnectionManager +ActiveResource will attempt to retrieve (`GET`) the `book` resource from the API with the following: `https://api.example.com/book/123`. -Use `ConnectionManager::add` to add one or more Connection instances. This allows you to use ActiveResource with any -number of APIs within your code. If you interact mostly with a single API, you can set the name to `default` without -needing to specify the connection name on each of your models. +If found, a fully hydrated `Book` class instance is returned using the response from the API. -If you *do* need to interact with multiple APIs, be sure to give them distinct connection names. You'll likely want to -create an abstract BaseModel with the connectionName property set and extend your actual models from the BaseModel. +### Get all records -###### Example - ```php +$books = Book::all(); - ConnectionManager::add('yourConnectionName', $connection); +foreach( $books as $book ){ + echo $book->title . "\n"; +} ``` +### Create a new record -## Response -Although ActiveResource comes with a basic Response class (that simply JSON decodes the response body), each and every -API responds with its own unique payload and encoding and it is recommended you provide your own custom response class that -extends `\ActiveResource\ResponseAbstract`. See Connection option `responseClass`. +```php +$book = new Book([ + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Philip K Dick", +]); +``` -### Required method implementation -`decode` Accepts the raw payload contents from the response. Should return an array or \StdClass -object representing the data. See Expected Data Format for more details. +### Save a record -`isSuccessful` Should return a boolean indicating whether the request was successful or not. Some APIs -do not adhere to strict REST patterns and may return an HTTP Status Code of 200 for all requests. In this -case there is usually a property in the payload indicating whether the request was successful or not. - -The Response object is also a great way to include any other methods to access non-payload -related data or headers. It all depends on what data is in the response body for the API you -are working with. +```php +$book->publisher = "Penguin"; +$book->published_at = "1983-11-12"; +$book->save(); +``` -###### Example +### Delete a record ```php - - class Response extends \ActiveResource\ResponseAbstract - { - public function decode($payload) - { - return json_decode($payload); - } - - public function isSuccessful() - { - return $this->getStatusCode() < 400; - } - - public function getMeta() - { - return $this->getPayload()->meta; - } - - public function getEnvelope() - { - return $this->getPayload()->envelope; - } - } +$book->delete(); ``` - -## Expected data format -In order for ActiveResource to properly hydrate your Model instances, the decoded response payload must be formatted in - the following pattern: - -```json - - { - "property1": "value", - "property2": "value", - "property3": "value", - "related_single_resource": { - "property1": "value", - "property2": "value" - }, - "related_multiple_resources": [ - { - "property1": "value", - "property2": "value" - } - ] - } -``` - -###### Example +## Connection -```json +The `Connection` instance represents a single specific API integration. You must pass the hostname and base URI (if any). In addition to the host name, you can also specify default headers and query parameters to be included with each request, and other options that alter how the underlying API calls should be made. - { - "id": "1234", - "title": "Blog post", - "body": "This is a blog post", - "author": { - "id": "32135", - "name": "John Doe", - "email": "jdoe@example.com" - }, - "comments": [ - { - "id": "18319", - "body": "This is a comment", - "author": { - "id": "49913", - "name": "Jane Doe", - "email": "jane.doe@example.com" - } - }, - - { - "id": "18320", - "body": "This is another comment", - "author": { - "id": "823194", - "name": "Thomas Quigley", - "email": "tquigley@example.com" - } - } - ] - } +```php +$connection = new Connection( + host: "https://api.example.com/v1/", + headers: [ + "Content-Type" => "application/json", + "Authorization" => "Bearer {$token}" + ], + options: [ + Connection::UPDATE_METHOD => "patch", + Connection::UPDATE_DIFF => true, + ] +); ``` -If the API you are working with does not have its data formatted in this manor - you will need to transform it so that it is. -This can (and should) be done in your `Response` class `decode` method. +### Options -## Models -Create your model classes and extend them from `\ActiveResource\Model`. +`Connection::OPTION_CREATE_METHOD` (string) The HTTP method to use when creating new resources. Defaults to `POST`. +`Connection::OPTION_UPDATE_METHOD` (string) The HTTP method to use when updating resources. Defaults to `PUT`. +`Connection::OPTION_UPDATE_DIFF` (boolean) Set to true if the API allows only sending the properties that have changed when updating. Defaults to `false`. +`Connection::OPTION_HTTP_VERSION` (string) The HTTP version to use when making API calls. Defaults to `1.1`. +`Connection::OPTION_SERIALIZER` => (callable) The callable to use when serializing request body data. Defaults to `json_encode`. +`Connection::OPTION_DESERIALIZER` => (callable) The callable to use when deserializing response body data. Defaults to `json_decode`. -##### Properties -`connectionName` Name of connection to use. Defaults to `default`. +## Connection Manager -`resourceName` Name of the API resource URI. Defaults to lowercase name of class. +The `ConnectionManager` manages your various connections and issues the underlying HTTP calls, serialization, deserialization, and event dispatching when saving and deleting resources. -`resourceIdentifier` Name of the property to use as the ID. Defaults to `id`. +ActiveResource uses PSR-7 and PSR-17 instances to issue HTTP calls and handle the responses. You can bring your own implementations (eg, Guzzle). If none are provided, `nimbly/Shuttle` and `nimbly/Capsule` will be used. -`readOnlyProperties` Array of property names that are read only. When set to null or empty array, all properties are writable. +In addition to PSR-7 and PSR-17, a PSR-18 instance can be provided to dispatch events during the save and deletion lifecycle. + +```php +ConnectionManager::init( + connections: [ + "default" => $connection1, + "other" => $connection2, + ], + httpClient: $httpClient, + requestFactory: $factory, + streamFactory: $factory, + eventDispatcher: $dispatcher, +); +``` -`fillableProperties` When set to array of property names, only these properties are allowed to be mass assigned when calling the fill() method. -If null, *all* properties can be mass assigned. +## Resources -`excludedProperties` Array of property names that are excluded when saving/updating model to API. If null or empty array, all properties can be sent when saving model. +### Retrieving a single resource -##### Static methods -`find` Find a single instance of a resource given its ID. Assumes payload will return *single* object. +```php +$book = Book::find($id); +``` -`all` Get all instances of a resource. Assumes payload will return an *array* of objects. +### Retrieving multiple resources -`delete` Destroy (delete) a resource given its ID. +```php +$books = Book::all(); +``` -`findThrough` Find a resource *through* another resource. For example, if you have to retrieve -a comment through its post `/posts/1234/comments/5678`. +A call to `all` will return a `ResourceCollection` instance that contains an array of the resources but also any metadata that was returned in the response as well as the response headers. The `ResourceCollection` implements `ArrayAccess`, `Iterator`, and `Countable` and can be used in `for` or `foreach` loops and calls to `count()`. -`allThrough` Get all instances of a resource *through* another resource. For example, if you have -to retrieve comments through its post `/posts/1234/comments`. +```php +$books = Book::all(); -`connection` Get the model's `Connection` instance. +echo "There are " . count($books) . " in this response."; -`request` Get the last request object. +foreach( $books as $book ){ + // ... +} +``` -`response` Get the last response object. +Unless the API directly returns an array of the resources, ActiveResource will need to know where to find the actual resources in the response data, you will need to add a `#[CollectionProperty]` attribute on the class. -##### Instance methods -`fill` Mass assign object properties with an array of key/value pairs. +```php +#[CollectionProperty("results")] +class Book extends Resource +{} +``` -`save` Save or update the instance. +To retrieve response metadata, simply call the `getMeta` on the `ResourceCollection` instance. -`destroy` Destroy (delete) the instance. +```php +$books = Book::all(); -`getConnection` Get the model's `Connection` instance. +$books->getMeta("current_page"); +$books->getMeta("total_pages"); +``` -`getRequest` Get the `Request` object for the last request. +### Creating -`getResponse` Get the `Response` object for the last request. +To create a new instance, simply instantiate the class, assign values (either in the constructor or directly), and call the `save()` method. -`includesOne` Tells the Model class that the response includes a single instance of another -model class. ActiveResource will then create an instance of the model and hydrate with the data. +```php +$book = new Book([ + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Philip K Dick" +]); -`includesMany` Tells the Model class that the response includes an array of instances of another -model class. ActiveResource will then create a Collection of hydrated model instances. +$book->save(); +``` -`parseFind` Tells the Model class where in the response payload to look for the data for a single resource. This method - is called when using the `find` and `findThrough` static methods and the `save` instance method. The `parseFind` method accepts - a single parameter containing the decoded payload and should return an object or an associative array - containing the instance data. If you do not specify this method on your model, ActiveResource will pass the - full payload to hydrate the model. Unless the API you are working with returns all relevant data in the root - of the response, you *must* implement this method. See Expected Data Format for more information. +### Updating -`parseAll` Tells the Model class where in the response payload to look for the data for an array of resources. This - method is called when using the `all` and `allThrough` static methods. This method accepts a single - parameter containing the decoded response payload and should return an object or an associative array - containing the instance data. If you do not specify this method on your model, ActiveResource will pass the - full payload to hydrate the model. Unless the API you are working with returns all relevant data in the root - of the response, you *must* implement this method. See Expected Data Format for more information. +To update a resource, simply retrieve the resource from the API, make your changes, and call the `save()` method. -`encode` Called before sending a request to format and encode the model instance into a request body. Defaults to json_encode(). -You should override this method if you need a different format or encoding for the API you are working with. +```php +$book = Book::find("123"); +$book->published_at = "2000-03-24"; +$book->save(); +``` -`reset` Resets the state of the model to its original condition - i.e. all modified properties are reverted. +### Loading a resource from cache -`original` Returns the original value of a property. +You can take a cached or shallow copy version of your resource and load it directly by calling the `make` method. If the cached version has an identifier set, subsequent calls to `save()` will update it. If no identifier exists, ActiveResource will assume it needs to be created when calling `save()`. +```php +$book = Book::make($cache); +$book->published_at = "2000-03-24"; +$book->save(); +``` -You can also define `public` methods with the same name as an instance property that the model will send the data to. -You can then modify the data or more commonly, create a new model instance representing the data. - -For example, say you are interacting with a blog API that has blog posts, users, and comments. You create the three model -classes representing the API resources. +### Fillable -###### Users +In order to bulk load values into a resource, you must define which fields *can* be bulk filled. Use the `$fillableProperties` class property to declare property names that can be bulk filled. ```php - - class Users extends \ActiveResource\Model - { - } +class Book extends Resource +{ + protected array $fillableProperties = ["isbn", "title", "author"]; +} ``` - -###### Comments + +Load values directly in constructor... ```php +$book = new Book([ + "isbn" => "123123313", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Philip K Dick" +]); - class Comments extends \ActiveResource\Model - { - public function author($data) - { - return $this->includesOne(Users::class, $data); - } - } +$book->save(); ``` -###### Posts +Update values with `fill`... ```php - - class Posts extends \ActiveResource\Model - { - public function author($data) - { - return $this->includesOne(Users::class, $data); - } - - public function comments($data) - { - return $this->includesMany(Comments::class, $data); - } - - /** - * You can find the blog post data in $.data.post in the payload - */ - protected function parseFind($payload) - { - return $payload->data->post; - } - - /** - * You can find the collection of post data in $.data.posts in the payload - */ - protected function parseAll($payload) - { - return $payload->data->posts; - } - } +$book->fill($request->getParsedBody()); +$book->save(); ``` -Now grab blog post ID 7. - +You can always directly set property values, regardless of whether they are listed in the `fillableProperties` array or not. + ```php +class Book extends Resource +{ + protected array $fillableProperties = ["isbn", "title", "author"]; +} +``` - $posts = Posts::find(7); +```php +$book = new Book; +$book->published_at = "2000-03-24"; ``` - -The response from the API looks like: -```json +### Custom setters and getters + +You can create custom setters and getters that will be invoked when accessing or assigning resource properties. Simply name them as `get{Name}Property` and `set{Name}Property` where `{Name}` is the name of the property. Both methods will be passed the raw unmodified value and you must return the modified value. - { - "data": { - "post": { - "id": 7, - "title": "Blog post", - "body": "I am a short blog post", - "author": { - "id": 123, - "name": "John Doe", - "email": "jdoe@example.com" - }, - "created_at": "2016-12-03 15:36:12", - "comments": [ - { - "id": 8, - "body": "Great article!", - "author": { - "id": 567, - "name": "Thomas Quigley", - "email": "tquigley@example.com" - }, - "created_at": "2016-12-04 09:18:45" - }, - - { - "id": 9, - "body": "Love the way your write", - "author": { - "id": 4178, - "name": "Jane Johnson", - "email": "jjohnson@example.com" - }, - "created_at": "2016-12-04 11:29:18" - } - ] - } - } - } -``` - -ActiveResource will automatically hydrate model instances for comments and authors (users) on the Posts instance. These -instances can then be modified and updated or even deleted. - -## Middleware - -Middleware in ActiveResource is managed by the excellent [Onion](https://github.com/esbenp/onion) package - "a standalone middleware library without dependencies". - -Your middleware classes must implement Onion's LayerInterface class and implement the `peel` method. - -The input object is an `ActiveResource\Request` instance. The output is an instance of `ActiveResource\ResponseAbstract`. - -###### Example - ```php - - class Authorize implements LayerInterface - { - /** - * - * @param \ActiveResource\Request $object - */ - public function peel($object, \Closure $next) - { - // Add a query param to the URL (&foo=bar) - $object->setQuery('foo', 'bar'); - - // Do some HMAC authorization logic here - // ... - // ... - - // Now add the HMAC headers - $object->setHeader('X-Hmac-Timestamp', $timestamp); - $object->setHeader('Authorization', "HMAC {$hmac}"); - - // Send the request off to the next layer - $response = $next($object); - - // Now let's slip in a spoofed header into the response object - $response->setHeader('X-Spoofed-Response-Header', 'Foo'); - - // How about we completely change the response status code? - $response->setStatusCode(500); - - // Return the response - return $response; - } - } +protected function getPublishedAtProperty(string $date): DateTime +{ + return new DateTime($date); +} ``` - -## Logging -You can activate request and response logging of every ActiveResource call by enabling the `log` option on a `Connection`. -To access the log data, call the `getLog` method on the connection. Due to memory footprint and security reasons, *do not* -use logging in production environments. +```php +protected function setPasswordProperty(#[SensitiveParameter] string $password): string +{ + return \password_hash($password, PASSWORD_BCRYPT); +} +``` -###### Example +**NOTE:** These methods cannot be `private` (i.e. they must be `protected` or `public`.) -```php - $connection = new Connection([ - Connection::OPTION_BASE_URI => 'https://someurl.com/v1/', - Connection::OPTION_LOG => true, - ]); - - ConnectionManager::add('yourConnectionName', $connection); - - $post = Post::find(12); +### Computed values - $connection = ConnectionManager::get('yourConnectionName'); - $log = $connection->getLog(); - - // Or... +You can use a custom getter to provide computed values that have no base or raw value themselves by not providing any function parameters. - - $post->getConnection()->getLog(); +```php +class Book extends Resource +{ + protected function getAgeProperty(): int + { + return (new DateTime)->diff(new DateTime($this->created_at))->y; + } +} +``` - // Or... - - Post::connection()->getLog(); +```php +$book = Book::find("123"); +echo $book->age; ``` -## Quick Start Examples +### Excluded properties -### Find a single resource +Sometimes you need class properties that should be excluded from being sent with API requests when saving or updating. The `Resource` class provides an `$excludedProperties` property to accomplish this. Excluded properties do not prevent the property from being consumed when retrieving records from the API. Excluded properties can still use custom getters and setters. ```php - - $user = User::find(123); +class Book extends Resource +{ + protected array $excludedProperties = ["age"]; + + protected function getAgeProperty(): int + { + return (new DateTime)->diff(new DateTime($this->published_at))->y; + } +} ``` -### Get all resources +### Resource Attributes -```php +It's always better to adhere to convention over configuration, but ActiveResource does provide class attributes that can override convention. - $users = User::all(); -``` +#### Connection name -### Creating a new resource +By default, ActiveResource will attempt to use the `default` connection from the connection manager. However, if you would like to use a different connection, you can add the `#[ConnectionName]` class attribute to your resource. ```php - - $user = new User; - $user->name = 'Test User'; - $user->email = 'test@example.com'; - $user->save(); +#[ConnectionName("segment")] +class User extends Resource +{} ``` - -### Updating a resource -```php +### Resource name - $user = User::find(123); - $user->status = 'INACTIVE'; - $user->save(); -``` - -### Quickly assign properties +By default, ActiveResource will use the lower case name of the class as the resource name. However, you can override this behavior by using the `#[ResourceName]` class attribute. ```php - - $user = User::find($id); - $user->fill([ - 'name' => 'Buckley', - 'email' => 'buckley@example.com', - ]); - $user->save(); +#[ResourceName("library_books")] +class Book extends Resource +{} ``` - -### Destroy (delete) a resource -```php +### Resource identifier + +By default, ActiveResource assumes the identifier of the resource is contained within the `id` property of the response. You can override this behavior by using the `#[ResourceIdenfitier]` class attribute. - $user = User::find($id); - $user->destory(); - - // Or... - - User::delete($id); +```php +#[ResourceIdentifier("isbn")] +class Book extends Resource +{} ``` -## FAQ -##### How do I send an Authorization header with every request? -If the Authorization scheme is either Basic or Bearer, the easiest way to add the header is in the -`defaultHeaders` option array when creating the Connection object. +### Collection property + +Many APIs return a slightly different response body when retrieving multiple records. For example, the response may contain some meta data about the page number, the total number of available records, and finally a property that actually contains the records themselves. + +For example, we make a call to get all the records of books... -###### Example - ```php +$books = Book::all(); +``` - $options = [ - Connection::OPTION_BASE_URI => 'http://myapi.com/v2/', - Connection::OPTION_DEFAULT_HEADERS => [ - 'Authorization' => 'Bearer MYAPITOKEN', - ], - ]; +And are returned the following payload (which is typical for paginated results)... - $connection = new Connection($options); +```json +{ + "count": 3, + "page": 1, + "total": 235, + "results": [ + { + "id": 123, + "title": "Do Androids Dream of Electric Sheep?", + "author": "Philip K Dick" + }, + + { + "id": 345, + "title": "Breakfast of Champions", + "author": "Kurt Vonnegut" + }, + + { + "id": 256, + "title": "Less Than Zero", + "author": "Bret Easton Ellis" + } + ] +} ``` - -For Authorization schemas that are a bit more complex (eg HMAC), use a Middleware approach. See the Middleware section -for more information. -##### The API response payload I am working with has all its data returned in the same root path. Do I really need to have a parseFind and parseAll method on every model? -No, you don't. Create an abstract BaseModel class with the `parseFind` and `parseAll` methods. Then extend -all your models from that BaseModel. +We need to let ActiveResource know to look in the `results` property for the actual returned resources. -##### How do I handle JSON-API responses? -In your `Response` object `decode` method you'll need to do a lot of work, but it can be done. ActiveResource -is looking for the decoded payload data to be in a specific format. See Expected Data Format for more information. For -requests that need to be in JSON-API format, you'll need to do a lot of work in the Model `encode` method. +```php +#[CollectionProperty("results")] +class Book extends Resource +{} +``` -##### How do I access the response object to pull out headers, status code, or parse and error payload? -You can access the `Response` object for the last API request via the Model's `getResponse` instance method. -The `Response` object has methods for retrieving response headers, status, and body. Alternatively, you can also access -the response object statically via the model's `response` static method. +## Response headers -##### How can I throw an exception on certain HTTP response codes? -The `Response` object has a protected array property called `throwable`. By default, HTTP Status 500 will throw an -`ActiveResourceResponseException`. You can override the array in your `Response` class with any set of HTTP status -codes you want. Or make it an empty array to *never* throw an exception. +Sometimes, it's important to capture response headers from the API. ActiveResource will attach the response headers to the retrieved `Resource` or `ResourceCollection`. -Connection issues including timeouts will *always* throw a `GuzzleHttp\Exception\ConnectException`. +```php +$book = Book::find($id); -##### The API I am working with has an endpoint that simply does not conform to the ActiveResource pattern, how can I call the endpoint? -You can send a custom request by getting the `Connection` object instance and using the `buildRequest` and `send` -methods. +if( $book->getResponseHeader("X-Foo") === "bar" ) { + //... +} +``` ```php +$book = Book::find($id); - $connection = ConnectionManager::get('yourConnectionName'); - $request = $connection->buildRequest('post', '/some/oddball/endpoint', ['param1' => 'value1'], ['foo' => 'bar', 'fox' => 'sox'], ['X-Custom-Header', 'Foo']); - $response = $connection->send($request); +$headers = $book->getResponseHeaders(); ``` - -You'll get an instance of a `ResponseAbstract` object back. \ No newline at end of file + +For calls to `all()`, the response headers will be attached to the `ResourceCollection` instance. + +```php +$books = Book::all(); + +if( $books->getResponseHeader("X-Foo") === "bar" ){ + //... +} +``` + +## Events + +You can tap into the lifecycle events for saving and deleting resources by providing a PSR-X Event Dispatcher instance and subscribing to any of the following: + +`ResourceSavingEvent` is triggered just before the API call to save/update the resource. +`ResourceSavedEvent` is triggered after the resource has been succesfully saved. +`ResourceDeletingEvent` is triggered just before the API cal to delete the resource. +`ResourceDeletedEvent` is triggered after the resource has been successfully deleted. \ No newline at end of file diff --git a/composer.json b/composer.json index 3206278..8c3aac8 100644 --- a/composer.json +++ b/composer.json @@ -1,41 +1,32 @@ { - "name": "nimbly/activeresource", - "description": "Use a RESTful resource based API like a database", - "keywords": [ - "rest", - "api", - "php", - "model", - "resource", - "database", - "activerecord", - "active", - "record" - ], - "license": "MIT", - "authors": [ - { - "name": "Brent Scheffler", - "email": "brent@brentscheffler.com" - } - ], - "autoload": { - "psr-4": { - "ActiveResource\\": "src/" - } - }, + "name": "nimbly/activeresource", + "description": "Use a RESTful API in an ActiveRecord pattern.", + "license": "MIT", "require": { - "php": ">=7.3|^8.0", - "guzzlehttp\/guzzle": "^6.2", - "optimus\/onion": "^1.0" - }, - "autoload-dev": { + "php": "^8.2", + "psr/http-client": "^1.0", + "psr/event-dispatcher": "^1.0", + "nimbly/shuttle": "^2.0", + "nimbly/capsule": "^3.0", + "nimbly/announce": "^2.0" + }, + "suggest": { + "ext-curl": "Needed to use cURL based HTTP clients.", + "ext-json": "Needed to encode and decode JSON request and responses." + }, + "autoload": { "psr-4": { - "Tests\\": "tests/" + "Nimbly\\ActiveResource\\": "src/" } }, "require-dev": { - "phpunit\/phpunit": "^9", - "vimeo/psalm": "^4.11" - } -} \ No newline at end of file + "symfony/var-dumper": "^5.0", + "phpunit/phpunit": "^10.0", + "vimeo/psalm": "^6.0" + }, + "autoload-dev": { + "psr-4": { + "Nimbly\\ActiveResource\\Tests\\": "tests/" + } + } +} diff --git a/phpunit.xml b/phpunit.xml index c3b761c..8aa9426 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,21 @@ - - - - src - - + tests + + + src + + diff --git a/psalm.xml b/psalm.xml index 7c0333d..1f2cff2 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,10 +1,12 @@ diff --git a/src/ActiveResourceException.php b/src/ActiveResourceException.php deleted file mode 100644 index 8803cf5..0000000 --- a/src/ActiveResourceException.php +++ /dev/null @@ -1,8 +0,0 @@ -getStatusPhrase(), $response->getStatusCode(), null); - - $this->response = $response; - } - - public function getResponse(): ResponseAbstract - { - return $this->response; - } -} \ No newline at end of file diff --git a/src/Attributes/CollectionProperty.php b/src/Attributes/CollectionProperty.php new file mode 100644 index 0000000..5744d60 --- /dev/null +++ b/src/Attributes/CollectionProperty.php @@ -0,0 +1,40 @@ +collection_property; + } + + /** + * Get the index by identifier option. + * + * @return boolean + */ + public function getIndexByIdentifier(): bool + { + return $this->index_by_identifier; + } +} \ No newline at end of file diff --git a/src/Attributes/ConnectionName.php b/src/Attributes/ConnectionName.php new file mode 100644 index 0000000..f68717b --- /dev/null +++ b/src/Attributes/ConnectionName.php @@ -0,0 +1,24 @@ +name; + } +} \ No newline at end of file diff --git a/src/Attributes/ResourceIdentifier.php b/src/Attributes/ResourceIdentifier.php new file mode 100644 index 0000000..46cd4af --- /dev/null +++ b/src/Attributes/ResourceIdentifier.php @@ -0,0 +1,24 @@ +identifier; + } +} \ No newline at end of file diff --git a/src/Attributes/ResourceName.php b/src/Attributes/ResourceName.php new file mode 100644 index 0000000..0eed8fb --- /dev/null +++ b/src/Attributes/ResourceName.php @@ -0,0 +1,24 @@ +name; + } +} \ No newline at end of file diff --git a/src/Attributes/Through.php b/src/Attributes/Through.php new file mode 100644 index 0000000..3cdf2a1 --- /dev/null +++ b/src/Attributes/Through.php @@ -0,0 +1,24 @@ +path; + } +} \ No newline at end of file diff --git a/src/Collection.php b/src/Collection.php index c38f684..5f12be6 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -1,121 +1,216 @@ + * @inheritDoc */ - protected $objects = []; + public function current(): mixed + { + return $this->items[$this->index]; + } /** - * @var int + * @inheritDoc */ - protected $index = 0; + public function key(): mixed + { + return $this->index; + } /** - * Collection constructor. - * @param array $data + * @inheritDoc */ - public function __construct(array $data = []) + public function next(): void { - $this->objects = $data; + $this->index++; } - public function getIterator() + /** + * @inheritDoc + */ + public function rewind(): void { - return new \ArrayIterator($this->objects); + $this->index = 0; } - public function first() + /** + * @inheritDoc + */ + public function valid(): bool { - if( $this->offsetExists(0) ){ - return $this->offsetGet(0); - } - - return null; + return $this->offsetExists($this->index); } - public function toArray() + /** + * @inheritDoc + */ + public function offsetExists(mixed $offset): bool { - $objects = []; - foreach( $this->objects as $object ){ - if( $object instanceof Model ){ - $objects[] = $object->toArray(); - } - } - - return $objects; + return $offset < \count($this->items); } - public function toJson(): ?string + /** + * @inheritDoc + */ + public function offsetSet(mixed $offset, mixed $value): void { - return \json_encode($this->toArray()); + $this->items[$offset] = $value; } - public function current() + /** + * @inheritDoc + */ + public function offsetGet(mixed $offset): mixed { - return $this->objects[$this->index]; + return $this->items[$offset]; } - public function seek($position) + /** + * @inheritDoc + */ + public function offsetUnset(mixed $offset): void { - if( !($this->offsetExists($position)) ){ - throw new \OutOfBoundsException('Offset does not exist'); - } - - $this->index = $position; + unset($this->items[$offset]); } - public function next() + /** + * @inheritDoc + */ + public function count(): int { - $this->index++; + return \count($this->items); } - public function rewind() + /** + * Get the resources. + * + * @return array + */ + public function toArray(): array { - $this->index = 0; + return $this->items; } - public function count() + /** + * Filter out results from array. + * + * @param callable $callback + * @param integer $mode + * @return Collection + */ + public function filter(callable $callback, int $mode = 0): Collection { - return count($this->objects); + return new Collection( + \array_filter($this->items, $callback, $mode) + ); } - public function valid() + /** + * Map items in collection to a new value. + * + * @param callable $callback + * @return Collection + */ + public function map(callable $callback): Collection { - return (($this->index+1) > $this->count()); + return new Collection( + \array_map($callback, $this->items) + ); } - public function key() + /** + * Reduce the items to a single value. + * + * @param callable $callback + * @param mixed $initial + * @return mixed + */ + public function reduce(callable $callback, mixed $initial = null): mixed { - return $this->index; + return \array_reduce($this->items, $callback, $initial); } - public function offsetGet($offset) + /** + * Take a slice or subset of the array. + * + * @param integer $offset + * @param integer|null $length + * @param boolean $preserve_keys + * @return Collection + */ + public function slice(int $offset, ?int $length = null, bool $preserve_keys = false): Collection { - return $this->objects[$offset]; + return new Collection( + \array_slice($this->items, $offset, $length, $preserve_keys) + ); } - public function offsetExists($offset) + /** + * Find a specific item in the collection. + * + * @param string $property + * @param mixed $value + * @return mixed + */ + public function find(string $property, mixed $value): mixed { - return (array_key_exists($offset, $this->objects)); + foreach( $this->items as $item ){ + if( $item->{$property} === $value ){ + return $item; + } + } + + return null; } - public function offsetSet($offset, $value) + /** + * Index the collection based on a particular property. + * + * @param string $property + * @return Collection + */ + public function index(string $property): Collection { - if( $offset == null ){ - $this->objects[] = $value; - } - else { - $this->objects[$offset] = $value; + $results = []; + + foreach( $this->items as $item ){ + $results[$item->{$property}] = $item; } + + return new Collection($results); } - public function offsetUnset($offset) + /** + * Sort items in array with a custom function. + * + * @param callable $callback + * @return Collection + */ + public function sort(callable $callback): Collection { - unset($this->objects[$offset]); + $items = $this->toArray(); + \uasort($items, $callback); + return new Collection($items); } } \ No newline at end of file diff --git a/src/Connection.php b/src/Connection.php index b214d4b..2b2bc4a 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -1,390 +1,158 @@ value pairs to include in the headers with each request. + * HTTP method to use when creating. * - * Type: array - * Default: [] + * Default: "post" */ - const OPTION_DEFAULT_HEADERS = 'defaultHeaders'; + public const OPTION_CREATE_METHOD = "create_method"; /** - * The default Content-Type when sending requests with a body (POST, PUT, PATCH) + * HTTP method to use when updating. * - * Type: string - * Default: 'application/json' + * Default: "put" */ - const OPTION_DEFAULT_CONTENT_TYPE = 'defaultContentType'; + public const OPTION_UPDATE_METHOD = "update_method"; /** - * An array of key => value pairs to include in the query params with each request. + * Send just the properties that have changed when updating. * - * Type: array - * Default: [] - */ - const OPTION_DEFAULT_QUERY_PARAMS = 'defaultQueryParams'; - - /** - * Response class name - * - * Type: string - * Default: 'ActiveResource\\Response' - */ - const OPTION_RESPONSE_CLASS = 'responseClass'; - - /** - * Name of custom Collection class to use to pass array of models to. Class must allow passing in array of data into - * constructor. Set to NULL to return a simple Array of objects. - * - * Type: string - * Default: 'ActiveResource\\Collection' + * Default: false */ - const OPTION_COLLECTION_CLASS = 'collectionClass'; + public const OPTION_UPDATE_DIFF = "update_diff"; /** - * HTTP method to use for updates + * HTTP version to use when sending requests. * - * Type: string - * Default: 'put' + * Default: "1.1" */ - const OPTION_UPDATE_METHOD = 'updateMethod'; + public const OPTION_HTTP_VERSION = "http_version"; /** - * If the API allows you to send *just* the modified fields on update, you can set this to true to help - * speed things up by making the request body smaller. + * Serializer to use on request body when sending requests. * - * Type: boolean - * Default: false + * Default: "json_encode" */ - const OPTION_UPDATE_DIFF = 'updateDiff'; + public const OPTION_SERIALIZER = "serializer"; /** - * Array of class names to apply before each request is sent. + * Deserializer to use when parsing response. * - * Type: array - * Default: [] + * Default: "json_decode" */ - const OPTION_MIDDLEWARE = 'middleware'; + public const OPTION_DESERIALIZER = "deserializer"; /** - * Keep a log of all calls made + * Connection default options. * - * Type: boolean - * Default: false + * @var array */ - const OPTION_LOG = 'log'; - - /** - * Common API content types - */ - const CONTENT_TYPE_JSON = 'application/json'; - const CONTENT_TYPE_XML = 'application/xml'; - const CONTENT_TYPE_FORM = 'application/x-www-form-urlencoded'; - - - /** @var array */ - protected $options = [ - self::OPTION_BASE_URI => null, - self::OPTION_DEFAULT_HEADERS => [], - self::OPTION_DEFAULT_CONTENT_TYPE => self::CONTENT_TYPE_JSON, - self::OPTION_DEFAULT_QUERY_PARAMS => [], - self::OPTION_RESPONSE_CLASS => 'ActiveResource\\Response', - self::OPTION_COLLECTION_CLASS => 'ActiveResource\\Collection', - self::OPTION_UPDATE_METHOD => 'put', + protected array $default_options = [ + self::OPTION_CREATE_METHOD => "post", + self::OPTION_UPDATE_METHOD => "put", self::OPTION_UPDATE_DIFF => false, - self::OPTION_MIDDLEWARE => [], - self::OPTION_LOG => false, + self::OPTION_HTTP_VERSION => "1.1", + self::OPTION_SERIALIZER => "\json_encode", + self::OPTION_DESERIALIZER => "\json_decode", ]; - /** @var Request */ - protected $request; - - /** @var ResponseAbstract */ - protected $response; - /** - * Connection constructor. - * @param Client $httpClient - * @param array $options + * @param string $host + * @param array $headers Default headers to include with each request. + * @param array $query Default query parameters to include with each request. + * @param array $options Override default options. */ - public function __construct(array $options = [], Client $httpClient = null) + public function __construct( + protected string $host, + protected array $headers = [], + protected array $query = [], + protected array $options = []) { - if( $options ){ - foreach( $options as $option => $value ){ - $this->setOption($option, $value); - } - } - - if( !empty($httpClient) ){ - $this->setHttpClient($httpClient); - } + $this->options = \array_merge($this->default_options, $options); } /** - * Set the HTTP client for this connection + * Get the hostname for this connection. * - * @param Client $httpClient + * @return string */ - public function setHttpClient(Client $httpClient) + public function getHost(): string { - $this->httpClient = $httpClient; - } - - /** - * Set an option - * - * @param $name - * @param $value - * - * @return Connection - */ - public function setOption($name, $value) - { - if( array_key_exists($name, $this->options) ){ - $this->options[$name] = $value; - } - - return $this; - } - - /** - * Get an option - * - * @param $name - * @return mixed|null - */ - public function getOption($name) - { - if( array_key_exists($name, $this->options) ){ - return $this->options[$name]; - } - - return null; + return $this->host; } /** - * Set this connection to use the Basic authorization schema by providing the username and password. + * Get the default headers. * - * @param $username - * @param $password - * @return $this + * @return array */ - public function useBasicAuthorization($username, $password) + public function getHeaders(): array { - $this->options[self::OPTION_DEFAULT_HEADERS] = array_merge( - $this->options[self::OPTION_DEFAULT_HEADERS], - ['Authorization' => 'Basic '.base64_encode("{$username}:{$password}")] - ); - - return $this; + return $this->headers; } /** - * Set this connection to use the Bearer authorization schema by providing the bearer token. + * Get the default query params. * - * @param $token - * @return $this + * @return array */ - public function useBearerAuthorization($token) + public function getQuery(): array { - $this->options[self::OPTION_DEFAULT_HEADERS] = array_merge( - $this->options[self::OPTION_DEFAULT_HEADERS], - ['Authorization' => "Bearer {$token}"] - ); - - return $this; + return $this->query; } /** - * Build an ActiveResource Request object instance using the connection's options. - * - * This Request object will be passed through the middleware layers. + * Get a connection option value. * - * @param string $method HTTP method (get, post, put, delete, etc.) - * @param string $url - * @param array $queryParams Associative array of key=>value pairs to add to URL query - * @param string|null $body The body to send in the request - * @param array $headers Associative array of key=>value pairs to add to headers - * @return Request + * @param string $option The connection option name. + * @return mixed The connection option value. Returns `null` if option not set. */ - public function buildRequest($method, $url, array $queryParams = [], $body = null, array $headers = []) + public function getOption(string $option): mixed { - $request = new Request; - - // Set the request method - $request->setMethod(strtoupper($method)); - - // Set the URI - $request->setUrl($this->getOption(self::OPTION_BASE_URI) . $url); - - // Set the query params - $request->setQueries(array_merge($this->getOption(self::OPTION_DEFAULT_QUERY_PARAMS), $queryParams)); - - // Set the request body - $request->setBody($body); - - // Set the headers - $request->setHeaders(array_merge($this->getOption(self::OPTION_DEFAULT_HEADERS), $headers)); - - // Check for Content-Type header and set it - if( in_array($request->getMethod(), ['POST','PUT','PATCH']) && - $request->getHeader('Content-Type') === null && - ($contentType = $this->getOption(self::OPTION_DEFAULT_CONTENT_TYPE)) ){ - $request->setHeader('Content-Type', $contentType); - } - - return $request; + return $this->options[$option] ?? null; } /** - * Make the HTTP call + * Serialize a request body. * - * @param Request $request - * @throws ConnectException - * @return ResponseAbstract + * @param mixed $data + * @return string */ - public function send(Request $request) + public function serialize(mixed $data): string { - // Initialize middleware manager - $this->initializeMiddlewareManager(); - - // Lazy load the the HttpClient - if( empty($this->httpClient) ){ - $this->setHttpClient(new Client); - } - - // Get the response class name to instantiate (to pass into Middleware) - $responseClass = $this->getOption(self::OPTION_RESPONSE_CLASS); - - // Capture start time (for logging requests) - $start = microtime(true); - - // Save the request object so it may be retrieved - $this->request = $request; - - // Run the request - /** @var ResponseAbstract $response */ - $response = $this->middlewareManager->peel( - $request, - function(Request $request) use ($responseClass): ResponseAbstract { - - try { - - $response = $this->httpClient->send($request->newPsr7Request()); - } catch( BadResponseException $badResponseException ){ - $response = $badResponseException->getResponse(); - } - - return new $responseClass($response); - }); - - // Capture end time - $stop = microtime(true); - - // Save the response object so it may be retrieved - $this->response = $response; - - // Should we log this request? - if( $this->getOption(self::OPTION_LOG) ){ - $this->addLog($request, $response, ($stop-$start)); + if( !isset($this->options[self::OPTION_SERIALIZER]) || + !\is_callable($this->options[self::OPTION_SERIALIZER]) ) { + throw new UnexpectedValueException("Serializer is not callable."); } - return $response; - } - - /** - * @return array - */ - public function getLog(): array - { - return $this->log; - } - - /** - * @param Request $request - * @param ResponseAbstract $response - * @param float $timing - * @return void - */ - private function addLog(Request $request, ResponseAbstract $response, $timing): void - { - $this->log[] = [ - 'request' => $request, - 'response' => $response, - 'time' => $timing, - ]; + return \call_user_func( + $this->options[self::OPTION_SERIALIZER], + $data + ); } /** - * Initialize middleware manager by instantiating all middlware classes - * and creating Onion instance. + * Deserialize a response body. * - * @return void + * @param string $data + * @return mixed */ - private function initializeMiddlewareManager() + public function deserialize(string $data): mixed { - if( empty($this->middlewareManager) ){ - - $layers = []; - foreach( $this->getOption(self::OPTION_MIDDLEWARE) as $middleware ){ - $layers[] = new $middleware; - } - - // Create new Onion - $this->middlewareManager = new Onion($layers); + if( !isset($this->options[self::OPTION_DESERIALIZER]) || + !\is_callable($this->options[self::OPTION_DESERIALIZER]) ) { + throw new UnexpectedValueException("Deserializer is not callable."); } - } - - /** - * Get the last Request object - * - * @return \ActiveResource\Request - */ - public function getLastRequest() - { - return $this->request; - } - /** - * Get the last Response object - * - * @return ResponseAbstract - */ - public function getLastResponse() - { - return $this->response; + return \call_user_func( + $this->options[self::OPTION_DESERIALIZER], + $data + ); } } \ No newline at end of file diff --git a/src/ConnectionException.php b/src/ConnectionException.php new file mode 100644 index 0000000..01191a3 --- /dev/null +++ b/src/ConnectionException.php @@ -0,0 +1,8 @@ + + * ConnectionManager singleton instance. + * + * @var self|null */ - protected static $connections = []; + protected static ?ConnectionManager $instance; + + /** + * @param array $connections + * @param ClientInterface $client + * @param RequestFactoryInterface $requestFactory + * @param StreamFactoryInterface $streamFactory + * @param EventDispatcherInterface|null $eventDispatcher + */ + protected function __construct( + protected array $connections = [], + protected ClientInterface $httpClient = new Shuttle, + protected RequestFactoryInterface $requestFactory = new RequestFactory, + protected StreamFactoryInterface $streamFactory = new StreamFactory, + protected ?EventDispatcherInterface $eventDispatcher = null, + ) + { + } /** - * Add an API connection + * ActiveResource ConnectionManager init factory. + * + * @param array $connections + * @param ClientInterface $httpClient + * @param RequestFactoryInterface $requestFactory + * @param StreamFactoryInterface $streamFactory + * @param EventDispatcherInterface $eventDispatcher + * @return ConnectionManager + */ + public static function init( + array $connections = [], + ClientInterface $httpClient = new Shuttle, + RequestFactoryInterface $requestFactory = new RequestFactory, + StreamFactoryInterface $streamFactory = new StreamFactory, + ?EventDispatcherInterface $eventDispatcher = null, + ): ConnectionManager + { + self::$instance = new self( + connections: $connections, + httpClient: $httpClient, + requestFactory: $requestFactory, + streamFactory: $streamFactory, + eventDispatcher: $eventDispatcher, + ); + + return self::$instance; + } + + /** + * Add a new connection instance to the pool. * - * @param string $name * @param Connection $connection + * @param string $name Connection name, defaults to `default`. + * @return void + */ + public function addConnection(Connection $connection, string $name = "default"): void + { + $this->connections[$name] = $connection; + } + + /** + * Get the Manager instance. + * + * @return ConnectionManager + */ + public static function getInstance(): ConnectionManager + { + if( empty(self::$instance) ){ + throw new RuntimeException( + "ConnectionManager instance has not been initialized. Did you run ConnectionManager::init()?." + ); + } + + return self::$instance; + } + + /** + * Get the ClientInterfaceInstance. + * + * @return ClientInterface + */ + public function getHttpClient(): ClientInterface + { + return $this->httpClient; + } + + /** + * Get the RequestFactoryInterface instance. + * + * @return RequestFactoryInterface + */ + public function getRequestFactory(): RequestFactoryInterface + { + return $this->requestFactory; + } + + /** + * Get the StreamFactoryInterface instance. + * + * @return StreamFactoryInterface + */ + public function getStreamFactory(): StreamFactoryInterface + { + return $this->streamFactory; + } + + /** + * Get a Connection from the manager. + * + * @param string|null $name The name of the connection to get. If null is passed, the default connection is returned. + * @return Connection|null + */ + public function getConnection(?string $name = null): ?Connection + { + return $this->connections[$name ?? "default"] ?? null; + } + + /** + * Dispatch an event. + * + * @param ResourceEventAbstract $event * @return void */ - public static function add($name, Connection $connection): void + public function dispatch(ResourceEventAbstract $event): void { - self::$connections[$name] = $connection; + $this->eventDispatcher?->dispatch($event); } /** - * Get an API connection by its name + * Build a RequestInterface instance for this resource. * - * @param string $name - * @throws ActiveResourceException - * @return Connection + * @param string $method + * @param string $uri + * @param string|null $body + * @param array $query + * @param array $headers + * @return RequestInterface */ - public static function get($name): Connection + protected function buildRequest( + string $method, + string $uri, + ?string $body = null, + array $query = [], + array $headers = [], + string $version = "1.1"): RequestInterface { - if( isset(self::$connections[$name]) ){ - return self::$connections[$name]; + if( $query ){ + $uri .= ("?" . \http_build_query($query)); + } + + // Create the RequestInterface instance. + $request = $this->getRequestFactory()->createRequest( + \strtoupper($method), + $uri + ); + + // Add body to Request + if( $body ){ + $request = $request->withBody( + $this->getStreamFactory()->createStream($body) + ); + } + + // Attach the headers to Request + foreach( $headers as $header => $value ){ + $request = $request->withAddedHeader($header, $value); + } + + // Set the protocol version + $request = $request->withProtocolVersion($version); + + return $request; + } + + /** + * @param string|Connection $connection Connection name or Connection instance to send the request on. + * @param string $method The HTTP method to use. + * @param string $uri The URI of the request. + * @param mixed $body The body of the request. Can be `null` for an empty body. + * @param array $query Query parameters to send with request. + * @param array $headers HTTP headers to send with request. + * @throws ResponseException + * @return Response Returns the deserialized response body and headers. + */ + public function sendRequest( + string|Connection $connection, + string $method, + string $uri, + mixed $body = null, + array $query = [], + array $headers = []): Response + { + if( \is_string($connection) ){ + $cxn = $this->getConnection($connection); + + if( empty($cxn) ){ + throw new ConnectionException( + \sprintf("Connection \"%s\" not found.", $connection) + ); + } + + $connection = $cxn; + } + + $request = $this->buildRequest( + $method, + \trim($connection->getHost(), "/") . "/" . $uri, + $connection->serialize($body), + \array_merge($query, $connection->getQuery()), + \array_merge($headers, $connection->getHeaders()), + $connection->getOption(Connection::OPTION_HTTP_VERSION) ?? "1.1" + ); + + try { + + $response = $this->getHttpClient()->sendRequest($request); + } + catch( ClientExceptionInterface $exception ){ + throw new ResponseException( + request: $request, + previous: $exception + ); + } + + if( $response->getStatusCode() >= 400 ){ + throw new ResponseException( + request: $request, + response: $response, + ); } - throw new ActiveResourceException("Connection \"{$name}\" not found"); + return new Response( + payload: $connection->deserialize($response->getBody()->getContents()), + headers: $response->getHeaders() + ); } } \ No newline at end of file diff --git a/src/Events/ResourceDeletedEvent.php b/src/Events/ResourceDeletedEvent.php new file mode 100644 index 0000000..ce1e621 --- /dev/null +++ b/src/Events/ResourceDeletedEvent.php @@ -0,0 +1,7 @@ +resource; + } +} \ No newline at end of file diff --git a/src/Events/ResourceSavedEvent.php b/src/Events/ResourceSavedEvent.php new file mode 100644 index 0000000..653daf2 --- /dev/null +++ b/src/Events/ResourceSavedEvent.php @@ -0,0 +1,7 @@ + + */ + public function getResponseHeaders(): array + { + return $this->responseHeaders; + } + + /** + * Get a specific response header. + * + * @param string $header + * @return string|null + */ + public function getResponseHeader(string $header): ?string + { + foreach( $this->responseHeaders as $responseHeader => $value ){ + if( \strtolower($responseHeader) === \strtolower($header) ){ + return $value; + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/Model.php b/src/Model.php deleted file mode 100644 index c2bea4a..0000000 --- a/src/Model.php +++ /dev/null @@ -1,839 +0,0 @@ -fill($data); - } - } - - /** - * Get the ID of the resource - * - * @return mixed|null - */ - public function getId() - { - return $this->{$this->identifierName}; - } - - /** - * Get the identifier property name (defaults to "id") - * - * @return string - */ - public function getIdentifierName() - { - return $this->identifierName; - } - - /** - * Get the full resource URI - * - * @return string - */ - public function getResourceUri() - { - $uri = ''; - - if( ($dependencies = $this->getDependencies()) ){ - $uri.="{$dependencies}/"; - } - - $uri.=$this->getResourceName(); - - if( ($id = $this->getId()) ){ - $uri.="/{$id}"; - } - - return $uri; - } - - /** - * Save the entity - * - * @param array $queryParams - * @param array $headers - * - * @return bool - */ - public function save(array $queryParams = [], array $headers = []) - { - // By default, submit all data - $data = array_merge($this->properties, $this->modifiedProperties); - - // No id, new (POST) resource instance - if( $this->{$this->identifierName} == false ){ - $method = 'post'; - } - - // Existing resource, update (PUT/POST/PATCH depending on API) resource instance - else { - - // Can we just send the modified properties? (i.e. a PATCH) - if( $this->getConnection()->getOption(Connection::OPTION_UPDATE_DIFF) ){ - $data = $this->modifiedProperties; - } - - // Get the update method (usually either PUT or PATCH) - $method = $this->getConnection()->getOption(Connection::OPTION_UPDATE_METHOD); - } - - // Filter out excluded properties - if( is_array($this->excludedProperties) ){ - $data = array_diff($data, $this->excludedProperties); - } - - // Build request object - $request = $this->getConnection()->buildRequest($method, $this->getResourceUri(), $queryParams, $this->encode($data), $headers); - - // Do the update - $response = $this->getConnection()->send($request); - if( $response->isSuccessful() ){ - $this->hydrate($this->parseFind($response->getPayload())); - $this->modifiedProperties = []; - return true; - } - - // Should we throw an exception? - if( $response->isThrowable() ) { - throw new ActiveResourceResponseException($response); - } - - return false; - } - - /** - * Destroy (delete) the resource - * - * @param array $queryParams - * @param array $headers - * - * @return bool - */ - public function destroy(array $queryParams = [], array $headers = []) - { - // Build request - $request = $this->getConnection()->buildRequest('delete', $this->getResourceUri(), $queryParams, null, $headers); - - // Get response - $response = $this->getConnection()->send($request); - if( $response->isSuccessful() ){ - return true; - } - - // Throw if needed - if( $response->isThrowable() ) { - throw new ActiveResourceResponseException($response); - } - - return false; - } - - - /** - * Mass assign properties with an array of key/value pairs - * - * @param array $data - */ - public function fill(array $data) - { - foreach( $data as $property => $value ){ - if( is_array($this->fillableProperties) && - !in_array($property, $this->fillableProperties) ){ - continue; - } - - $this->{$property} = $value; - } - } - - /** - * Build a Collection of included resources in response payload. - * - * @param string $model - * @param array $data - * - * @return array|mixed - */ - public function includesMany($model, array $data) - { - if( empty($data) ){ - return $this->buildCollection($model, []); - } - - return $this->buildCollection($model, $data); - } - - /** - * Build a single instance of an included resource in response payload. - * - * @param string $model - * @param $data - * @return Model - */ - public function includesOne($model, $data) - { - if( empty($data) || - (!is_object($data) && !is_array($data)) ){ - return $data; - } - - /** @var Model $modelInstance */ - $modelInstance = new $model; - $modelInstance->hydrate($data); - - /** @var self $instance */ - return $modelInstance; - } - - /** - * Set dependent resources to prepend to URI. You can call this method multiple times to prepend additional dependent - * resources. - * - * For example, if the API only allows you to create a new comment on a post *through* the post's URI: - * POST /posts/1234/comment - * - * $comment = new Comment; - * $comment->through('posts/1234'); - * $comment->body = "This is a comment"; - * $comment->save(); - * - * OR - * - * $post = Post::find(1234); - * $comment = new Comment; - * $comment->through($post); - * $comment->body = "This is a comment"; - * $comment->save(); - * - * @param Model|string $resource - * - * @return Model - */ - public function through($resource) - { - if( $resource instanceof Model ){ - - if( !in_array($resource->getResourceUri(), $this->dependentResources) ){ - $this->dependentResources[] = $resource->getResourceUri(); - } - - } - - else{ - - if( !in_array($resource, $this->dependentResources) ){ - $this->dependentResources[] = $resource; - } - - } - - return $this; - } - - - /** - * Magic getter - * - * @param $property - * @return mixed|null - */ - public function __get($property) - { - if( array_key_exists($property, $this->modifiedProperties) ){ - return $this->modifiedProperties[$property]; - } - - elseif( array_key_exists($property, $this->properties) ){ - return $this->properties[$property]; - } - - return null; - } - - /** - * Magic setter - * - * @param $property - * @param $value - */ - public function __set($property, $value) - { - // Is this a read only property? - if( is_array($this->readOnlyProperties) && - in_array($property, $this->readOnlyProperties) ){ - return; - } - - $this->modifiedProperties[$property] = $value; - } - - /** - * Get the original value of a property (before it was modified). - * - * - * @param $property - * @return mixed|null - */ - public function original($property) - { - if( array_key_exists($property, $this->properties) ){ - return $this->properties[$property]; - } - - return null; - } - - /** - * Reset all modified properties - * - * @return void - */ - public function reset() - { - $this->modifiedProperties = []; - } - - /** - * Reset all data on model instance and reload from remote API. - * - * @param array $queryParams - * @param array $headers - * @return bool - */ - public function refresh(array $queryParams = [], array $headers = []) - { - // Build the request object - $request = $this->getConnection()->buildRequest('get', $this->getResourceUri(), $queryParams, null, $headers); - - // Send the request - $response = $this->getConnection()->send($request); - if( $response->isSuccessful() ) { - - // Clear out all local properties and modified properties - $this->properties = []; - $this->modifiedProperties = []; - - $this->hydrate($this->parseFind($response->getPayload())); - return true; - } - - if( $response->isThrowable() ) { - throw new ActiveResourceResponseException($response); - } - - return false; - } - - /** - * @return array - */ - public function toArray() - { - $properties = array_merge($this->properties, $this->modifiedProperties); - return self::objectToArray($properties); - } - - /** - * @return string - */ - public function toJson() - { - return json_encode($this->toArray(), JSON_UNESCAPED_SLASHES); - } - - /** - * Used to recursively convert model and relations into array - * - * @param $data - * @return array - */ - private static function objectToArray($data) - { - $result = []; - - foreach( $data as $property => $value ) - { - if( $value instanceof Model ) - { - $result[$property] = $value->toArray(); - } - elseif( $value instanceof \StdClass ) - { - $result[$property] = (array)$value; - } - elseif( is_array($value) || - $value instanceof \ArrayAccess ) - { - $result[$property] = self::objectToArray($value); - } - else{ - $result[$property] = $value; - } - } - - return $result; - } - - /** - * Default encode method for Request payloads. Need to send JSON or XML or something else? - * - * Gets called for all PUT/POST/PATCH calls to the API. - * - * You should override this method in your models (if necessary) or in a BaseModel class. This is also a good - * place to add any extra markup needed in the request body. For example: - * - * return json_encode(['data' => $data]); - * - * @param string - * - * @return string - */ - protected function encode($data) - { - return json_encode($data); - } - - /** - * Get the model's resource name (defaults to lowercase class name) - * - * @return null|string - */ - public function getResourceName() - { - if( empty($this->resourceName) ){ - - $resourceName = get_called_class(); - - if( ($pos = strrpos($resourceName, '\\')) !== false ) { - $resourceName = substr($resourceName, $pos + 1); - } - - $this->resourceName = strtolower($resourceName); - } - - return $this->resourceName; - } - - /** - * Get the API connection for the model - * - * @return Connection - */ - public function getConnection() - { - return ConnectionManager::get($this->connectionName); - } - - /** - * Return an instance of the called class - * - * @param null $constructorData - * @return self - */ - protected static function getCalledClassInstance($constructorData = null) - { - $className = get_called_class(); - return new $className($constructorData); - } - - /** - * Is this entity modified? - * - * @return int - */ - protected function isModified() - { - return count($this->modifiedProperties) > 0; - } - - /** - * Get any resource dependencies - * - * @return string - */ - protected function getDependencies() - { - return implode('/', $this->dependentResources); - } - - /** - * Where to find the single resource data from the response payload. - * - * You should overwrite this method in your model class to suite your needs. - * - * @param $payload - * @return mixed - */ - protected function parseFind($payload) - { - return $payload; - } - - /** - * Where to find the array of data from the response payload. - * - * You should overwrite this method in your model class to suite your needs. - * - * @param $payload - * @return mixed - */ - protected function parseAll($payload) - { - return $payload; - } - - /** - * Hydrate model instance - * - * @param array|object $data - * @throws ActiveResourceException - * @return boolean - */ - protected function hydrate($data) - { - if( empty($data) ){ - return true; - } - - // Convert array based data into object - if( is_array($data) ) { - $data = (object)$data; - } - - // Process the data payload object - if( is_object($data) ){ - foreach( get_object_vars($data) as $property => $value ){ - - // is there some sort of filter method on this property? - if( method_exists($this, $property) ){ - $value = $this->{$property}($value); - } - - $this->properties[$property] = $value; - } - - return true; - } - - throw new ActiveResourceException('Failed to hydrate - invalid data format.'); - } - - /** - * Manually set the resource identifier on the model instance. - * - * This property is used to inform the model whether the object was retrieved via the API vs a manually hydrated - * object instance. - * - * @param $value - */ - public function setResourceIdentifier($value) - { - $this->resourceIdentifier = $value; - } - - - /** - * Find (GET) a specific resource by its ID - * - * This method assumes the payload contains a *SINGLE* resource instance. This method will call the - * parseFind method on the Model instance to know where to look in the payload to get the resource data. - * - * @param integer|string $id - * @param array $queryParams - * @param array $headers - * - * @throws ActiveResourceResponseException - * - * @return Model|boolean - */ - public static function find($id, array $queryParams = [], array $headers = []) - { - $instance = self::getCalledClassInstance(); - - $uri = $instance->getResourceUri() . "/{$id}"; - - // Build the request object - $request = $instance->getConnection()->buildRequest('get', $uri, $queryParams, null, $headers); - - // Send the request - $response = $instance->getConnection()->send($request); - if( $response->isSuccessful() ) { - $instance->hydrate($instance->parseFind($response->getPayload())); - return $instance; - } - - if( $response->isThrowable() ) { - throw new ActiveResourceResponseException($response); - } - - return false; - } - - /** - * Get ALL resources - * - * This method assumes the payload contains an ARRAY of resource instances. This method will call the - * parseAll method on the Model instance to know where to look in the payload to get the array of resource data. - * - * @param array $queryParams - * @param array $headers - * - * @throws ActiveResourceResponseException - * - * @return array|boolean|mixed - */ - public static function all(array $queryParams = [], array $headers = []) - { - $instance = self::getCalledClassInstance(); - - // Build the request - $request = $instance->getConnection()->buildRequest('get', $instance->getResourceUri(), $queryParams, null, $headers); - - // Send the request - $response = $instance->getConnection()->send($request); - if( $response->isSuccessful() ) { - $data = $instance->parseAll($response->getPayload()); - return $instance->buildCollection(get_called_class(), $data); - } - - if( $response->isThrowable() ) { - throw new ActiveResourceResponseException($response); - } - - return false; - } - - /** - * Delete a resource - * - * @param mixed $id - * @param array $queryParams - * @param array $headers - * @throws ActiveResourceResponseException - * @return bool - */ - public static function delete($id, array $queryParams = [], array $headers = []) - { - $instance = self::getCalledClassInstance(); - - $uri = $instance->getResourceUri() . "/{$id}"; - - // Build request object - $request = $instance->getConnection()->buildRequest('delete', $uri, $queryParams, null, $headers); - - // Send request - $response = $instance->getConnection()->send($request); - if( $response->isSuccessful() ) { - return true; - } - - if( $response->isThrowable() ) { - throw new ActiveResourceResponseException($response); - } - - return false; - } - - /** - * Find a single instance *through* a dependent resource. It prepends the resource URI with the given dependent - * resource URI. For example: - * API URI: [GET] /posts/1234/comments/5678 - * - * $comment = Comment::findThrough('posts/1234', 5678); - * - * OR - * - * $post = Post::find(1234); - * $comment = Comment::findThrough($post, 5678); - * - * @param Model|string $resource - * @param string|null $id - * @param array $queryParams - * @param array $headers - * - * @throws ActiveResourceResponseException - * - * @return Model|bool - */ - public static function findThrough($resource, $id = null, array $queryParams = [], array $headers = []) - { - $instance = self::getCalledClassInstance(); - $instance->through($resource); - $uri = $instance->getResourceUri() . "/{$id}"; - - // Build request object - $request = $instance->getConnection()->buildRequest('get', $uri, $queryParams, null, $headers); - - // Do request - $response = $instance->getConnection()->send($request); - if( $response->isSuccessful() ) { - $instance->hydrate($instance->parseFind($response->getPayload())); - return $instance; - } - - if( $response->isThrowable() ) { - throw new ActiveResourceResponseException($response); - } - - return false; - } - - /** - * Find all instances *through* a dependent resource. It prepends the resource URI with the given dependent - * resource URI. For example: - * - * API URI: [GET] /posts/1234/comments - * - * $comments = Comment::allThrough('posts/1234'); - * - * OR - * - * $post = Post::find(1234); - * $comments = Comment::allThrough($post); - * - * @param Model|string $resource - * @param array $queryParams - * @param array $headers - * - * @throws ActiveResourceResponseException - * - * @return Collection|bool - */ - public static function allThrough($resource, array $queryParams = [], array $headers = []) - { - $instance = self::getCalledClassInstance(); - $instance->through($resource); - - // Build request object - $request = $instance->getConnection()->buildRequest('get', $instance->getResourceUri(), $queryParams, null, $headers); - - // Do request, get response - $response = $instance->getConnection()->send($request); - if( $response->isSuccessful() ) { - $data = $instance->parseAll($response->getPayload()); - return $instance->buildCollection(get_called_class(), $data); - } - - if( $response->isThrowable() ) { - throw new ActiveResourceResponseException($response); - } - - return false; - } - - /** - * Build a collection of models - * - * @param string $model - * @param array $data - * - * @return array|mixed - */ - protected function buildCollection($model, array $data) - { - $instances = []; - foreach( $data as $object ){ - /** @var Model $modelInstance */ - $modelInstance = new $model; - $modelInstance->hydrate($object); - $instances[] = $modelInstance; - } - - if( ($collectionClass = $this->getConnection()->getOption(Connection::OPTION_COLLECTION_CLASS)) ){ - return new $collectionClass($instances); - } - - return $instances; - } - - /** - * Get the Request object from the last API call - * - * @return Request - */ - public function getRequest() - { - return $this->getConnection()->getLastRequest(); - } - - /** - * Get the Response object from the last API call - * - * @return ResponseAbstract - */ - public function getResponse() - { - return $this->getConnection()->getLastResponse(); - } -} \ No newline at end of file diff --git a/src/Request.php b/src/Request.php deleted file mode 100644 index 19524f4..0000000 --- a/src/Request.php +++ /dev/null @@ -1,273 +0,0 @@ -method = $method; - $this->url = $url; - $this->query = $query; - $this->headers = $headers; - $this->body = $body; - } - - /** - * Get HTTP method for request - * - * @return string - */ - public function getMethod() - { - return $this->method; - } - - /** - * Set the HTTP method for the request - * - * @param string $method - */ - public function setMethod($method) - { - $this->method = $method; - } - - /** - * Get the URL for the request - * - * @return string - */ - public function getUrl() - { - return $this->url; - } - - /** - * Set the URL for the request - * - * @param string $url - */ - public function setUrl($url) - { - $this->url = $url; - } - - /** - * Get the URL query for the request - * - * @return array - */ - public function getQueries() - { - return $this->query; - } - - /** - * Set an array of queries - * - * @param array $queries - */ - public function setQueries(array $queries) - { - $this->query = $queries; - } - - /** - * Get a query param from the request - * - * @param $name - * @return mixed|null - */ - public function getQuery($name) - { - if( ($index = $this->findArrayIndex($name, $this->query)) ){ - return $this->query[$index]; - } - - return null; - } - - /** - * Add a query parameter - * - * @param $name - * @param $value - */ - public function setQuery($name, $value) - { - $this->query[$name] = $value; - } - - /** - * Return query as HTTP query string (RFC1738) - * - * @return null|string - */ - public function getQueryAsString() - { - // Process the query array - if( ($query = http_build_query($this->query, "", '&', PHP_QUERY_RFC1738)) ){ - return "?{$query}"; - } - - return null; - } - - - /** - * Get the headers for the request - * - * @return array - */ - public function getHeaders() - { - return $this->headers; - } - - /** - * Set an array of headers for the request - * - * @param array $headers - */ - public function setHeaders(array $headers) - { - $this->headers = $headers; - } - - /** - * Get a header from the request - * - * @param $name - * @return string|null - */ - public function getHeader($name) - { - if( ($index = $this->findArrayIndex($name, $this->headers)) ){ - return $this->headers[$index]; - } - - return null; - } - - /** - * Set a header for the request - * - * @param string $name - * @param string $value - */ - public function setHeader($name, $value) - { - if( ($index = $this->findArrayIndex($name, $this->headers)) ){ - $this->headers[$index] = $value; - } - - else { - $this->headers[$name] = $value; - } - } - - /** - * Remove a header from the request - * - * @param $name - * @return boolean - */ - public function removeHeader($name) - { - if( ($index = $this->findArrayIndex($name, $this->headers)) ){ - unset($this->headers[$index]); - return true; - } - - return false; - } - - /** - * Get the request body - * - * @return string - */ - public function getBody() - { - return $this->body; - } - - /** - * Set the request body - * - * @param string $body - */ - public function setBody($body) - { - if( empty($body) ){ - $body = null; - } - - $this->body = $body; - } - - - /** - * Find an array key (index) case-insensitive - * - * @param $key - * @param array $array - * @return string|int|bool - */ - protected function findArrayIndex($key, array $array) - { - foreach( $array as $k => $v ) - { - if( strtolower($key) === strtolower($k) ){ - return $k; - } - } - - return false; - } - - - /** - * Build a PSR-7 Request instance - * - * @return \GuzzleHttp\Psr7\Request - */ - public function newPsr7Request() - { - return new Psr7Request( - $this->method, - $this->url.$this->getQueryAsString(), - $this->headers, - $this->body - ); - } -} \ No newline at end of file diff --git a/src/Resource.php b/src/Resource.php new file mode 100644 index 0000000..c422b13 --- /dev/null +++ b/src/Resource.php @@ -0,0 +1,478 @@ + + */ + protected array $fillableProperties = []; + + /** + * Object properties that have been modified. + * + * @var array + */ + protected array $modifiedProperties = []; + + /** + * Object properties that should be ignored when saving. + * + * @var array + */ + protected array $excludedProperties = []; + + /** + * @param array $properties + */ + public function __construct( + protected array $properties = []) + { + $this->fill($properties); + } + + /** + * Hydrate the instance properties. + * + * Hydrating an instance sets the properties directly + * and does not mark them as modified. This is especially + * useful if caching resources or resource properties. + * + * Alternatively, and a better approach, is to use the static + * `make` method. + * + * @param array|object $properties + * @return void + */ + public function hydrate(array|object $properties): void + { + foreach( $properties as $property => $value ){ + + $mutator = $this->getPropertyMutator($property); + + if( $mutator ){ + $value = \call_user_func($mutator, $value); + } + + $this->properties[$property] = $value; + } + + if( !$this->getResourceUri() ){ + + $resource_identifier = ResourceManager::getResourceIdentifier($this::class); + + if( isset($this->properties[$resource_identifier]) ){ + $this->resource_uri = \sprintf( + "%s/%s", + ResourceManager::getResourceName($this::class), + $this->properties[$resource_identifier] + ); + } + } + + $this->modifiedProperties = []; + } + + /** + * Make a new instance with hydrated properties. + * + * @param array|object $properties + * @param array $responseHeaders + * @return static + */ + public static function make( + array|object $properties, + array $responseHeaders = []): static + { + $instance = new static; + $instance->hydrate($properties); + $instance->responseHeaders = $responseHeaders; + return $instance; + } + + /** + * Mass assign properties on resource. + * + * @param array $properties + * @return void + */ + public function fill(array $properties): void + { + foreach( $properties as $property => $value ){ + if( \in_array($property, $this->fillableProperties) ){ + $this->setProperty($property, $value); + } + } + } + + /** + * Get the modified properties. + * + * @return array + */ + public function getModifiedProperties(): array + { + return $this->modifiedProperties; + } + + /** + * Reset all modified properties on resource. + * + * @return void + */ + public function reset(): void + { + $this->modifiedProperties = []; + } + + /** + * Magic getter for resource properties. + * + * @param string $property + * @return mixed|null + */ + public function __get(string $property) + { + return $this->getProperty($property); + } + + /** + * Get a property by its name. + * + * @param string $property + * @return mixed|null + */ + public function getProperty(string $property): mixed + { + $value = $this->modifiedProperties[$property] ?? $this->properties[$property] ?? null; + + $accessor = $this->getPropertyAccessor($property); + + if( $accessor ){ + return \call_user_func($accessor, $value); + } + + return $value; + } + + /** + * Get the resource property's accessor, if any. + * + * @param string $property + * @return callable|null + */ + private function getPropertyAccessor(string $property): ?callable + { + $methodName = "get" . $this->toCamelCase($property) . "Property"; + + if( \method_exists($this, $methodName) ){ + return [$this, $methodName]; + } + + return null; + } + + /** + * Magic setter for resource properties. + * + * @param string $property + * @param mixed $value + * @return void + */ + public function __set(string $property, $value): void + { + $this->setProperty($property, $value); + } + + /** + * Set a class property. + * + * @param string $property + * @param mixed $value + * @return void + */ + public function setProperty(string $property, mixed $value): void + { + $mutator = $this->getPropertyMutator($property); + + if( $mutator ){ + $value = \call_user_func($mutator, $value); + } + + $this->modifiedProperties[$property] = $value; + } + + /** + * Get the resource property's mutator, if any. + * + * @param string $property + * @return callable|null + */ + private function getPropertyMutator(string $property): ?callable + { + $methodName = "set" . $this->toCamelCase($property) . "Property"; + + if( \method_exists($this, $methodName) ){ + return [$this, $methodName]; + } + + return null; + } + + /** + * Convert a string into camel case. + * + * @param string $value + * @return string + */ + private function toCamelCase(string $value): string + { + $result = \preg_replace("/[^\w\d]|[_]/", " ", \trim($value)); + + if( $result === null ){ + $result = $value; + } + + $result = \preg_replace("/\s/", "", \ucwords($result)); + + return $result ?? $value; + } + + /** + * Get all properties. + * + * @return array + */ + public function toArray(): array + { + return \array_merge( + $this->properties, + $this->modifiedProperties + ); + } + + /** + * Get the URI for this resource. + * + * @return string|null + */ + public function getResourceUri(): ?string + { + return $this->resource_uri; + } + + /** + * Find a resource by its ID. + * + * @param string|integer $id + * @param array $query + * @param array $headers + * @throws ResponseException + * @return static + */ + public static function find( + string|int $id, + array $query = [], + array $headers = []): static + { + $resource_uri = ResourceManager::getResourceName(static::class) . "/" . (string) $id; + + $response = ConnectionManager::getInstance()->sendRequest( + connection: ResourceManager::getConnectionName(static::class), + method: "get", + uri: $resource_uri, + query: $query, + headers: $headers + ); + + return static::make( + $response->getPayload(), + $response->getHeaders() + ); + } + + /** + * Get all resources. + * + * @param array $query + * @param array $headers + * @throws ResponseException + * @return ResourceCollection + */ + public static function all( + array $query = [], + array $headers = []): ResourceCollection + { + $resourceName = ResourceManager::getResourceName(static::class); + + $response = ConnectionManager::getInstance()->sendRequest( + connection: ResourceManager::getConnectionName(static::class), + method: "get", + uri: $resourceName, + query: $query, + headers: $headers, + ); + + $collectionProperty = ResourceManager::getCollectionProperty(static::class); + + if( $collectionProperty ){ + + if( !isset($response->getPayload()->{$collectionProperty}) ){ + throw new UnexpectedValueException( + \sprintf( + "Cannot create collection. No property named \"%s\" in payload.", + $collectionProperty + ) + ); + } + + $resources = $response->getPayload()->{$collectionProperty}; + $meta = \array_filter( + (array) $response->getPayload(), + function(string $property) use ($collectionProperty): bool { + return $property !== $collectionProperty; + }, + ARRAY_FILTER_USE_KEY + ); + } + else { + $resources = $response->getPayload(); + } + + if( !\is_array($resources) ){ + throw new UnexpectedValueException( + "Response payload is not an array." + ); + } + + return new ResourceCollection( + resources: \array_map( + function(array|object $properties): static { + return static::make($properties); + }, + $resources, + ), + meta: $meta ?? [], + responseHeaders: $response->getHeaders(), + ); + } + + /** + * Save the resource. + * + * @param array $query + * @param array $headers + * @throws ResponseException + * @return boolean + */ + public function save(array $query = [], array $headers = []): bool + { + $connection_name = ResourceManager::getConnectionName(static::class); + + $connection = ConnectionManager::getInstance()->getConnection($connection_name); + + if( $this->getResourceUri() ){ + $method = $connection->getOption(Connection::OPTION_UPDATE_METHOD) ?? "put"; + } + else { + $method = $connection->getOption(Connection::OPTION_CREATE_METHOD) ?? "post"; + } + + if( $connection->getOption(Connection::OPTION_UPDATE_DIFF) ){ + $body = $this->modifiedProperties; + } + else { + $body = \array_merge($this->properties, $this->modifiedProperties); + } + + // Filter out excluded properties from body. + $body = \array_filter( + $body, + fn(string $property) => !\in_array($property, $this->excludedProperties), + ARRAY_FILTER_USE_KEY + ); + + ConnectionManager::getInstance()->dispatch( + new ResourceSavingEvent($this) + ); + + $response = ConnectionManager::getInstance()->sendRequest( + connection: $connection, + method: $method, + uri: $this->getResourceUri() ?? ResourceManager::getResourceName(static::class), + body: $body, + query: $query, + headers: $headers + ); + + // $this->properties = \array_replace_recursive( + // $this->properties, + // $this->modifiedProperties + // ); + + $this->hydrate($response->getPayload()); + $this->responseHeaders = $response->getHeaders(); + + ConnectionManager::getInstance()->dispatch( + new ResourceSavedEvent($this) + ); + + return true; + } + + /** + * Delete this instance. + * + * @param array $query + * @param array $headers + * @throws ResponseException + * @return boolean + */ + public function delete(array $query = [], array $headers = []): bool + { + if( !$this->getResourceUri() ){ + throw new ResourceException( + "Cannot delete this resource as it does not have a URI." + ); + } + + ConnectionManager::getInstance()->dispatch( + new ResourceDeletingEvent($this) + ); + + $response = ConnectionManager::getInstance()->sendRequest( + connection: ResourceManager::getConnectionName($this::class), + method: "delete", + uri: $this->getResourceUri(), + query: $query, + headers: $headers + ); + + ConnectionManager::getInstance()->dispatch( + new ResourceDeletedEvent($this) + ); + + $this->responseHeaders = $response->getHeaders(); + return true; + } +} \ No newline at end of file diff --git a/src/ResourceCollection.php b/src/ResourceCollection.php new file mode 100644 index 0000000..fe14e43 --- /dev/null +++ b/src/ResourceCollection.php @@ -0,0 +1,44 @@ + $resources + * @param array $meta + */ + public function __construct( + array $resources = [], + protected array $meta = [], + array $responseHeaders = [], + ) + { + parent::__construct($resources); + $this->responseHeaders = $responseHeaders; + } + + /** + * Get the meta data for the resource list. + * + * @param string|null $property The meta data property name to return. If `null` returns all meta data. + * @return mixed + */ + public function getMeta(?string $property = null): mixed + { + if( $property ){ + return $this->meta[$property] ?? null; + } + + return $this->meta; + } +} \ No newline at end of file diff --git a/src/ResourceException.php b/src/ResourceException.php new file mode 100644 index 0000000..ed8f41d --- /dev/null +++ b/src/ResourceException.php @@ -0,0 +1,8 @@ +newInstance(); + $name = $connectionAttribute->getName(); + } + + self::$connections[$resource] = $name ?? "default"; + } + + return self::$connections[$resource]; + } + + public static function getCollectionProperty(Resource|string $resource): ?string + { + if( $resource instanceof Resource ){ + $resource = $resource::class; + } + + if( \array_key_exists($resource, self::$collections) === false ){ + $attribute = self::getResourceAttributes($resource, CollectionProperty::class); + + if( $attribute ){ + $collectionPropertyAttribute = $attribute[0]->newInstance(); + $collectionProperty = $collectionPropertyAttribute->getCollectionProperty(); + } + + self::$collections[$resource] = $collectionProperty ?? null; + } + + return self::$collections[$resource]; + } + + /** + * Get the Resource's resource name (defaults to lowercase class name). + * + * @param Resource|class-string $resource + * @return string + */ + public static function getResourceName(Resource|string $resource): string + { + if( $resource instanceof Resource ){ + $resource = $resource::class; + } + + if( !isset(self::$names[$resource]) ){ + $attribute = self::getResourceAttributes($resource, ResourceName::class); + + if( $attribute ){ + $connectionAttribute = $attribute[0]->newInstance(); + $resourceName = $connectionAttribute->getName(); + } + else { + $pos = \strrpos($resource, "\\"); + + if( $pos !== false ) { + $resourceName = \substr($resource, $pos + 1); + } + else { + $resourceName = $resource; + } + } + + self::$names[$resource] = \strtolower($resourceName); + } + + return self::$names[$resource]; + } + + /** + * Get the resource identifier. Defaults to "id". + * + * @param Resource|class-string $resource + * @return string + */ + public static function getResourceIdentifier(Resource|string $resource): string + { + if( $resource instanceof Resource ){ + $resource = $resource::class; + } + + if( !isset(self::$identifiers[$resource]) ){ + $attribute = self::getResourceAttributes($resource, ResourceIdentifier::class); + + if( $attribute ){ + $connectionAttribute = $attribute[0]->newInstance(); + $resourceIdentifier = $connectionAttribute->getIdentifier(); + } + + self::$identifiers[$resource] = $resourceIdentifier ?? "id"; + } + + return self::$identifiers[$resource]; + } + + /** + * Get attributes for Resource. + * + * @param Resource|class-string $resource + * @param string|null $attribute + * @return array<\ReflectionAttribute> + */ + protected static function getResourceAttributes( + Resource|string $resource, + ?string $attribute = null): array + { + $reflectionClass = new ReflectionClass($resource); + /** + * @psalm-suppress InvalidArgument + */ + return $reflectionClass->getAttributes($attribute); + } +} \ No newline at end of file diff --git a/src/Response.php b/src/Response.php index 7748bf5..1e54ee7 100644 --- a/src/Response.php +++ b/src/Response.php @@ -1,26 +1,41 @@ headers = \array_map( + fn(array $values): string => \implode(", ", $values), + $headers + ); + } + + /** + * Get the parsed payload of the response. + * + * @return mixed + */ + public function getPayload(): mixed + { + return $this->payload; + } - /** - * @return bool - */ - public function isSuccessful() - { - return $this->getStatusCode() < 400; - } + /** + * Get the response headers. + * + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } } \ No newline at end of file diff --git a/src/ResponseAbstract.php b/src/ResponseAbstract.php deleted file mode 100644 index e8866ed..0000000 --- a/src/ResponseAbstract.php +++ /dev/null @@ -1,265 +0,0 @@ - 'Continue', - 101 => 'Switching Protocols', - 102 => 'Processing', // RFC2518 - 200 => 'OK', - 201 => 'Created', - 202 => 'Accepted', - 203 => 'Non-Authoritative Information', - 204 => 'No Content', - 205 => 'Reset Content', - 206 => 'Partial Content', - 207 => 'Multi-Status', // RFC4918 - 208 => 'Already Reported', // RFC5842 - 226 => 'IM Used', // RFC3229 - 300 => 'Multiple Choices', - 301 => 'Moved Permanently', - 302 => 'Found', - 303 => 'See Other', - 304 => 'Not Modified', - 305 => 'Use Proxy', - 307 => 'Temporary Redirect', - 308 => 'Permanent Redirect', // RFC7238 - 400 => 'Bad Request', - 401 => 'Unauthorized', - 402 => 'Payment Required', - 403 => 'Forbidden', - 404 => 'Not Found', - 405 => 'Method Not Allowed', - 406 => 'Not Acceptable', - 407 => 'Proxy Authentication Required', - 408 => 'Request Timeout', - 409 => 'Conflict', - 410 => 'Gone', - 411 => 'Length Required', - 412 => 'Precondition Failed', - 413 => 'Payload Too Large', - 414 => 'URI Too Long', - 415 => 'Unsupported Media Type', - 416 => 'Range Not Satisfiable', - 417 => 'Expectation Failed', - 418 => 'I\'m a teapot', // RFC2324 - 421 => 'Misdirected Request', // RFC7540 - 422 => 'Unprocessable Entity', // RFC4918 - 423 => 'Locked', // RFC4918 - 424 => 'Failed Dependency', // RFC4918 - 425 => 'Reserved for WebDAV advanced collections expired proposal', // RFC2817 - 426 => 'Upgrade Required', // RFC2817 - 428 => 'Precondition Required', // RFC6585 - 429 => 'Too Many Requests', // RFC6585 - 431 => 'Request Header Fields Too Large', // RFC6585 - 451 => 'Unavailable For Legal Reasons', // RFC7725 - 500 => 'Internal Server Error', - 501 => 'Not Implemented', - 502 => 'Bad Gateway', - 503 => 'Service Unavailable', - 504 => 'Gateway Timeout', - 505 => 'HTTP Version Not Supported', - 506 => 'Variant Also Negotiates (Experimental)', // RFC2295 - 507 => 'Insufficient Storage', // RFC4918 - 508 => 'Loop Detected', // RFC5842 - 510 => 'Not Extended', // RFC2774 - 511 => 'Network Authentication Required', // RFC6585 - ]; - - /** - * Response constructor. - * @param ResponseInterface $response - */ - public function __construct(ResponseInterface $response = null) - { - if( $response ){ - $this->setStatusCode($response->getStatusCode()); - $this->setHeaders($response->getHeaders()); - $this->setBody($response->getBody()->getContents()); - } - } - - /** - * Get the response status code - * - * @return int - */ - public function getStatusCode() - { - return $this->statusCode; - } - - /** - * @param $statusCode - */ - public function setStatusCode($statusCode) - { - $this->statusCode = $statusCode; - } - - /** - * Get the response status code - * - * @return string - */ - public function getStatusPhrase() - { - if( array_key_exists($this->getStatusCode(), $this->statusTexts) ){ - return $this->statusTexts[$this->getStatusCode()]; - } - - return null; - } - - /** - * Get all headers - * - * @return mixed - */ - public function getHeaders() - { - return $this->headers; - } - - /** - * Get a specific header - * - * @param $name - * @return string|null - */ - public function getHeader($name) - { - foreach( $this->headers as $header => $value) - { - if( strtolower($header) == strtolower($name) ){ - return $value; - } - } - - return null; - } - - /** - * @param $header - * @param $value - */ - public function setHeader($header, $value) - { - $this->headers[$header] = $value; - } - - - /** - * @param array $headers - */ - public function setHeaders(array $headers) - { - foreach( $headers as $header => $value ){ - if( is_array($value) && count($value) == 1 ) { - $this->setHeader($header, $value[0]); - } - else { - $this->setHeader($header, $value); - } - } - } - - /** - * Get the raw (pre-decoded) response body - * - * @return string - */ - public function getBody() - { - return $this->body; - } - - /** - * Set the raw response body and trigger the decoder to update the payload - * - * @param $body - */ - public function setBody($body) - { - $this->body = $body; - $this->payload = $this->decode($this->body); - } - - /** - * Get the parsed/decoded response payload - * - * @return mixed - */ - public function getPayload() - { - return $this->payload; - } - - /** - * Is this response a throwable error - * - * @return bool - */ - public function isThrowable() - { - return in_array($this->getStatusCode(), $this->throwable); - } - - /** - * Decode the response body for processing - * - * @param string $body - * @return mixed - */ - abstract public function decode($body); - - /** - * Whether this request was successful or not - * - * @return bool - */ - abstract public function isSuccessful(); -} \ No newline at end of file diff --git a/src/ResponseException.php b/src/ResponseException.php new file mode 100644 index 0000000..1044761 --- /dev/null +++ b/src/ResponseException.php @@ -0,0 +1,43 @@ +getReasonPhrase() ?? "An error occured.", + $response?->getStatusCode() ?? 0, + $previous + ); + } + + /** + * Get the RequestInterface instance. + * + * @return RequestInterface + */ + public function getRequest(): RequestInterface + { + return $this->request; + } + + /** + * Get the ResponseInterface instance. + * + * @return ResponseInterface|null + */ + public function getResponse(): ?ResponseInterface + { + return $this->response; + } +} \ No newline at end of file diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php deleted file mode 100644 index 45e24fa..0000000 --- a/tests/BaseTestCase.php +++ /dev/null @@ -1,27 +0,0 @@ - HandlerStack::create(new MockHandler($responses)) - ]); - } - -} \ No newline at end of file diff --git a/tests/ConnectionManagerTest.php b/tests/ConnectionManagerTest.php new file mode 100644 index 0000000..66f77a8 --- /dev/null +++ b/tests/ConnectionManagerTest.php @@ -0,0 +1,25 @@ +setStaticPropertyValue("instance", null); + + + $this->expectException(RuntimeException::class); + ConnectionManager::getInstance(); + } +} \ No newline at end of file diff --git a/tests/ConnectionOptionsTest.php b/tests/ConnectionOptionsTest.php deleted file mode 100644 index fc7d2f8..0000000 --- a/tests/ConnectionOptionsTest.php +++ /dev/null @@ -1,102 +0,0 @@ - $baseUri, - ]); - - $request = $connection->buildRequest('get', 'test/1'); - - $this->assertEquals($request->getUrl(), $baseUri.'test/1'); - } - - public function test_option_default_headers() - { - $connection = new Connection([ - Connection::OPTION_DEFAULT_HEADERS => [ - 'X-Custom-Header' => 'Foo', - ], - ]); - - $request = $connection->buildRequest('get', 'test/1'); - - $this->assertEquals('Foo', $request->getHeader('X-Custom-Header')); - } - - public function test_option_default_query_params() - { - $connection = new Connection([ - Connection::OPTION_DEFAULT_QUERY_PARAMS => [ - 'username' => 'foo', - 'key' => 'bar', - ] - ] - ); - - $request = $connection->buildRequest('post', 'test/1'); - - $this->assertEquals('foo', $request->getQuery('username')); - $this->assertEquals('bar', $request->getQuery('key')); - } - - public function test_option_default_content_type() - { - $connection = new Connection([ - Connection::OPTION_DEFAULT_CONTENT_TYPE => Connection::CONTENT_TYPE_XML, - ]); - - $request = $connection->buildRequest('post', 'test', [], 'foo'); - - $this->assertEquals(Connection::CONTENT_TYPE_XML, $request->getHeader('Content-Type')); - } - - public function test_option_response_class() - { - $responses = [ - new Response(200), - ]; - - ConnectionManager::add('default', new Connection([ - Connection::OPTION_RESPONSE_CLASS => '\\Tests\\Models\\CustomResponseClass', - ], $this->buildMockClient($responses))); - - $response = Users::find(1); - - $this->assertTrue($response->getResponse() instanceof CustomResponseClass); - } - - public function test_option_log() - { - $responses = [ - new Response(200), - ]; - - ConnectionManager::add('default', new Connection([ - Connection::OPTION_LOG => true, - ], $this->buildMockClient($responses))); - - $blogs = Blogs::find(1); - - $log = $blogs->getConnection()->getLog(); - - $this->assertTrue($log[0]['request'] instanceof Request, 'Log request not instance of Request'); - $this->assertTrue($log[0]['response'] instanceof ResponseAbstract, 'Log response not instance of ResponseAbstract'); - $this->assertNotEmpty($log[0]['time'], 'Log timing is empty'); - } -} \ No newline at end of file diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php new file mode 100644 index 0000000..72c60f3 --- /dev/null +++ b/tests/ConnectionTest.php @@ -0,0 +1,174 @@ +assertEquals( + "https://api.example.com", + $connection->getHost() + ); + } + + public function test_overriding_default_options(): void + { + $connection = new Connection( + host: "https://api.example.com", + options: [ + Connection::OPTION_UPDATE_DIFF => true, + Connection::OPTION_UPDATE_METHOD => "patch", + Connection::OPTION_HTTP_VERSION => "2.0", + Connection::OPTION_SERIALIZER => "foo", + Connection::OPTION_DESERIALIZER => "bar", + ] + ); + + $this->assertTrue( + $connection->getOption(Connection::OPTION_UPDATE_DIFF) + ); + + $this->assertEquals( + "patch", + $connection->getOption(Connection::OPTION_UPDATE_METHOD) + ); + + $this->assertEquals( + "2.0", + $connection->getOption(Connection::OPTION_HTTP_VERSION) + ); + + $this->assertEquals( + "foo", + $connection->getOption(Connection::OPTION_SERIALIZER) + ); + + $this->assertEquals( + "bar", + $connection->getOption(Connection::OPTION_DESERIALIZER) + ); + } + + public function test_get_headers(): void + { + $connection = new Connection( + host: "https://api.example.com", + headers: ["Foo" => "Bar"] + ); + + $this->assertEquals( + ["Foo" => "Bar"], + $connection->getHeaders() + ); + } + + public function test_get_query(): void + { + $connection = new Connection( + host: "https://api.example.com", + query: ["foo" => "bar"] + ); + + $this->assertEquals( + ["foo" => "bar"], + $connection->getQuery() + ); + } + + public function test_get_option_with_default_values(): void + { + $connection = new Connection("https://api.example.com"); + + $this->assertEquals( + "put", + $connection->getOption(Connection::OPTION_UPDATE_METHOD), + ); + + $this->assertEquals( + false, + $connection->getOption(Connection::OPTION_UPDATE_DIFF), + ); + + $this->assertEquals( + "1.1", + $connection->getOption(Connection::OPTION_HTTP_VERSION), + ); + + $this->assertEquals( + "\json_encode", + $connection->getOption(Connection::OPTION_SERIALIZER), + ); + + $this->assertEquals( + "\json_decode", + $connection->getOption(Connection::OPTION_DESERIALIZER), + ); + } + + public function test_get_unset_option_returns_null(): void + { + $connection = new Connection("https://api.example.com"); + $this->assertNull($connection->getOption("foo")); + } + + public function test_serialize(): void + { + $connection = new Connection("https://api.example.com"); + + $result = $connection->serialize(["foo" => "bar"]); + + $this->assertEquals( + "{\"foo\":\"bar\"}", + $result + ); + } + + public function test_serialize_with_non_callable_throws_unexpected_value_exception(): void + { + $connection = new Connection( + host: "https://api.example.com", + options: [ + Connection::OPTION_SERIALIZER => "foo" + ] + ); + + $this->expectException(UnexpectedValueException::class); + $connection->serialize(["foo" => "bar"]); + } + + public function test_deserialize(): void + { + $connection = new Connection("https://api.example.com"); + + $result = $connection->deserialize("{\"foo\":\"bar\"}"); + + $this->assertEquals( + ["foo" => "bar"], + (array) $result + ); + } + + public function test_deserialize_with_non_callable_throws_unexpected_value_exception(): void + { + $connection = new Connection( + host: "https://api.example.com", + options: [ + Connection::OPTION_DESERIALIZER => "foo" + ] + ); + + $this->expectException(UnexpectedValueException::class); + $connection->deserialize("{\"foo\":\"bar\"}"); + } +} \ No newline at end of file diff --git a/tests/GetTest.php b/tests/GetTest.php deleted file mode 100644 index e42eb1f..0000000 --- a/tests/GetTest.php +++ /dev/null @@ -1,61 +0,0 @@ - 7, - 'title' => 'Blog Post', - 'created_at' => '2017-01-28 03:45:00', - 'author' => [ - 'id' => 40, - 'name' => 'Brent Scheffler', - 'email' => 'brent@nimbly.io', - ], - 'comments' => [ - [ - 'id' => 1, - 'body' => 'Comment 1', - 'created_at' => '2017-01-29 12:34:00', - 'author' => [ - 'id' => 3, - 'name' => 'Joe Blow', - 'email' => 'jblow@example.com', - ] - ], - - [ - 'id' => 2, - 'body' => 'Comment 2', - 'created_at' => '2017-02-04 16:12:33', - 'author' => [ - 'id' => 5, - 'name' => 'Jane Doe', - 'email' => 'jdoe@example.com', - ] - ], - ] - ])), - ]; - - ConnectionManager::add('default', new Connection([], $this->buildMockClient($responses))); - - $blog = Blogs::find(1); - - $this->assertTrue($blog instanceof Blogs); - $this->assertTrue($blog->author instanceof Users); - $this->assertTrue($blog->comments instanceof Collection); - $this->assertCount(2, $blog->comments); - } -} \ No newline at end of file diff --git a/tests/Models/Blogs.php b/tests/Models/Blogs.php deleted file mode 100644 index cf97b10..0000000 --- a/tests/Models/Blogs.php +++ /dev/null @@ -1,19 +0,0 @@ -includesOne(Users::class, $data); - } - - public function comments($data) - { - return $this->includesMany(Comments::class, $data); - } -} \ No newline at end of file diff --git a/tests/Models/Comments.php b/tests/Models/Comments.php deleted file mode 100644 index 510cfce..0000000 --- a/tests/Models/Comments.php +++ /dev/null @@ -1,20 +0,0 @@ -includesOne(Users::class, $data); - } -} \ No newline at end of file diff --git a/tests/Models/CustomResponseClass.php b/tests/Models/CustomResponseClass.php deleted file mode 100644 index 02e494c..0000000 --- a/tests/Models/CustomResponseClass.php +++ /dev/null @@ -1,25 +0,0 @@ -getStatusCode() >= 200 && $this->getStatusCode() < 400); - } -} \ No newline at end of file diff --git a/tests/Models/Users.php b/tests/Models/Users.php deleted file mode 100644 index b23edf6..0000000 --- a/tests/Models/Users.php +++ /dev/null @@ -1,17 +0,0 @@ - "0345404475", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Phillip K. Dick", + "publisher" => "Del Rey", + "published_at" => "1996-05-28" + ]; + + $book = new Book($attributes); + + $this->assertEquals( + $attributes, + $book->toArray() + ); + } + + public function test_hydrate_sets_properties() + { + $book = new Book; + + $attributes = [ + "isbn" => "0345404475", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Phillip K. Dick", + "publisher" => "Del Rey", + "published_at" => "1996-05-28" + ]; + + $book->hydrate($attributes); + + $this->assertEquals( + $book->toArray(), + $attributes + ); + + $this->assertEmpty( + $book->getModifiedProperties() + ); + } + + public function test_hydrate_uses_property_mutator(): void + { + $attributes = [ + "isbn" => "0345404475", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Phillip K. Dick", + "status" => "IN STOCK", + "publisher" => "Del Rey", + "published_at" => "1996-05-28" + ]; + + $book = Book::make($attributes); + + $this->assertEquals( + "in stock", + $book->status + ); + } + + public function test_make(): void + { + $attributes = [ + "isbn" => "0345404475", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Phillip K. Dick", + "publisher" => "Del Rey", + "published_at" => "1996-05-28" + ]; + + $book = Book::make($attributes); + + $this->assertEquals( + $book->toArray(), + $attributes + ); + + $this->assertEmpty( + $book->getModifiedProperties() + ); + } + + public function test_fill(): void + { + $attributes = [ + "isbn" => "0345404475", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Phillip K. Dick", + "publisher" => "Del Rey", + "status" => "in stock", + "published_at" => "1996-05-28" + ]; + + $book = Book::make($attributes); + $book->fill(["status" => "out of stock"]); + + $this->assertEquals( + "out of stock", + $book->status + ); + } + + public function test_fill_only_fillable_on_declared_properties(): void + { + $attributes = [ + "isbn" => "0345404475", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Phillip K. Dick", + "publisher" => "Del Rey", + "status" => "in stock", + "published_at" => "1996-05-28" + ]; + + $book = Book::make($attributes); + $book->fill(["id" => "f76f6cb7-ee89-453d-aa2a-515612adbe57"]); + + $this->assertNull($book->id); + } + + public function test_get_modified_properties(): void + { + $attributes = [ + "isbn" => "0345404475", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Phillip K. Dick", + "publisher" => "Del Rey", + "status" => "in stock", + "published_at" => "1996-05-28" + ]; + + $book = Book::make($attributes); + $book->id = "f76f6cb7-ee89-453d-aa2a-515612adbe57"; + $book->status = "out of stock"; + + $this->assertEquals( + [ + "id" => "f76f6cb7-ee89-453d-aa2a-515612adbe57", + "status" => "out of stock", + ], + $book->getModifiedProperties() + ); + } + + public function test_reset(): void + { + $attributes = [ + "isbn" => "0345404475", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Phillip K. Dick", + "publisher" => "Del Rey", + "status" => "in stock", + "published_at" => "1996-05-28" + ]; + + $book = Book::make($attributes); + $book->id = "f76f6cb7-ee89-453d-aa2a-515612adbe57"; + $book->status = "out of stock"; + + $book->reset(); + + $this->assertEmpty($book->getModifiedProperties()); + } + + public function test_magic_getter(): void + { + $attributes = [ + "isbn" => "0345404475", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Phillip K. Dick", + "publisher" => "Del Rey", + "status" => "in stock", + "published_at" => "1996-05-28" + ]; + + $book = Book::make($attributes); + + $this->assertEquals( + "0345404475", + $book->isbn + ); + } + + public function test_get_property(): void + { + $attributes = [ + "isbn" => "0345404475", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Phillip K. Dick", + "publisher" => "Del Rey", + "status" => "in stock", + "published_at" => "1996-05-28" + ]; + + $book = Book::make($attributes); + + $this->assertEquals( + "0345404475", + $book->getProperty("isbn") + ); + } + + public function test_get_property_with_accessor(): void + { + $attributes = [ + "isbn" => "0345404475", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Phillip K. Dick", + "publisher" => "Del Rey", + "status" => "in stock", + "published_at" => "1996-05-28", + "created_at" => "2025-03-02T14:10:34Z" + ]; + + $book = Book::make($attributes); + + $this->assertInstanceOf( + DateTime::class, + $book->getProperty("created_at") + ); + } + + public function test_magic_setter(): void + { + $book = new Book; + $book->id = "f76f6cb7-ee89-453d-aa2a-515612adbe57"; + + $this->assertEquals( + "f76f6cb7-ee89-453d-aa2a-515612adbe57", + $book->getProperty("id") + ); + } + + public function test_set_property(): void + { + $book = new Book; + $book->setProperty("id", "f76f6cb7-ee89-453d-aa2a-515612adbe57"); + + $this->assertEquals( + "f76f6cb7-ee89-453d-aa2a-515612adbe57", + $book->getProperty("id") + ); + } + + public function test_set_property_with_mutator(): void + { + $book = new Book; + $book->setProperty("status", "OUT OF STOCK"); + + $this->assertEquals( + "out of stock", + $book->getProperty("status") + ); + } + + public function test_to_array(): void + { + $attributes = [ + "isbn" => "0345404475", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Phillip K. Dick", + "status" => "IN STOCK", + "publisher" => "Del Rey", + "published_at" => "1996-05-28" + ]; + + $book = Book::make($attributes); + $book->id = "f76f6cb7-ee89-453d-aa2a-515612adbe57"; + + $this->assertEquals( + [ + "id" => "f76f6cb7-ee89-453d-aa2a-515612adbe57", + "isbn" => "0345404475", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Phillip K. Dick", + "status" => "in stock", + "publisher" => "Del Rey", + "published_at" => "1996-05-28" + ], + $book->toArray() + ); + } + + public function test_find(): void + { + ConnectionManager::init( + connections: ["default" => new Connection("https://api.example.com")], + httpClient: new Shuttle( + handler: new MockHandler([ + function(Request $request): Response { + $book = [ + "id" => "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + "request" => [ + "method" => $request->getMethod(), + "uri" => (string) $request->getUri(), + "headers" => $request->getHeaders(), + ], + ]; + + return new Response(ResponseStatus::OK, \json_encode($book)); + } + ]) + ) + ); + + $book = Book::find(123, ["p" => 1], ["X-Foo" => "Bar"]); + + $this->assertEquals( + "books/6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + $book->getResourceUri() + ); + + $this->assertEquals( + "GET", + $book->request->method + ); + + $this->assertEquals( + "https://api.example.com/books/123?p=1", + $book->request->uri, + ); + + $this->assertEquals( + "Bar", + $book->request->headers->{"X-Foo"}[0] + ); + } + + public function test_find_with_failed_connection_throws_response_exception(): void + { + ConnectionManager::init( + connections: ["default" => new Connection("https://api.example.com")], + httpClient: new Shuttle( + handler: new MockHandler([ + function(Request $request): Response { + throw new RequestException($request, "Failed to connect."); + } + ]) + ) + ); + + $this->expectException(ResponseException::class); + Book::find(123); + } + + public function test_find_with_failed_request_throws_response_exception(): void + { + ConnectionManager::init( + connections: ["default" => new Connection("https://api.example.com")], + httpClient: new Shuttle( + handler: new MockHandler([ + function(): Response { + return new Response(500); + } + ]) + ) + ); + + $this->expectException(ResponseException::class); + Book::find(123); + } + + public function test_all(): void + { + ConnectionManager::init( + connections: ["default" => new Connection("https://api.example.com")], + httpClient: new Shuttle( + handler: new MockHandler([ + function(Request $request): Response { + $books = [ + [ + "id" => "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + "request" => [ + "method" => $request->getMethod(), + "uri" => (string) $request->getUri(), + "headers" => $request->getHeaders(), + ] + ] + ]; + + return new Response(ResponseStatus::OK, \json_encode($books)); + } + ]) + ) + ); + + $books = Book::all(["p" => 1], ["X-Foo" => "Bar"]); + + $this->assertCount(1, $books); + + $this->assertEquals( + "books/6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + $books[0]->getResourceUri() + ); + + $this->assertEquals( + "GET", + $books[0]->request->method + ); + + $this->assertEquals( + "https://api.example.com/books?p=1", + $books[0]->request->uri, + ); + + $this->assertEquals( + "Bar", + $books[0]->request->headers->{"X-Foo"}[0] + ); + } + + public function test_all_with_failed_connection_throws_response_exception(): void + { + ConnectionManager::init( + connections: ["default" => new Connection("https://api.example.com")], + httpClient: new Shuttle( + handler: new MockHandler([ + function(Request $request): Response { + throw new RequestException($request, "Failed to connect."); + } + ]) + ) + ); + + $this->expectException(ResponseException::class); + Book::all(); + } + + public function test_all_with_failed_request_throws_response_exception(): void + { + ConnectionManager::init( + connections: ["default" => new Connection("https://api.example.com")], + httpClient: new Shuttle( + handler: new MockHandler([ + function(): Response { + return new Response(500); + } + ]) + ) + ); + + $this->expectException(ResponseException::class); + Book::all(); + } + + public function test_save_create(): void + { + ConnectionManager::init( + connections: ["default" => new Connection("https://api.example.com")], + httpClient: new Shuttle( + handler: new MockHandler([ + function(Request $request): Response { + $book = [ + "id" => "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + "isbn" => "123456", + "request" => [ + "method" => $request->getMethod(), + "uri" => (string) $request->getUri(), + "headers" => $request->getHeaders(), + ], + + "body" => \json_decode($request->getBody()->getContents()) + ]; + + return new Response(ResponseStatus::CREATED, \json_encode($book)); + } + ]) + ) + ); + + $book = new Book([ + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Philip K. Dick", + "publisher" => "Penguin", + "status" => "in stock", + ]); + + $book->save(["p" => 1], ["X-Foo" => "Bar"]); + + $this->assertEquals( + [ + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Philip K. Dick", + "publisher" => "Penguin", + "status" => "in stock", + ], + (array) $book->body + ); + + $this->assertEquals( + "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + $book->id + ); + + $this->assertEquals( + "books/6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + $book->getResourceUri() + ); + + $this->assertEquals( + "123456", + $book->isbn + ); + + $this->assertEquals( + "POST", + $book->request->method, + ); + + $this->assertEquals( + "https://api.example.com/books?p=1", + $book->request->uri, + ); + + $this->assertEquals( + "Bar", + $book->request->headers->{"X-Foo"}[0] + ); + + $this->assertEmpty($book->getModifiedProperties()); + } + + public function test_save_triggers_events(): void + { + $events = []; + + $dispatcher = new Dispatcher; + + $dispatcher->listen( + ResourceSavingEvent::class, + function(ResourceSavingEvent $event) use (&$events): void { + $events[] = $event; + } + ); + + $dispatcher->listen( + ResourceSavedEvent::class, + function(ResourceSavedEvent $event) use (&$events): void { + $events[] = $event; + } + ); + + ConnectionManager::init( + connections: ["default" => new Connection("https://api.example.com")], + httpClient: new Shuttle( + handler: new MockHandler([ + function(Request $request): Response { + return new Response( + ResponseStatus::OK, + \json_encode([ + "id" => "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + ]) + ); + } + ]) + ), + eventDispatcher: $dispatcher, + ); + + $book = new Book([ + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Philip K. Dick", + "publisher" => "Penguin", + "status" => "in stock", + ]); + + $book->save(); + + $this->assertCount(2, $events); + + $this->assertInstanceOf( + ResourceSavingEvent::class, + $events[0] + ); + + $this->assertInstanceOf( + ResourceSavedEvent::class, + $events[1] + ); + } + + public function test_save_update_no_diff(): void + { + ConnectionManager::init( + connections: [ + "default" => new Connection( + host: "https://api.example.com", + options: [ + Connection::OPTION_UPDATE_DIFF => false + ] + ) + ], + httpClient: new Shuttle( + handler: new MockHandler([ + function(Request $request): Response { + $book = [ + "id" => "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + "isbn" => "123456", + "request" => [ + "method" => $request->getMethod(), + "uri" => (string) $request->getUri(), + "headers" => $request->getHeaders(), + ], + + "body" => \json_decode($request->getBody()->getContents()) + ]; + + return new Response(ResponseStatus::CREATED, \json_encode($book)); + } + ]) + ) + ); + + $book = Book::make([ + "id" => "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + "isbn" => "123456", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Philip K. Dick", + "publisher" => "Penguin", + "status" => "in stock", + ]); + + $book->status = "out of stock"; + + $book->save(["p" => 1], ["X-Foo" => "Bar"]); + + $this->assertEquals( + [ + "id" => "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + "isbn" => "123456", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Philip K. Dick", + "publisher" => "Penguin", + "status" => "out of stock", + ], + (array) $book->body + ); + + $this->assertEquals( + "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + $book->id + ); + + $this->assertEquals( + "books/6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + $book->getResourceUri() + ); + + $this->assertEquals( + "123456", + $book->isbn + ); + + $this->assertEquals( + "PUT", + $book->request->method, + ); + + $this->assertEquals( + "https://api.example.com/books/6af9a9f5-0251-4e09-be1d-4e36ecb242fa?p=1", + $book->request->uri, + ); + + $this->assertEquals( + "Bar", + $book->request->headers->{"X-Foo"}[0] + ); + + $this->assertEmpty($book->getModifiedProperties()); + } + + public function test_save_update_diff(): void + { + ConnectionManager::init( + connections: [ + "default" => new Connection( + host: "https://api.example.com", + options: [ + Connection::OPTION_UPDATE_METHOD => "patch", + Connection::OPTION_UPDATE_DIFF => true, + ] + ) + ], + httpClient: new Shuttle( + handler: new MockHandler([ + function(Request $request): Response { + $book = [ + "id" => "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + "isbn" => "123456", + "request" => [ + "method" => $request->getMethod(), + "uri" => (string) $request->getUri(), + "headers" => $request->getHeaders(), + ], + + "body" => \json_decode($request->getBody()->getContents()) + ]; + + return new Response(ResponseStatus::CREATED, \json_encode($book)); + } + ]) + ) + ); + + $book = Book::make([ + "id" => "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + "isbn" => "123456", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Philip K. Dick", + "publisher" => "Penguin", + "status" => "in stock", + ]); + + $book->status = "out of stock"; + + $book->save(["p" => 1], ["X-Foo" => "Bar"]); + + $this->assertEquals( + [ + "status" => "out of stock", + ], + (array) $book->body + ); + + $this->assertEquals( + "PATCH", + $book->request->method, + ); + + $this->assertEquals( + "https://api.example.com/books/6af9a9f5-0251-4e09-be1d-4e36ecb242fa?p=1", + $book->request->uri, + ); + + $this->assertEquals( + "Bar", + $book->request->headers->{"X-Foo"}[0] + ); + + $this->assertEmpty($book->getModifiedProperties()); + } + + public function test_save_excludes_properties(): void + { + ConnectionManager::init( + connections: ["default" => new Connection("https://api.example.com")], + httpClient: new Shuttle( + handler: new MockHandler([ + function(Request $request): Response { + $book = [ + "id" => "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + "isbn" => "123456", + "request" => [ + "method" => $request->getMethod(), + "uri" => (string) $request->getUri(), + "headers" => $request->getHeaders(), + ], + + "body" => \json_decode($request->getBody()->getContents()) + ]; + + return new Response(ResponseStatus::CREATED, \json_encode($book)); + } + ]) + ) + ); + + $book = new Book([ + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Philip K. Dick", + "publisher" => "Penguin", + "status" => "in stock", + "genre" => "Science Fiction" + ]); + + $book->save(); + + $this->assertEquals( + [ + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Philip K. Dick", + "publisher" => "Penguin", + "status" => "in stock", + ], + (array) $book->body + ); + } + + public function test_delete_no_resource_uri_throws_resource_exception(): void + { + $book = new Book; + + $this->expectException(ResourceException::class); + $book->delete(); + } + + public function test_delete(): void + { + $body = []; + + ConnectionManager::init( + connections: ["default" => new Connection("https://api.example.com")], + httpClient: new Shuttle( + handler: new MockHandler([ + function(Request $request) use (&$body): Response { + + $body["method"] = $request->getMethod(); + $body["uri"] = (string) $request->getUri(); + $body["headers"] = $request->getHeaders(); + + return new Response( + ResponseStatus::NO_CONTENT + ); + } + ]) + ), + ); + + $book = Book::make([ + "id" => "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Philip K. Dick", + "publisher" => "Penguin", + "status" => "in stock", + ]); + + $book->delete(["p" => 1], ["X-Foo" => "Bar"]); + + $this->assertEquals( + "DELETE", + $body["method"] + ); + + $this->assertEquals( + "https://api.example.com/books/6af9a9f5-0251-4e09-be1d-4e36ecb242fa?p=1", + $body["uri"] + ); + + $this->assertEquals( + $body["headers"]["X-Foo"], + ["Bar"] + ); + } + + public function test_delete_triggers_events(): void + { + $events = []; + + $dispatcher = new Dispatcher; + $dispatcher->listen( + "*", + function(ResourceEventAbstract $event) use (&$events): void { + $events[] = $event; + } + ); + + ConnectionManager::init( + connections: ["default" => new Connection("https://api.example.com")], + httpClient: new Shuttle( + handler: new MockHandler([ + function(Request $request): Response { + return new Response( + ResponseStatus::NO_CONTENT + ); + } + ]) + ), + eventDispatcher: $dispatcher, + ); + + $book = Book::make([ + "id" => "6af9a9f5-0251-4e09-be1d-4e36ecb242fa", + "title" => "Do Androids Dream of Electric Sheep?", + "author" => "Philip K. Dick", + "publisher" => "Penguin", + "status" => "in stock", + ]); + + $book->delete(); + + $this->assertCount(2, $events); + + $this->assertInstanceOf( + ResourceDeletingEvent::class, + $events[0] + ); + + $this->assertInstanceOf( + ResourceDeletedEvent::class, + $events[1] + ); + } +} \ No newline at end of file diff --git a/tests/Resources/Book.php b/tests/Resources/Book.php new file mode 100644 index 0000000..b7ad813 --- /dev/null +++ b/tests/Resources/Book.php @@ -0,0 +1,51 @@ +