diff --git a/.gitignore b/.gitignore index bdd7bea..dfd6caa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,2 @@ -/.idea /vendor -composer.lock -.DS_STORE -.*.swo -.*.swp -.swo -.swp -*.sublime-project -*.sublime-workspace \ No newline at end of file +composer.lock \ No newline at end of file diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 8cf7e11..a9a5478 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,7 +1,5 @@ -# Scrutinizer CI configuration filter: excluded_paths: [tests/*] - checks: php: code_rating: true @@ -19,60 +17,17 @@ checks: fix_identation_4spaces: true fix_doc_comments: true uppercase_constants: true - simplify_boolean_return: true - return_doc_comments: true - parameter_doc_comments: true classes_in_camel_caps: true - variable_existence: true verify_property_names: false - use_self_instead_of_fqcn: true properties_in_camelcaps: true - phpunit_assertions: true parameters_in_camelcaps: true - optional_parameters_at_the_end: true - no_new_line_at_end_of_file: true - encourage_single_quotes: true - avoid_multiple_statements_on_same_line: true - -coding_style: - php: - spaces: - before_parentheses: - closure_definition: true - around_operators: - concatenation: true - negation: true - within: - brackets: true - function_call: true - function_declaration: true - if: true - for: true - while: true - switch: true - catch: true - braces: - classes_functions: - class: new-line - function: new-line - closure: end-of-line - if: - opening: end-of-line - for: - opening: end-of-line - while: - opening: end-of-line - do_while: - opening: end-of-line - switch: - opening: end-of-line - try: - opening: end-of-line - upper_lower_casing: - keywords: - general: lower - constants: - true_false_null: lower build: environment: - php: 7.0.6 \ No newline at end of file + php: 7.1.0 + tests: + override: + - + command: 'vendor/bin/phpunit --coverage-clover=coverage' + coverage: + file: 'coverage' + format: 'clover' \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 495bf89..25c5cdf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,10 @@ -# Travis CI configuration language: php php: - - '7.0' + - 7.0 + - 7.1 before_script: - composer self-update - composer install --prefer-source --no-interaction - composer dump-autoload - script: - vendor/bin/phpunit \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..399a24e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,73 @@ +# 2.0.0 (2017-08-01) + +Version `2.0.0` has been a complete rewrite of the package and brings a lot new stuff to the table, including this very new changelog. The documentation has also been revamped and explains all the new features in greater details. If you're upgrading from an earlier version, make sure to remove your `config/responder.php` file and rerun `php artisan vendor:publish --provider="Flugg\Responder\ResponderServiceProvider"` to publish the new configuration file. + +### Breaking Changes + +* Fractal requirement changed to `0.16.0` +* Moved `Flugg\Responder\Transformer` to `Flugg\Responder\Transformers\Transformer` +* Changed `Flugg\Responder\Traits\RespondsWithJson` to `Flugg\Responder\Http\Controllers\MakesResponses` +* Changed `Flugg\Responder\Traits\HandlesApiErrors` to `Flugg\Responder\Exceptions\ConvertsExceptions` +* Moved `Flugg\Responder\Traits\MakesApiRequests` to `Flugg\Responder\Testing\MakesApiRequests` +* Removed `Flugg\Responder\Traits\ConvertsParameter`, use new `ConvertToSnakeCase` middleware instead +* Removed `Flugg\Responder\Traits\ThrowsApiErrors`, manually override form requests to replicate +* Changed `Flugg\Responder\Exceptions\Http\ApiException` to `Flugg\Responder\Exceptions\Http\HttpException` +* Renamed `$statusCode` property of the `HttpException` exceptions to `$status` +* Removed `Flugg\Responder\Exceptions\Http\ResourceNotFoundException`, handler now points to `PageNotFoundException` +* Renamed `Flugg\Responder\Serializers\ApiSerializer` to `Flugg\Responder\Serializers\SuccessSerializer` +* Renamed `successResponse` method of the `MakesResponses` trait to `success` +* Renamed `errorResponse` method of the `MakesResponses` trait to `error` +* Return `SuccessResponseBuilder` from `success` method instead of `JsonResponse` +* Return `ErrorResponseBuilder` from `error` method instead of `JsonResponse` +* Renamed `include` method to `with` on `SuccessResponseBuilder` +* Renamed `addMeta` method to `meta` on `SuccessResponseBuilder` +* Removed `transform` method on `SuccessResponseBuilder`, use `success` instead +* Removed `getManager` and `getResource` methods from `SuccessResponseBuilder` +* Changed `transformer` method of the `Transformable` interface to non-static +* Added an `include` prefix to include methods in transformers +* Renamed `transformException` of exception handler trait to `convertDefaultException` +* Renamed `renderApiError` of exception handler trait to `renderResponse` + +### Features + +* Added configurable response decorators +* Added a `recursion_limit` configuration option +* Allow transforming raw arrays and collections +* Allow sending transformers to the `success` method +* Allow sending resources as data to the `success` method +* Added a `only` method to `SuccessResponseBuilder` to replicate Fractal's `parseFieldsets` +* Added a `cursor` method to `SuccessResponseBuilder` for setting cursors +* Added a `paginator` method to `SuccessResponseBuilder` for setting paginators +* Added a `without` method to `SuccessResponseBuilder` to replicate Fractal's `parseExcludes` +* Relationships are now automatically eager loaded +* Changed `with` method to allow eager loading closures +* Added a `filter_fields_parameter` configuration option for automatic data filtering +* Added a `PageNotFoundException` exception +* Added a `page_not_found` default error code +* Added a `ConvertToSnakeCase` middleware to convert request parameters to snake case +* Added a `Flugg\Responder\Transformer` service to transform without serializing +* Added a `Transformer` facade to transform without serializing +* Added a `transform` helper method to transform without serializing +* Added a `NullSerializer` serializer to serialize without modifying the data +* Added an `ErrorSerializer` contract for serializing errors +* Added a default `Flugg\Responder\Serializers\ErrorSerializer` +* Added a `$load` property to transformers to replicate Fractal's `$defaultIncludes` +* Added a dynamic method in transformers to filter relations: `filterRelationName` +* Allow converting custom exceptions using the `convert` method of the `ConvertsExceptions` trait +* Added a shortcut `-m` to the `--model` modifier of the `make:transformer` command +* Added a `--plain` (and `-p`) option to `make:transformer` to make plain transformers +* Added possibility to bind transformers to models using the `TransformerResolver` class +* Added possibility to bind error messages to error codes using tne `ErrorMessageResolver` class +* Decoupled Fractal from the package by introducing a `TransformFactory` adapter +* Changed `success` to transform using an item resource if passed a has-one relation +* Added a `resource` method to the base `Transformer` for creating related resources + +### Bug Fixes + +* Remove extra field added from deeply nested relations (fixes #33) +* Relations are not eager loaded when automatically including relations (fixes #48) + +### Performance Improvements + +* Add a new caching layer to transformers, increasing performance with deeply nested relations +* The relation inclusion code has been drastically improved \ No newline at end of file diff --git a/composer.json b/composer.json index 0c45118..018df0c 100644 --- a/composer.json +++ b/composer.json @@ -1,15 +1,14 @@ { "name": "flugger/laravel-responder", - "description": "A Laravel package for APIs, wrapping the Fractal library behind an elegant Laravel API.", + "description": "A Fractal Laravel package for building API responses, giving you the power of Fractal and the elegancy of Laravel.", "keywords": [ "laravel", "lumen", - "responder", + "fractal", + "transformer", "api", - "json", "response", - "fractal", - "transformer" + "responder" ], "homepage": "https://github.com/flugger/laravel-responder", "license": "MIT", @@ -21,25 +20,43 @@ ], "require": { "php": "^7.0", - "illuminate/support": "5.1.* || 5.2.* || 5.3.* || 5.4.*", - "league/fractal": ">=0.14.0" + "illuminate/contracts": "5.1.* || 5.2.* || 5.3.* || 5.4.* || 5.5.*", + "illuminate/support": "5.1.* || 5.2.* || 5.3.* || 5.4.* || 5.5.*", + "league/fractal": "^0.16.0" }, "require-dev": { - "phpunit/phpunit": "^5.4", - "illuminate/database": "5.1.* || 5.2.* || 5.3.*", + "illuminate/database": "5.4.*", "orchestra/testbench": "~3.0", "mockery/mockery": "^0.9.5", "fzaninotto/faker": "^1.6", - "doctrine/dbal": "^2.5" + "doctrine/dbal": "^2.5", + "phpunit/phpunit": "^6.1" }, "autoload": { "psr-4": { "Flugg\\Responder\\": "src" - } + }, + "files": [ + "src/helpers.php" + ] }, "autoload-dev": { "psr-4": { "Flugg\\Responder\\Tests\\": "tests" } + }, + "config": { + "sort-packages": true + }, + "extra": { + "laravel": { + "providers": [ + "Flugg\\Responder\\ResponderServiceProvider" + ], + "aliases": { + "Responder": "Flugg\\Responder\\Facades\\Responder", + "Transformer": "Flugg\\Responder\\Facades\\Transformer" + } + } } } diff --git a/config/responder.php b/config/responder.php new file mode 100644 index 0000000..675b2a1 --- /dev/null +++ b/config/responder.php @@ -0,0 +1,76 @@ + [ + 'success' => Flugg\Responder\Serializers\SuccessSerializer::class, + 'error' => \Flugg\Responder\Serializers\ErrorSerializer::class, + ], + + /* + |-------------------------------------------------------------------------- + | Response Decorators + |-------------------------------------------------------------------------- + | + | Response decorators are used to decorate both your success- and error + | responses. A decorator can be disabled by removing it from the list + | below. You may additionally add your own decorators to the list. + | + */ + + 'decorators' => [ + \Flugg\Responder\Http\Responses\Decorators\StatusCodeDecorator::class, + \Flugg\Responder\Http\Responses\Decorators\SuccessFlagDecorator::class, + ], + + /* + |-------------------------------------------------------------------------- + | Autoload Relationships With Query String + |-------------------------------------------------------------------------- + | + | The package can automatically load relationships from the query string + | and will look for a query string parameter with the name configured + | below. You can set the value to null to disable the autoloading. + | + */ + + 'load_relations_parameter' => 'with', + + /* + |-------------------------------------------------------------------------- + | Filter Fields With Query String + |-------------------------------------------------------------------------- + | + | The package can automatically filter the fields of transformed data + | from a query string parameter configured below. The technique is + | also known as sparse fieldsets. Set it to null to disable it. + | + */ + + 'filter_fields_parameter' => 'only', + + /* + |-------------------------------------------------------------------------- + | Recursion Limit + |-------------------------------------------------------------------------- + | + | When transforming data, you may be including relations recursively. + | By setting the value below, you can limit the amount of times it + | should include relationships recursively. Five might be good. + | + */ + + 'recursion_limit' => 5, + +]; \ No newline at end of file diff --git a/license.md b/license.md index c0d9377..6dc51d0 100644 --- a/license.md +++ b/license.md @@ -1,6 +1,6 @@ # The MIT License (MIT) -Copyright (c) 2016 Alexander Tømmerås +Copyright (c) 2017 Alexander Tømmerås > Permission is hereby granted, free of charge, to any person obtaining a copy > of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ Copyright (c) 2016 Alexander Tømmerås > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -> SOFTWARE. +> SOFTWARE. \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml index ed163e9..59d69e4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,6 +1,7 @@ - - ./tests/ + + ./tests/Unit + + + ./tests/Feature + + + ./src + + \ No newline at end of file diff --git a/readme.md b/readme.md index 1c93301..b3b45ec 100644 --- a/readme.md +++ b/readme.md @@ -1,858 +1,992 @@ -# Laravel Responder +

-[![Latest Stable Version](https://poser.pugx.org/flugger/laravel-responder/v/stable?format=flat-square)](https://github.com/flugger/laravel-responder) -[![Packagist Downloads](https://img.shields.io/packagist/dt/flugger/laravel-responder.svg?style=flat-square)](https://packagist.org/packages/flugger/laravel-responder) -[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](license.md) -[![Build Status](https://img.shields.io/travis/flugger/laravel-responder/master.svg?style=flat-square)](https://travis-ci.org/flugger/laravel-responder) -[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/flugger/laravel-responder.svg?style=flat-square)](https://scrutinizer-ci.com/g/flugger/laravel-responder/?branch=master) +

+ Latest Stable Version + Packagist Downloads + Software License + Build Status + Code Quality + Test Coverage + Donate +

-![Laravel Responder](http://goo.gl/HvmX4j) +Laravel Responder is a package for building API responses, integrating [Fractal](https://github.com/thephpleague/fractal) into Laravel and Lumen. It can transform your data using transformers, create and serialize success- and error responses, handle exceptions and assist you with testing your responses. -Laravel Responder is a package for your JSON API, integrating [Fractal](https://github.com/thephpleague/fractal) into Laravel and Lumen. It can [transform](http://fractal.thephpleague.com/transformers) your Eloquent models and [serialize](http://fractal.thephpleague.com/serializers) your success responses, but it can also help you build error responses, handle exceptions and integration test your API. +# Table of Contents -## Table of Contents - -- [Philosophy](#philosophy) +- [Introduction](#philosophy) - [Requirements](#requirements) - [Installation](#installation) - [Usage](#usage) - - [Accessing the Responder](#accessing-the-responder) - - [Success Responses](#success-responses) - - [Transformers](#transformers) - - [Serializers](#serializers) - - [Error Responses](#error-responses) - - [Exceptions](#exceptions) - - [Testing Helpers](#testing-helpers) -- [Configuration](#configuration) + - [Creating Responses](#creating-responses) + - [Creating Success Responses](#creating-success-responses) + - [Creating Error Responses](#creating-error-responses) + - [Creating Transformers](#creating-transformers) + - [Transforming Data](#creating-transformers) + - [Handling Exceptions](#handling-exceptions) + - [Testing Responses](#testing-responses) - [Contributing](#contributing) +- [Donating](#contributing) - [License](#license) -## Philosophy - -When building powerful APIs, you want to make sure your endpoints are consistent and easy to consume by your application. Laravel is a great fit your API, however, it lacks support for common tools like transformers and serializers. Fractal, on the other hand, has some great tools for building APIs and fills in the gaps of Laravel. +# Introduction -While Fractal solves many of the shortcomings of Laravel, it's often a bit cumbersome to integrate into the framework. Take this example from a controller: +Laravel lets you return models directly from a controller method to convert it to JSON. This is a quick way to build APIs but leaves your database columns exposed. [Fractal](https://fractal.thephpleague.com), a popular PHP package from [The PHP League](https://thephpleague.com/), solves this by introducing transformers. However, it can be a bit cumbersome to integrate into the framework as seen below: ```php public function index() { - $manager = new Manager(); - $resource = new Collection(User::get(), new UserTransformer(), 'users'); + $resource = new Collection(User::all(), new UserTransformer()); - return response()->json($manager->createData($resource)->toArray()); + return response()->json((new Manager)->createData($resource)->toArray()); } ``` -I admit, the Fractal manager could be moved outside the controller and you could return the array directly. However, as soon as you want a different status code than the default `200`, you probably need to wrap it in a `response()->json()` anyway. - -The point is, we all get a little spoiled by Laravel's magic. Wouldn't it be sweet if the above could be written as following: +Not _that_ bad, but we all get a little spoiled by Laravel's magic. Wouldn't it be better if we could refactor it to: ```php public function index() { - return responder()->success(User::all()); + return responder()->success(User::all())->respond(); } ``` -The package will call on Fractal behind the scenes to automatically transform and serialize the data. No longer will you have to instantiate different Fractal resources depending on if it's a model or a collection, the package deals with all of it automatically under the hood. +The package will allow you to do this and much more. The goal has been to create a high-quality package that feels like native Laravel. A package that lets you embrace the power of Fractal, while hiding it behind beautiful abstractions. There has also been put a lot of focus and thought to the documentation. Happy exploration! -## Requirements +# Requirements This package requires: - PHP __7.0__+ - Laravel __5.1__+ or Lumen __5.1__+ -## Installation +# Installation -Install the package through Composer: +To get started, install the package through Composer: ```shell composer require flugger/laravel-responder ``` -### Laravel +## Laravel -#### Registering the Service Provider +#### Register Service Provider -After updating Composer, append the following service provider to the `providers` key in `config/app.php`: +Append the following line to the `providers` key in `config/app.php` to register the package: ```php -Flugg\Responder\ResponderServiceProvider::class +Flugg\Responder\ResponderServiceProvider::class, ``` -#### Registering the Facade +*** +_The package supports auto-discovery, so if you use Laravel 5.5 or later you may skip registering the service provider and facades and instead run `php artisan package:discover`._ +*** -If you like facades, you may also append the `Responder` facade to the `aliases` key: +#### Register Facades _(optional)_ + +If you like facades, you may also append the `Responder` and `Transformer` facades to the `aliases` key: ```php -'Responder' => Flugg\Responder\Facades\Responder::class +'Responder' => Flugg\Responder\Facades\Responder::class, +'Transformer' => Flugg\Responder\Facades\Transformer::class, ``` -#### Publishing Package Assets +#### Publish Package Assets _(optional)_ -You may also publish the package configuration and language file using the Artisan command: +You may additionally publish the package configuration and language file using the `vendor:publish` Artisan command: ```shell php artisan vendor:publish --provider="Flugg\Responder\ResponderServiceProvider" ``` -This will publish a `responder.php` configuration file in your `config` folder. - -It will also publish an `errors.php` file inside your `lang/en` folder which is used to store your error messages. +This will publish a `responder.php` configuration file in your `config` folder. It will also publish an `errors.php` file inside your `lang/en` folder which can be used for storing error messages. -### Lumen +## Lumen -#### Registering the Service Provider +#### Register Service Provider -Register the package service provider by adding the following line to `app/bootstrap.php`: +Add the following line to `app/bootstrap.php` to register the package: ```php $app->register(Flugg\Responder\ResponderServiceProvider::class); ``` -#### Registering the Facade +#### Register Facades _(optional)_ -You may also add the following line to `app/bootstrap.php` to register the optional facade: +You may also add the following lines to `app/bootstrap.php` to register the facades: ```php class_alias(Flugg\Responder\Facades\Responder::class, 'Responder'); +class_alias(Flugg\Responder\Facades\Transformer::class, 'Transformer'); ``` -*** -_Remember to uncomment `$app->withFacades();` to enable facades in Lumen._ -*** - -#### Publishing Package Assets +#### Publish Package Assets _(optional)_ -There is no `php artisan vendor:publish` in Lumen, you will therefore have to create your own `config/responder.php` file, if you want to configure the package. Do also note that unlike Laravel there is no `resources/lang` folder, however, you're free to create a `resources/lang/en/errors.php` manually and it will be picked up by the package. +Seeing there is no `vendor:publish` command in Lumen, you will have to create your own `config/responder.php` file if you want to configure the package. -## Usage +# Usage -The package has a `Flugg\Responder\Responder` service which is responsible for building success and error responses for your API. +This documentation assumes some knowledge of how [Fractal](https://github.com/thephpleague/fractal) works. -### Accessing the Responder +## Creating Responses -Before you can start making JSON responses, you need to access the responder service. In good Laravel spirit you have multiple ways of doing the same thing: +The package has a `Responder` service class, which has a `success` and `error` method to build success- and error responses respectively. To use the service and begin creating responses, pick one of the options below: -#### Option 1: Dependency Injection +#### Option 1: Inject `Responder` Service -You may inject the responder service directly into your controller to create success responses: +You may inject the `Flugg\Responder\Responder` service class directly into your controller methods: ```php public function index(Responder $responder) { - return $responder->success(User::all()); + return $responder->success(); } ``` -You may also create error responses: +You can also use the `error` method to create error responses: ```php -return $responder->error('invalid_user'); +return $responder->error(); ``` -#### Option 2: Facade +#### Option 2: Use `responder` Helper + +If you're a fan of Laravel's `response` helper function, you may like the `responder` helper function: + +```php +return responder()->success(); +``` +```php +return responder()->error(); +``` + +#### Option 3: Use `Responder` Facade Optionally, you may use the `Responder` facade to create responses: ```php -return Responder::success(User::all()); +return Responder::success(); ``` ```php -return Responder::error('invalid_user'); +return Responder->error(); ``` -#### Option 3: Helper Method +#### Option 4: Use `MakesApiResponses` Trait -Additionally, you can use the `responder()` helper method if you're fan of Laravel's `response()` helper method: +Lastly, the package provides a `Flugg\Responder\Http\MakesResponses` trait you can use in your controllers: ```php -return responder()->success(User::all()); +return $this->success(); ``` + ```php -return responder()->error('invalid_user'); +return $this->error(); ``` -Both the helper method and the facade are just different ways of accessing the responder service, so you have access to the same methods. +*** +_Which option you pick is up to you, they are all equivalent, the important thing is to stay consistent. The helper function (option 2) will be used for the remaining of the documentation._ +*** -#### Option 4: Trait +### Building Responses -Lastly, the package also has a `Flugg\Responder\Traits\RespondsWithJson` trait you can use in your base controller. +The `success` and `error` methods return a `SuccessResponseBuilder` and `ErrorResponseBuilder` respectively, which both extend an abstract `ResponseBuilder`, giving them common behaviors. They will be converted to JSON when returned from a controller, but you can explicitly create an instance of `Illuminate\Http\JsonResponse` with the `respond` method: -The trait gives you access to `successResponse()` and `errorResponse()` methods in your controllers: +```php +return responder()->success()->respond(); +``` ```php -return $this->successResponse(User::all()); +return responder()->error()->respond(); ``` + +The status code is set to `200` by default, but can be changed by setting the first parameter. You can also pass a list of headers as the second argument: + ```php -return $this->errorResponse('invalid_user'); +return responder()->success()->respond(201, ['x-foo' => true]); ``` -These methods call on the responder service behind the scene. +```php +return responder()->error()->respond(404, ['x-foo' => false]); +``` *** -_As described above, you may build your responses in multiple ways. Which way you choose is up to you, the important thing is to stay consistent. We will use the facade for the remaining of the documentation for simplicity's sake._ +_Consider always using the `respond` method for consistency's sake._ *** -### Success Responses +### Casting Response Data -The responder service has a `success()` method you can use to quickly generate a successful JSON response: +Instead of converting the response to a `JsonResponse` using the `respond` method, you can cast the response data to a few other types, like an array: ```php -public function index() -{ - return Responder::success(User::all()); -} +return responder()->success()->toArray(); ``` -This method returns an instance of `\Illuminate\Http\JsonResponse` and will transform and serialize the data before wrapping it in a JSON response. - -#### Setting Transformation Data +```php +return responder()->error()->toArray(); +``` -The first argument of the `success` method is the transformation data. The transformation data will be transformed if a transformer is set, and must be one of the following types: +You also have a `toCollection` and `toJson` method at your disposal. -##### Eloquent Model +### Decorating Response -You may pass in a model as the transformation data: +A response decorator allows for last minute changes to the response before it's returned. The package comes with two response decorators out of the box adding a `status` and `success` field to the response output. The `decorators` key in the configuration file defines a list of all enabled response decorators: ```php -return Responder::success(User::first()); +'decorators' => [ + \Flugg\Responder\Http\Responses\Decorators\StatusCodeDecorator::class, + \Flugg\Responder\Http\Responses\Decorators\SuccessFlagDecorator::class, +], ``` -##### Collection - -You may also pass in a collection of models: +You may disable a decorator by removing it from the list, or add your own decorator extending the abstract class `Flugg\Responder\Http\Responses\Decorators\ResponseDecorator`. You can also add additional decorators per response: ```php -return Responder::success(User::all()); +return responder()->success()->decorator(ExampleDecorator::class)->respond(); +``` +```php +return responder()->error()->decorator(ExampleDecorator::class)->respond(); ``` -##### Array +## Creating Success Responses -You may also pass in an array of models: +As briefly demonstrated above, success responses are created using the `success` method: ```php -return Responder::success([User::find(1), User::find(2)]); +return responder()->success()->respond(); ``` -*** -_The array must contain actual model instances, meaning you cannot use `User::all()->toArray()` as transformation data._ -*** +Assuming no changes have been made to the configuration, the above code would output the following JSON: -##### Query Builder +```json +{ + "status": 200, + "success": true, + "data": null +} +``` -Instead of turning it into a collection, you may pass in a query builder directly: +### Setting Response Data + +The `success` method takes the response data as the first argument: ```php -return Responder::success(User::where('id', 1)); +return responder()->success(Product::all())->respond(); ``` -The package will then automatically add info pagination data to the response defined by the serializer. - -##### Paginator +It accepts the same data types as you would normally return from your controllers, however, it also supports query builder and relationship instances: -Additionally, you may limit the amount of items by passing in a paginator: +```php +return responder()->success(Product::where('id', 1))->respond(); +``` ```php -return Responder::success(User::paginate(5)); +return responder()->success(Product::first()->shipments())->respond(); ``` -The package will then automatically add info pagination data to the response, depending on which [serializer](#serializes) you use. +*** +_The package will run the queries and convert them to collections behind the scenes._ +*** + +### Transforming Response Data -##### Relation +The response data will be transformed with Fractal if you've attached a transformer to the response. There are two ways to attach a transformer; either _explicitly_ by setting it on the response, or _implicitly_ by binding it to a model. Let's look at both ways in greater detail. -You can also pass in an Eloquent relationship instance: +#### Setting Transformer On Response + +You can attach a transformer to the response by sending a second argument to the `success` method. For instance, below we're attaching a simple closure transformer, transforming a list of products to only output their names: ```php -return Responder::success(User::first()->roles()); +return responder()->success(Product::all(), function ($product) { + return ['name' => $product->name]; +})->respond(); ``` -#### Including Relations +You may also transform using a dedicated transformer class: -When using Fractal, you include relations using the `parseIncludes()` method on the manager, and add the available relations to the `$availableIncludes` array in your transformer. - -With Laravel Responder you don't have to do any of these things. It integrates neatly with Eloquent and automatically parses loaded relations from the model: +```php +return responder()->success(Product::all(), ProductTransformer::class)->respond(); +``` ```php -return Responder::success(User::with('roles.permissions')->get()); +return responder()->success(Product::all(), new ProductTransformer)->respond(); ``` -You can also let the package automatically parse and include relations from a given GET parameter, just make sure you set the `load_relations_from_parameter` key in the configuration. +*** +_You can read more about creating dedicated transformer classes in the [Creating Transformers](#creating-transformers) chapter._ +*** -#### Setting Status Codes +#### Binding Transformer To Model -The status code is set to `200` by default, but can easily be changed by adding a second argument to the `success()` method: +If no transformer is set, the package will search the response data for an element implementing the `Flugg\Responder\Contracts\Transformable` interface to resolve a transformer from. You can take use of this by implementing the `Transformable` interface in your models: ```php -return Responder::success(User::all(), 201); +class Product extends Model implements Transformable {} ``` -Sometimes you may not want to return anything. In that case you may either pass in null as the first argument or skip it entirely: +You can satisfy the contract by adding a `transformer` method that returns the corresponding transformer: ```php -return Responder::success(201); +/** + * Get a transformer for the class. + * + * @return \Flugg\Responder\Transformers\Transformer|string|callable + */ +public function transformer() +{ + return ProductTransformer::class; +} ``` -#### Adding Meta Data +*** +_You're not limited to returning a class name string, you can return a transformer instance or closure transformer, just like the second parameter of the `success` method._ +*** -You may want to pass in additional meta data to the response, you can do so by adding an additional third argument: +Instead of implementing the `Transformable` contract for all models, an alternative approach is to bind the transformers using the `bind` method on the `TransformerResolver` class. You can place the code below within `AppServiceProvider` or an entirely new `TransformerServiceProvider`: ```php -return Responder::success(User::all(), 200, ['foo' => 'bar']); +use Flugg\Responder\Transformers\TransformerResolver; + +public function boot() +{ + $this->app->make(TransformerResolver)->bind([ + \App\Product::class => \App\Transformers\ProductTransformer::class, + \App\Shipment::class => \App\Transformers\ShipmentTransformer::class, + ]); +} ``` -You may also omit the status code if you want to send a default `200` response: +After you've bound a transformer to a model you can skip the second parameter and still transform the data: ```php -return Responder::success(User::all(), ['foo' => 'bar']); +return responder()->success(Product::all())->respond(); ``` -### Transformers - -A transformer is responsible for transforming your Eloquent models into an array for your API. A transformer may be associated with a model, which means your data will be automatically transformed without having to specify a transformer. - *** -_You may read more about how the mapping between a model and transformer work [a few chapters below](#mapping-transformers-to-models)._ +_As you might have noticed, unlike Fractal, you don't need to worry about creating resource objects like `Item` and `Collection`. The package will make one for you based on the data type, however, you may wrap your data in a resource to override this._ *** -#### Transforming Data +### Paginating Response Data -When using the `success()` method, the package will try to resolve a transformer from the model in the transformation data. If no transformer is found, the model's `toArray()` fields will be returned instead. - -If you want to be explicit about which transformer to use, you may call the `transform()` method on the responder service: +Sending a paginator to the `success` method will set pagination meta data and transform the data automatically, as well as append any query string parameters to the paginator links. ```php -return Responder::transform(User::all(), new UserTransformer)->respond(); +return responder()->success(Product::paginate())->respond(); ``` -Instead of using a full-blown transformer class, you may also pass in a closure: +Assuming there are no products and the default configuration is used, the JSON output would look like: -```php -return Responder::transform(User::all(), function ($user) { - return [ - 'id' => (int) $user->id, - 'email' => (string) $user->email - ]; -})->respond(); +```json +{ + "success": true, + "status": 200, + "data": null, + "pagination": { + "total": 0, + "count": 0, + "perPage": 15, + "currentPage": 1, + "totalPages": 1 + } +} ``` -If you don't pass in a transformer, it will behave in the same way as the `success()` method: +#### Setting Paginator On Response + +Instead of sending a paginator as data, you may set the data and paginator seperately, like you traditionally would with Fractal. You can manually set a paginator using the `paginator` method, which expects an instance of `League\Fractal\Pagination\IlluminatePaginatorAdapter`: ```php -return Responder::transform(User::all())->respond(); +$paginator = Product::paginate(); +$adapter = new IlluminatePaginatorAdapter($paginator); + +return responder()->success($paginator->getCollection())->paginator($adapter)->respond(); ``` -Unlike the `success()` method, the `transform()` method returns an instance of `Flugg\Responder\Http\SuccessResponseBuilder`, which is why we chain our call with `respond()` to convert it to an `Illuminate\Http\JsonResponse`. +#### Setting Cursor On Response -You can also set the status code or headers using the `respond()` method: +You can also set cursors using the `cursor` method, expecting an instance of `League\Fractal\Pagination\Cursor`: ```php -return Responder::transform(User::all())->respond(201, ['x-foo' => 'bar']); -``` +if ($request->has('cursor')) { + $products = Product::where('id', '>', request()->cursor)->take(request()->limit)->get(); +} else { + $products = Product::take(request()->limit)->get(); +} -You may additionally add any meta data using the `addMeta()` method: +$cursor = new Cursor(request()->cursor, request()->previous, $products->last()->id ?? null, Product::count()); -```php -return Responder::transform(User::all())->addMeta(['foo' => 'bar'])->respond(); +return responder()->success($products)->cursor($cursor)->respond(); ``` -*** -_As you might have guessed, the `Responder::success($data, $status, $meta)` method is just a shorthand for calling `Responder::transform($data)->addMeta($meta)->respond($status)`._ -*** +### Including Relationships -By using the `serializer()` method you can also explicitly set the serializer: +If a transformer class is attached to the response, you can include relationships using the `with` method: ```php -return Responder::transform(User::all())->serializer(new JsonApiSerializer)->respond(); +return responder()->success(Product::all())->with('shipments')->respond(); ``` -You may also manually include relations by using the `include()` method, which is just a wrapper for Fractal's own `parseIncludes()` method. +You can send multiple arguments and specify nested relations using dot notation: ```php -return Responder::transform(User::all())->include('roles.permissions')->toArray(); +return responder()->success(Product::all())->with('shipments', 'orders.customer')->respond(); ``` -Instead of using `respond()`, you may also convert it to a few other types: +All relationships will be automatically eager loaded, and just like you would when using `with` or `load` to eager load with Eloquent, you may use a callback to specify additional query constraints. Like in the example below, where we're only including related shipments that hasn't yet been shipped: ```php -return Responder::transform(User::all())->toArray(); +return responder()->success(Product::all())->with(['shipments' => function ($query) { + $query->whereNull('shipped_at'); +}])->respond(); ``` -```php -return Responder::transform(User::all())->toCollection(); + +#### Including From Query String + +Relationships are loaded from a query string parameter if the `load_relations_parameter` configuration key is set to a string. By default, it's set to `with`, allowing you to automatically include relations from the query string: + ``` +GET /products?with=shipments,orders.customer +``` + +#### Excluding Default Relations + +In your transformer classes, you may specify relations to automatically load. You may disable any of these relations using the `without` method: + ```php -return Responder::transform(User::all())->toJson(); +return responder()->success(Product::all())->without('comments')->respond(); ``` -You can also retrieve the Fractal resource or manager instances: +### Filtering Transformed Data + +The technique of filtering the transformed data to only return what we need is called sparse fieldsets and can be specified using the `only` method: ```php -return Responder::transform(User::all())->getResource(); +return responder()->success(Product::all())->only('id', 'name')->respond(); ``` + +When including relationships, you may also want to filter fields on related resources as well. This can be done by instead specifying an array where each key represents the resource keys for the resources being filtered + ```php -return Responder::transform(User::all())->getManager(); +return responder()->success(Product::all())->with('shipments')->only([ + 'products' => ['id', 'name'], + 'shipments' => ['id'] +])->respond(); ``` -#### Creating Transformers +#### Filtering From Query String -The package gives you an Artisan command you can use to quickly whip up new transformers: +Fields will automatically be filtered if the `filter_fields_parameter` configuration key is set to a string. It defaults to `only`, allowing you to filter fields from the query string: -```shell -php artisan make:transformer UserTransformer +``` +GET /products?only=id,name ``` -This will create a new `UserTransformer.php` in the `app/Transformers` folder. +You may automatically filter related resources by setting the parameter to a key-based array: + +``` +GET /products?with=shipments&only[products]=id,name&only[shipments]=id +``` -It will automatically resolve what model to inject from the name. For instance, in the example above the package will extract `User` from `UserTransformer` and assume the models live directly in the app folder (as per Laravel's default). +### Adding Meta Data -If you store your models somewhere else you may also use the `--model` option to specify model path: +You may want to attach additional meta data to your response. You can do this using the `meta` method: -```shell -php artisan make:transformer UserTransformer --model="App\Models\User" +```php +return responder()->success(Product::all())->meta(['count' => Product::count()])->respond(); ``` -You can also use the `--pivot` option to include an additional `transformPivot()` method to transform the model's pivot table: +When using the default serializer, the meta data will simply be appended to the response array: -```shell -php artisan make:transformer UserTransformer --pivot +```json +{ + "success": true, + "status": 200, + "data": [], + "count": 0 +} ``` -#### Set Available Relations +### Serializing Response Data -Just like you can set an `$availableIncludes` using Fractal, you have a `$relations` property on your transformers. By default it will allow all relations: +After the data has been transformed, it will be serialized using the specified success serializer in the configuration file, which defaults to the package's own `Flugg\Responder\Serializers\SuccessSerializer`. You can overwrite this on your responses using the `serializer` method: ```php -protected $relations = ['*']; +return responder()->success()->serializer(JsonApiSerializer::class)->respond(); ``` -You can also optionally create a method for every relation in your transformers, if you want to filter based on the parsed parameters. For instance, if you have a `roles` include, you can make a `roles()` method: - ```php -public function roles(User $user, ParamBag $paramBag) -{ - // -} +return responder()->success()->serializer(new JsonApiSerializer())->respond(); ``` -*** -_Do note how the `roles()` method takes in a `User` model as first argument, you can learn more about it in [Fractal's documentation](http://fractal.thephpleague.com/transformers/)._ -*** +Above we're using Fractal's `JsonApiSerializer` class. Fractal also ships with an `ArraySerializer` and `DataArraySerializer` class. If none of these suit your taste, feel free to create your own serializer by extending `League\Fractal\Serializer\SerializerAbstract`. You can read more about it in [Fractal's documentation](http://fractal.thephpleague.com/serializers/). + +## Creating Transformers -#### Mapping Transformers to Models +A dedicated transformer class gives you a convenient location to transform data and allows you to use the same transformer multiple times. It also allows you to include and transform relationships. You can create a transformer using the `make:transformer` Artisan command: -In a lot of cases you want to use the same transformer everytime you refer to a model. Instead of passing in a transformer for every response, you can map a transformer to a model, so the model is automatically transformed. +```shell +php artisan make:transformer ProductTransformer +``` -To map a transformer to a model, your model needs to implement `Flugg\Responder\Contracts\Transformable`. The interface requires a static `transformer()` method, which should return a transformer: +The command will generate a new `ProductTransformer.php` file in the `app/Transformers` folder: ```php -class Role extends Model implements Transformable + (int) $product->id, + ]; } } ``` -The `transformer()` method can also return a closure transformer: +It will automatically resolve a model name from the name provided. For instance, in the example above, the package will extract `Product` from `ProductTransformer` and assume the models live directly in the `app` folder (as per Laravel's convention). If you store them somewhere else, you can use the `--model` (or `-m`) option to override it: -```php -public static function transformer() -{ - return function ($user) { - return [ - 'id' => (int) $user->id, - 'email' => (string) $user->email - ]; - }; -} +```shell +php artisan make:transformer ProductTransformer --model="App\Models\Product" +``` + +#### Creating Plain Transformers + +The transformer file generated above is a model transformer expecting an `App\Product` model for the `transform` method. However, we can create a plain transformer by applying the `--plain` (or `-p`) modifier: + +```shell +php artisan make:transformer ProductTransformer --plain ``` -### Serializers +This will remove the typehint from the `transform` method and add less boilerplate code. -After your models have been transformed, the data will be serialized using the set serializer. The serializer structures your data output in a certain way, but it can also add additional data like pagination and meta data. +### Including Relationships -#### Default Serializer +All transformers generated through the `make:transformer` command will include a `$relations` and `$load` property, which are the equivalent to Fractal's `$availableIncludes` and `$defaultIncludes`. Fractal also requires you to to create methods in your transformer for all included relation. While this package also allows you to create such methods, it doesn't require it if you're transforming models. -The package brings it own default serializer, `Flugg\Responder\Serializers\ApiSerailizer`. Below is an example response, given a user with a related role: +For instance, if you're including a `shipments` relation in a `ProductTransformer`, the package will assume you have a `shipments` relationship method in your `Product` model and automatically fetch the relation. You can overwrite this by creating an `includeShipments` method in `ProductTransformer`, just like you would with Fractal: -```json +```php +/** + * Include related shipments. + * + * @param \App\Product $product + * @param array|null $parameters + * @return \League\Fractal\ResourceInterface + */ +public function includeShipments(Product $product, array $parameters = null) { - "status": 200, - "success": true, - "data": { - "id": 1, - "email": "example@email.com", - "role": { - "name": "admin" - } - } + return $this->resource($product->shipments); } ``` -The response output is quite similar to Laravel's default, except it wraps the data inside a `data` field. It also includes a `success` field to quickly tell the user if the request was successful or not. +The `resource` method used above replaces Fractal's `item` and `collection` methods in the Transformer for creating a resource. It will automatically figure out wether it should be an item or collection resource based on the data. It will also resolve a transformer from the `Shipment` model, if a transformer binding is set, just like the `success` method. In fact, it accepts the exact same arguments as the `success` method: + +```php +return $this->resource($product->shipments, new ShipmentTransformer); +``` *** -_The `status` field is actually not part of the default serializer, but instead added by the package after serializing the data. You can disable this in the [configuration file](#configuration)._ +_You should be careful with executing any new database calls inside the include methods as you might end up with an unexpected amount of hits to the database._ *** -#### Fractal Serializers +#### Setting Available Relationships -If the default serializer is not your cup of tea, you can easily swap it out with one of the three serializers included with Fractal. +The `$relations` property specifies a list of relations available to be included. When you generate a transformer, the `$relations` property will be equal to a wildcard, allowing all relations on the transformer: -##### ArraySerializer +```php +protected $relations = ['*']; +``` -The above example would look like the following using `League\Fractal\Serializers\ArraySerializer`: +If you only want to whitelist certain relations, you can instead set a list of relations you want to make available: -```json -{ - "id": 1, - "email": "example@email.com", - "role": { - "name": "admin" - } -} +```php +protected $relations = ['shipments', 'orders']; ``` -##### DataArraySerializer +*** +_**Security warning:** Since the transformer doesn't know what relations exists on a model unless you specify it in `$relations`, you're technically allowing calls to any method on your model when using a wildcard. You should therefore consider always specifying a whitelist._ +*** -You can also add the `data` field using `League\Fractal\Serializers\DataArraySerializer`: +#### Setting Default Relationships -```json -{ - "data": { - "id": 1, - "email": "example@email.com", - "role": { - "data": { - "name": "admin" - } - } - } -} +The `$load` property specifies a list of relations to be autoloaded every time you transform data with the transformer. By mapping a transformer to the relation the package will also be able to automatically eager load all default relations, including nested ones: + +```php +protected $load = [ + 'shipments' => ShipmentTransformer::class, + 'orders' => OrderTransformer::class, +]; +``` + +If you're transforming non-model data or don't care about the eager loading, you can skip the transformer mapping and just specify a list of relations: + +```php +protected $load = ['shipments', 'orders']; ``` *** -_Note how the `data` field applies to every relation as well in this case, unlike the default package serializer._ +_You don't have to add relations to both `$relations` and `$load`, all relations in `$load` will be available by nature._ *** -##### JsonApiSerializer +### Filtering Relationships -Fractal also has a representation of the [JSON-API](http://jsonapi.org/) standard, using `League\Fractal\Serializers\JsonApiSerializer`: +After a relation has been included, you can make any last second changes to it using a filter method. For instance, below we're filtering the list of related shipments to only include shipments that has not been shipped: -```json +```php +/** + * Filter included shipments. + * + * @param \Illuminate\Database\Eloquent\Collection $shipments + * @return \Illuminate\Support\Collection + */ +public function filterShipments($shipments) { - "data": { - "type": "users", - "id": 1, - "attributes": { - "email": "example@email.com" - }, - "relationships": { - "role": { - "data": { - "type": "roles", - "id": 1 - } - } - } - }, - "included": { - "role": { - "type": "roles", - "id": 1, - "attributes": { - "name": "admin" - } - } - } + return $shipments->filter(function ($shipment) { + return is_null($shipment->shipped_at); + }); } ``` -As you can see, quite more verbose, but it definitely has its uses. +## Transforming Data -#### Custom Serializers +We've already looked at how to transform data when creating success responses, however, you may want to transform data in other places than your controllers. An example of when you would want to transform data is in your broadcasted events. You're exposing data using websockets instead of HTTP, but you still want to receive the same transformed data in your frontend. -If none of the above serializers suit your taste, feel free to create your own and set the `serializer` key in the configuration file to point to your serializer class. You can read more about how to create your own serializer in [Fractal's documentation](http://fractal.thephpleague.com/serializers/). +#### Option 1: The `transform` Helper -### Error Responses +You can use the `transform` helper function to transform data without creating a response: -Just like success responses, you can equally easy generate error responses when something does not go as planned, using the `error()` method: +```php +transform(Product::all()); +``` + +Unlike the `success` method, this wont serialize the data. However, it will resolve a transformer from the model if a binding is set, and you can overwrite the transformer by setting a second parameter. You can also specify a list of included relations as a third argument: ```php -public function index() -{ - return Responder::error(); -} +transform(Product::all(), new ProductTransformer, ['comments']); ``` -Just like with success responses, this method returns an instance of `\Illuminate\Http\JsonResponse`, the above would return the following JSON: +In addition, if you want to blacklist any of the default loaded relations, you can fill the fourth parameter: -```json -{ - "status": 500, - "success": false, - "error": null -} +```php +transform(Product::all(), new ProductTransformer, ['shipments'], ['orders']); ``` -#### Setting Error Codes +#### Option 2: The `Transformer` Facade -The first parameter of the `error()` method is the error code, which can be any string value: +Instead of using the `transform` helper function, you can use the `Transformer` facade to do the same thing: ```php -if (request()->has('bomb')) { - return Responder::error('bomb_found'); -} +Transformer::transform(Product::all(), new ProductTransformer, ['comments'], ['user']); ``` -The above example would include an error object with a set error code: +#### Option 3: The `Transformer` Service -```json +Both the helper method and facade uses the `Flugg\Responder\Transformer` service class to apply the transformation. You can use the service yourself by injecting the service: + +```php +public function __construct(Transformer $transformer) { - "status": 500, - "success": false, - "error": { - "code": "bomb_found", - "message": null - } + $transformer->transform(Product::all(), new ProductTransformer, ['comments'], ['user']); } ``` -#### Setting Status Codes +### Transforming To Camel Case -The default status code for error responses is `500`. However, you are free to change the status code by passing in a second argument: +Model attributes are traditionally specified in snake case, however, you might prefer to use camel case in your response data. A transformer makes for a perfect location to convert the attributes, like the `soldOut` field in the example below: ```php -return Responder::error('bomb_found', 400); +return responder()->transform(Product::all(), function ($product) { + return ['soldOut' => (bool) $product->sold_out]; +})->respond(); ``` -You may also omit the error code: +#### Transforming Requests To Snake Case + +After responding with camel case, you probably want to let people send in request data using camel case parameters as well. The package provides a `Flugg\Responder\Http\Middleware\ConvertToSnakeCase` middleware you may append to the `$middleware` array in `app/Http/Kernel.php` to convert all request parameters to snake case automatically: ```php -return Responder::error(400); +protected $middleware = [ + // ... + \Flugg\Responder\Http\Middleware\ConvertToSnakeCase::class, +]; ``` -#### Setting Error Messages +*** +_The middleware will run before request validation, so you should specify your validation rules in snake case as well._ +*** + +## Creating Error Responses -You might also be interested in providing more descriptive error messages to your responses. You can do so by adding a third parameter to the `error()` method: +Whenever a consumer of your API does something unexpected, you can return an error response describing the problem. As briefly shown in a previous chapter, an error response can be created using the `error` method: ```php -return Responder::error('bomb_found', 500, 'No explosives allowed.'); +return responder()->error()->respond(); ``` -Which will output the following JSON: +The error response has knowledge about an error code, a corresponding error message, and optionally some error data. If using the default configuration, the above code would output the following JSON: ```json { - "status": 500, "success": false, + "status": 500, "error": { - "code": "bomb_found", - "message": "No explosives allowed." + "code": null, + "message": null } } ``` -You may also choose to omit the second parameter when responding with the default status code of `500`: +### Setting Error Code & Message + +You can fill the first parameter of the `error` method to set an error code: ```php -return Responder::error('bomb_found', 'No explosives allowed.'); +return responder()->error('sold_out_error')->respond(); ``` -#### Using Language File - -You might return the same error response multiple places. Instead of setting the message for each response, you can instead use the `errors.php` language file. This file should be in your `resources/lang/en` folder after you [published your vendor assets](#publishing-package-assets). - *** -_If you use Lumen, you need to create the `resources/lang/en/errors.php` file manually. You may simply copy the [default language file](resources/lang/en/errors.php)._ +_You can also use integers as error codes._ *** -The language file contains the following error messages out of the box: +Additionally, you may set the second parameter to an error message describing the error: ```php -'resource_not_found' => 'The requested resource does not exist.', -'unauthenticated' => 'You are not authenticated for this request.', -'unauthorized' => 'You are not authorized for this request.', -'relation_not_found' => 'The requested relation does not exist.', -'validation_failed' => 'The given data failed to pass validation..', +return responder()->error('sold_out_error', 'The requested product is sold out.')->respond(); ``` -These messages are used for Laravel's default exceptions, which the package can catch and convert to an error JSON response. We'll take a closer look at how to catch these exceptions in the next section on [exceptions](#exceptions). +#### Set Messages In Language Files -Let's add the `bomb_found` error code and map it to a corresponding message: +Alternatively, you can set the error messages in a language file, allowing for returning messages in different languages for different consumers. The configuration file has an `error_message_files` key defining a list of language files with error messages. By default, it is set to `['errors']`, meaning it will look for an `errors.php` file inside `resources/lang/en`. You can use these files to map error codes to corresponding error messages: ```php -'bomb_found' => 'No explosives allowed.', +return [ + 'sold_out_error' => 'The requested product is sold out.', +]; +``` + +#### Register Messages Using `ErrorMessageResolver` + +Instead of implementing the `Transformable` contract for all models, an alternative approach is to bind the transformers using the `bind` method on the `TransformerManager` class. You can place the code below within `AppServiceProvider` or an entirely new `TransformerServiceProvider`: + +```php +use Flugg\Responder\ErrorMessageResolver; + +public function boot() +{ + $this->app->make(ErrorMessageResolver::class)->register([ + 'sold_out_error' => 'The requested product is sold out.', + ]); +} ``` -You can then refer to it from your error response: +### Adding Error Data + +You may want to set additional data on the error response. Like in the example below, we're returning a list of shipments with the `sold_out` error response, giving the consumer information about when a new shipment for the product might arrive. ```php -return Responder::error('bomb_found'); +return responder()->error('sold_out')->data(['shipments' => Shipment::all()])->respond(); ``` -Which will output the same JSON as above, with the error message set: +The error data will be appended to the response data. Assuming we're using the default serializer and there are no shipments in the database, the code above would look like: ```json { - "status": 500, "success": false, + "status": 500, "error": { - "code": "bomb_found", - "message": "No explosives allowed." + "code": "sold_out", + "message": "The requested product is sold out.", + "shipments": [] } } ``` -### Exceptions - -When something unexpected happens, you might prefer to throw actual exceptions instead of using the `error()` method. And even if you don't, you might want the package to catch Laravel's default exceptions, to automatically convert them to JSON error responses. - -#### Handle Exceptions +### Serializing Response Data -If you let the package handle exceptions, the package will catch all exceptions extending `Flugg\Responder\Exceptions\Http\ApiException` and convert them to JSON responses. - -To use the package exception handler you need to replace the following line in `app/Exceptions/Handler.php`: +Similarly to success responses, error responses will be serialized using the specified error serializer in the configuration file. This defaults to the package's own `Flugg\Responder\Serializers\ErrorSerializer`, but can of course be changed by using the `serializer` method: ```php -use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +return responder()->error()->serializer(ExampleErrorSerializer::class)->respond(); ``` -With the package exception handler: - ```php -use Flugg\Responder\Exceptions\Handler as ExceptionHandler; +return responder()->success()->serializer(new ExampleErrorSerializer())->respond(); ``` -*** -_Lumen uses a different base exception handler, and is incompatible with the package exception handler. You may instead, simply copy the contents of the [package exception handler](src/Exceptions/Handler.php) and paste it into your `render()` method and use the `Flugg\Responder\Traits\HandlesApiErrors` trait._ -*** +You can create your own error serializer by implementing the `Flugg\Responder\Contracts\ErrorSerializer` contract. -#### Catching Laravel Exceptions +## Handling Exceptions -Laravel throws a few exceptions when things go wrong. For instance, an `Illuminate\Database\Eloquent\ModelNotFoundException` exception will be thrown when no model is found using the `findOrFail()` method. This exception, and more, are handled by the package if you added the package exception handling, as explained in the paragraph above. +No matter how much we try to avoid them, exceptions do happen. Responding to the exceptions in an elegant manner will improve the user experience of your API. The package can enhance your exception handler to automatically turn exceptions in to error responses. If you want to take use of this, you can either use the package's exception handler or include a trait as described in further details below. -The authorization and validation exceptions thrown from form requests cannot be caught by the package automatically since the exceptions are too generic. However, you may use the `Flugg\Responder\Traits\ThrowsApiErrors` in your base request class: +#### Option 1: Replace `Handler` Class -```php -convertDefaultException($exception); - /** - * The error code used for API responses. - * - * @var string - */ - protected $errorCode = 'custom_error'; + if ($exception instanceof HttpException) { + return $this->renderResponse($exception); + } + + return parent::render($request, $exception); } ``` -You can customize the response generated from your exceptions by setting the `$statusCode` and `$errorCode` properties as seen above. +### Converting Exceptions -### Testing Helpers +Once you've implemented one of the above options, the package will convert some of Laravel's exceptions to an exception extending `Flugg\Responder\Exceptions\Http\HttpException`. It will then convert these to an error response. The table below shows which Laravel exceptions are converted and what they are converted to. All the exceptions on the right is under the `Flugg\Responder\Exceptions\Http` namespace and extends `Flugg\Responder\Exceptions\Http\HttpException`. All exceptions extending the `HttpException` class will be automatically converted to an error response. -Once you start transforming your data, writing tests to test the data becomes increasingly more difficult. You could use methods like Laravel's `seeJson` or `seeJsonEquals`, however, because the data wont be transformed (or serialized) you need to hardcode every value. +| Caught Exceptions | Converted To | +| --------------------------------------------------------------- | ---------------------------- | +| `Illuminate\Auth\AuthenticationException` | `UnauthenticatedException` | +| `Illuminate\Auth\Access\AuthorizationException` | `UnauthorizedException` | +| `Symfony\Component\HttpKernel\Exception\NotFoundHttpException` | `PageNotFoundException` | +| `Illuminate\Database\Eloquent\ModelNotFoundException` | `PageNotFoundException` | +| `Illuminate\Database\Eloquent\RelationNotFoundException` | `RelationNotFoundException` | +| `Illuminate\Validation\ValidationException` | `ValidationFailedException` | -The package provides a `Flugg\Responder\Traits\MakesApiRequests` trait you can use in your `tests/TestCase.php` file, to get access to some helper methods to easily test the responses. +You can disable the conversions of some of the exceptions above using the `$dontConvert` property: + +```php +/** + * A list of default exception types that should not be converted. + * + * @var array + */ +protected $dontConvert = [ + ModelNotFoundException::class, +]; +``` *** -_Currently, the success response methods only work if you use the default serializer, `Flugg\Responder\Serializers\ApiSerializer`. In the future you can test using all serializers._ +If you're using the trait option, you can disable all the default conversions by removing the call to `convertDefaultException` in the `render` method. *** -#### Assert Success Responses +#### Convert Custom Exceptions -The testing trait provides a `seeSuccess()` method you can use to assert that the success response was successful: +In addition to letting the package convert Laravel exceptions, you can also convert your own exceptions using the `convert` method in the `render` method: ```php -$this->seeSuccess($user, 201); +$this->convert($exception, [ + InvalidValueException => PageNotFoundException, +]); ``` -This will transform and serialize your data, just like the `success()` method on the responder. It will run a `seeStatusCode()` on the status code and assert that the response has the right base structure and contains the given data. You may also pass in any meta data as the third parameter. - -While the above method only checks if any part of the success data has the values you specified, you can also assert for an exact match: +You can optionally give it a closure that throws the new exception, if you want to give it constructor parameters: ```php -$this->seeSuccessEquals($user, 201); +$this->convert($exception, [ + MaintenanceModeException => function ($exception) { + throw new ServerDownException($exception->retryAfter); + }, +]); ``` -This works much in the same way as Laravel's `seeJsonEquals`. - -#### Assert Error Responses +### Creating HTTP Exceptions -In the same way as you can assert for success responses, you may also verify that your application sends the right error responses using the `seeError()` method: +An exception class is a convenient place to store information about an error. The package provides an abstract exception class `Flugg\Responder\Exceptions\Http\HttpException`, which has knowledge about status code, an error code and an error message. Continuing on our product example from above, we could create our own `HttpException` class: ```php -$this->seeError('invalid_user', 400); -``` - -This checks the status code and error response structure. You may also pass in a message as third parameter. - -#### Fetch Success Data +json('post', 'sessions', $credentials); -$data = $this->getSuccessData(); -``` +use Flugg\Responder\Exceptions\Http\HttpException; -This will decode the response JSON and return the data as an array. +class SoldOutException extends HttpException +{ + /** + * An HTTP status code. + * + * @var int + */ + protected $status = 400; -## Configuration + /** + * An error code. + * + * @var string|null + */ + protected $code = 'sold_out_error'; -If you've published vendor assets as explained in the [installation guide](#installation), you will have access to a `config/responder.php` file. You may change the values in this file to change how the package should operate. We'll go through each configuration key. + /** + * An error message. + * + * @var string|null + */ + protected $message = 'The requested product is sold out.'; +} +``` -#### Serializer Class Path +You can also add a `data` method returning additional error data: -This key represents the full class path to the serializer class you would like the package to use when generating successful JSON responses. You may leave it with the default `Flugg\Responder\Serializers\ApiSerializer`, change it to one of [Fractal's serializers](http://fractal.thephpleague.com/serializers/), or create a [custom one yourself](#custom-serializers). +```php +/** + * Retrieve additional error data. + * + * @return array|null + */ +public function data() +{ + return [ + 'shipments' => Shipment::all() + ]; +} +``` -#### Include Status Code +If you're letting the package handle exceptions, you can now throw the exception anywhere in your application and it will automatically be rendered to an error response. -The package will include a status code for both success- and error responses. You can disable this by setting this key to `false`. +```php +throw new SoldOutException(); +``` -## Contributing +# Contributing Contributions are more than welcome and you're free to create a pull request on Github. You can run tests with the following command: @@ -862,6 +996,10 @@ vendor/bin/phpunit If you find bugs or have suggestions for improvements, feel free to submit an issue on Github. However, if it's a security related issue, please send an email to flugged@gmail.com instead. -## License +# Donating + +The package is completely free to use, however, a lot of time has been put into making it. If you want to show your appreciation by leaving a small donation, you can do so by clicking [here](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=PRMC9WLJY8E46&lc=NO&item_name=Laravel%20Responder¤cy_code=USD&bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted). Thanks! + +# License -Laravel Responder is free software distributed under the terms of the MIT license. See [license.md](license.md) for more details. +Laravel Responder is free software distributed under the terms of the MIT license. See [license.md](license.md) for more details. \ No newline at end of file diff --git a/resources/config/responder.php b/resources/config/responder.php deleted file mode 100644 index 130d223..0000000 --- a/resources/config/responder.php +++ /dev/null @@ -1,69 +0,0 @@ - Flugg\Responder\Serializers\ApiSerializer::class, - - /* - |-------------------------------------------------------------------------- - | Include Success Flag - |-------------------------------------------------------------------------- - | - | Wether or not you want to include success flag in your JSON responses. - | If true the success flag is prepended to your success and error - | responses as either true or false respectively. This takes place right - | after your data is serialized. - | - */ - - 'include_success_flag' => true, - - /* - |-------------------------------------------------------------------------- - | Include Status Code - |-------------------------------------------------------------------------- - | - | Wether or not you want to include status codes in your JSON responses. - | If true the status code is prepended to both your success and error - | responses. This takes place right after your data is serialized. - | - */ - - 'include_status_code' => true, - - /* - |-------------------------------------------------------------------------- - | Load Relations From Parameter - |-------------------------------------------------------------------------- - | - | The responder will automatically parse and load relations from a query - | string parameter if the value below is a string value. If you don't - | want the package to auto load relations, you can set it to null. - | - */ - - 'load_relations_from_parameter' => 'include', - - /* - |-------------------------------------------------------------------------- - | Custom Exceptions - |-------------------------------------------------------------------------- - */ - - 'exceptions' => [ - // 'access_denied' => App\Exceptions\AccessDeniedException::class, - // ... - ] - -]; diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index 104340c..ed405cd 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -13,9 +13,9 @@ | */ - 'resource_not_found' => 'The requested resource does not exist.', 'unauthenticated' => 'You are not authenticated for this request.', 'unauthorized' => 'You are not authorized for this request.', + 'page_not_found' => 'The requested page does not exist.', 'relation_not_found' => 'The requested relation does not exist.', 'validation_failed' => 'The given data failed to pass validation.', diff --git a/resources/stubs/transformer.model.stub b/resources/stubs/transformer.model.stub new file mode 100644 index 0000000..aeab3d9 --- /dev/null +++ b/resources/stubs/transformer.model.stub @@ -0,0 +1,36 @@ + (int) $DummyModelVariable->id, + ]; + } +} diff --git a/resources/stubs/transformer.pivot.stub b/resources/stubs/transformer.pivot.stub deleted file mode 100644 index 249b611..0000000 --- a/resources/stubs/transformer.pivot.stub +++ /dev/null @@ -1,43 +0,0 @@ - (int) $DummyModelVariable->id - ]; - } - - /** - * Transform the model's pivot table data into a generic array. - * - * @param Pivot $pivot - * @return array - */ - public function transformPivot(Pivot $pivot):array - { - return [ - // - ]; - } -} diff --git a/resources/stubs/transformer.plain.stub b/resources/stubs/transformer.plain.stub new file mode 100644 index 0000000..717b532 --- /dev/null +++ b/resources/stubs/transformer.plain.stub @@ -0,0 +1,35 @@ + (int) $DummyModelVariable->id - ]; - } -} diff --git a/src/Console/MakeTransformer.php b/src/Console/MakeTransformer.php index 57d41c7..96cbaba 100644 --- a/src/Console/MakeTransformer.php +++ b/src/Console/MakeTransformer.php @@ -2,27 +2,25 @@ namespace Flugg\Responder\Console; -use Illuminate\Console\Command; -use Illuminate\Filesystem\Filesystem; +use Illuminate\Console\GeneratorCommand; +use Illuminate\Support\Str; +use Symfony\Component\Console\Input\InputOption; /** - * An Artisan command for generating a new transformer class. + * An Artisan command class responsible for making transformer classes. * * @package flugger/laravel-responder * @author Alexander Tømmerås * @license The MIT License */ -class MakeTransformer extends Command +class MakeTransformer extends GeneratorCommand { /** - * The name and signature of the console command. + * The console command name. * * @var string */ - protected $signature = 'make:transformer - {name : The name of the transformer class} - {--pivot : Include a transformer method for pivot table data} - {--model= : The namespace to the model being transformed}'; + protected $name = 'make:transformer'; /** * The console command description. @@ -32,162 +30,120 @@ class MakeTransformer extends Command protected $description = 'Create a new transformer class'; /** - * The file system instance. + * The type of class being generated. * - * @var Filesystem - */ - protected $files; - - /** - * Create a new command instance. - * - * @param Filesystem $files - */ - public function __construct(Filesystem $files) - { - parent::__construct(); - - $this->files = $files; - } - - /** - * Execute the console command. - * - * @return mixed + * @var string */ - public function handle() - { - $this->generateTransformer(); - } + protected $type = 'Transformer'; /** - * Generate the transformer class. + * Get the stub file for the generator. * - * @return void + * @return string */ - protected function generateTransformer() + protected function getStub() { - $name = (string) $this->argument('name'); - $path = $this->laravel->basePath() . '/app/Transformers/' . $name . '.php'; - - if ($this->files->exists($path)) { - return $this->error($name . ' already exists!'); + if ($this->option('plain')) { + return __DIR__ . '/../../resources/stubs/transformer.plain.stub'; } - $this->makeDirectory($path); - - $stubPath = $this->option('pivot') ? 'resources/stubs/transformer.pivot.stub' : 'resources/stubs/transformer.stub'; - $stub = $this->files->get(__DIR__ . '/../../' . $stubPath); - - $this->files->put($path, $this->makeTransformer($name, $stub)); - - $this->info('Transformer created successfully.'); + return __DIR__ . '/../../resources/stubs/transformer.model.stub'; } /** - * Build a transformers directory if one doesn't exist. + * Get the default namespace for the class. * - * @param string $path - * @return void + * @param string $rootNamespace + * @return string */ - protected function makeDirectory(string $path) + protected function getDefaultNamespace($rootNamespace) { - if (! $this->files->isDirectory(dirname($path))) { - $this->files->makeDirectory(dirname($path), 0777, true, true); - } + return $rootNamespace . '\Transformers'; } /** - * Build the transformer class using the given name and stub. + * Build the class with the given name. * * @param string $name - * @param string $stub * @return string */ - protected function makeTransformer(string $name, string $stub):string + protected function buildClass($name) { - $stub = $this->replaceNamespace($stub); - $stub = $this->replaceClass($stub, $name); - $stub = $this->replaceModel($stub, $name); + $replace = []; - return $stub; - } - - /** - * Replace the namespace for the given stub. - * - * @param string $stub - * @return string - */ - protected function replaceNamespace(string $stub):string - { - if (method_exists($this->laravel, 'getNameSpace')) { - $namespace = $this->laravel->getNamespace() . 'Transformers'; - } else { - $namespace = 'App\Transformers'; + if (! $this->option('model') && ! $this->option('plain')) { + $this->input->setOption('model', $this->resolveModelFromClassName()); } - $stub = str_replace('DummyNamespace', $namespace, $stub); + if ($this->option('model')) { + $replace = $this->buildModelReplacements($replace); + } - return $stub; + return str_replace(array_keys($replace), array_values($replace), parent::buildClass($name)); } /** - * Replace the class name for the given stub. + * Resolve a model from the given class name. * - * @param string $stub - * @param string $name * @return string */ - protected function replaceClass(string $stub, string $name):string + protected function resolveModelFromClassName() { - $stub = str_replace('DummyClass', $name, $stub); - - return $stub; + return 'App\\' . str_replace('Transformer', '', array_last(explode('/', $this->getNameInput()))); } /** - * Replace the model for the given stub. + * Build the model replacement values. * - * @param string $stub - * @param string $name - * @return string + * @param array $replace + * @return array */ - protected function replaceModel(string $stub, string $name):string + protected function buildModelReplacements(array $replace) { - $model = $this->getModelNamespace($name); - $class = $this->getClassFromNamespace($model); - - $stub = str_replace('DummyModelNamespace', $model, $stub); - $stub = str_replace('DummyModelClass', $class, $stub); - $stub = str_replace('DummyModelVariable', camel_case($class), $stub); + if (! class_exists($modelClass = $this->parseModel($this->option('model')))) { + if ($this->confirm("A {$modelClass} model does not exist. Do you want to generate it?", true)) { + $this->call('make:model', ['name' => $modelClass]); + } + } - return $stub; + return array_merge($replace, [ + 'DummyFullModelClass' => $modelClass, + 'DummyModelClass' => class_basename($modelClass), + 'DummyModelVariable' => lcfirst(class_basename($modelClass)), + ]); } /** - * Get the full class path for the model. + * Get the fully-qualified model class name. * - * @param string $name + * @param string $model * @return string */ - protected function getModelNamespace(string $name):string + protected function parseModel($model) { - if ($this->option('model')) { - return $this->option('model'); + if (preg_match('([^A-Za-z0-9_/\\\\])', $model)) { + throw new InvalidArgumentException('Model name contains invalid characters.'); } - return 'App\\' . str_replace('Transformer', '', $name); + $model = trim(str_replace('/', '\\', $model), '\\'); + + if (! Str::startsWith($model, $rootNamespace = $this->laravel->getNamespace())) { + $model = $rootNamespace . $model; + } + + return $model; } /** - * Get the full class path for the transformer. + * Get the console command options. * - * @param string $namespace - * @return string + * @return array */ - protected function getClassFromNamespace(string $namespace):string + protected function getOptions() { - return last(explode('\\', $namespace)); + return [ + ['model', 'm', InputOption::VALUE_OPTIONAL, 'Generate a model transformer.'], + ['plain', 'p', InputOption::VALUE_NONE, 'Generate a plain transformer.'], + ]; } } \ No newline at end of file diff --git a/src/Contracts/ErrorFactory.php b/src/Contracts/ErrorFactory.php new file mode 100644 index 0000000..bc963ba --- /dev/null +++ b/src/Contracts/ErrorFactory.php @@ -0,0 +1,24 @@ + + * @license The MIT License + */ +interface ErrorFactory +{ + /** + * Make an error array from the given error code, message and error data. + * + * @param \Flugg\Responder\Contracts\ErrorSerializer $serializer + * @param string|null $errorCode + * @param string|null $message + * @param array|null $data + * @return array + */ + public function make(ErrorSerializer $serializer, string $errorCode = null, string $message = null, array $data = null): array; +} \ No newline at end of file diff --git a/src/Contracts/ErrorMessageResolver.php b/src/Contracts/ErrorMessageResolver.php new file mode 100644 index 0000000..1833a5d --- /dev/null +++ b/src/Contracts/ErrorMessageResolver.php @@ -0,0 +1,21 @@ + + * @license The MIT License + */ +interface ErrorMessageResolver +{ + /** + * Resolve a message from the given error code. + * + * @param string $errorCode + * @return string|null + */ + public function resolve(string $errorCode); +} \ No newline at end of file diff --git a/src/Contracts/ErrorSerializer.php b/src/Contracts/ErrorSerializer.php new file mode 100644 index 0000000..2e27fc2 --- /dev/null +++ b/src/Contracts/ErrorSerializer.php @@ -0,0 +1,23 @@ + + * @license The MIT License + */ +interface ErrorSerializer +{ + /** + * Format the error data. + * + * @param string|null $errorCode + * @param string|null $message + * @param array|null $data + * @return array + */ + public function format(string $errorCode = null, string $message = null, array $data = null): array; +} \ No newline at end of file diff --git a/src/Contracts/Pagination/PaginatorFactory.php b/src/Contracts/Pagination/PaginatorFactory.php new file mode 100644 index 0000000..6f7600a --- /dev/null +++ b/src/Contracts/Pagination/PaginatorFactory.php @@ -0,0 +1,34 @@ + + * @license The MIT License + */ +interface PaginatorFactory +{ + /** + * Make a Fractal paginator adapter from a Laravel paginator. + * + * @param \Illuminate\Contracts\Pagination\LengthAwarePaginator $paginator + * @return \League\Fractal\Pagination\PaginatorInterface + */ + public function make(LengthAwarePaginator $paginator): PaginatorInterface; + + /** + * Make a Fractal paginator adapter from a Laravel paginator. + * + * @param \Flugg\Responder\Pagination\CursorPaginator $paginator + * @return \League\Fractal\Pagination\Cursor + */ + public function makeCursor(CursorPaginator $paginator): Cursor; +} \ No newline at end of file diff --git a/src/Contracts/Resources/ResourceFactory.php b/src/Contracts/Resources/ResourceFactory.php new file mode 100644 index 0000000..358505b --- /dev/null +++ b/src/Contracts/Resources/ResourceFactory.php @@ -0,0 +1,25 @@ + + * @license The MIT License + */ +interface ResourceFactory +{ + /** + * Make resource from the given data. + * + * @param mixed $data + * @param \Flugg\Responder\Transformers\Transformer|string|callable|null $transformer + * @param string|null $resourceKey + * @return \League\Fractal\Resource\ResourceInterface + */ + public function make($data = null, $transformer = null, string $resourceKey = null): ResourceInterface; +} \ No newline at end of file diff --git a/src/Contracts/Responder.php b/src/Contracts/Responder.php new file mode 100644 index 0000000..16f1d21 --- /dev/null +++ b/src/Contracts/Responder.php @@ -0,0 +1,35 @@ + + * @license The MIT License + */ +interface Responder +{ + /** + * Build a successful response. + * + * @param mixed $data + * @param callable|string|\Flugg\Responder\Transformers\Transformer|null $transformer + * @param string|null $resourceKey + * @return \Flugg\Responder\Http\Responses\SuccessResponseBuilder + */ + public function success($data = null, $transformer = null, string $resourceKey = null): SuccessResponseBuilder; + + /** + * Build an error response. + * + * @param string|null $errorCode + * @param string|null $message + * @return \Flugg\Responder\Http\Responses\ErrorResponseBuilder + */ + public function error(string $errorCode = null, string $message = null): ErrorResponseBuilder; +} \ No newline at end of file diff --git a/src/Contracts/ResponseFactory.php b/src/Contracts/ResponseFactory.php new file mode 100644 index 0000000..bc1ca71 --- /dev/null +++ b/src/Contracts/ResponseFactory.php @@ -0,0 +1,25 @@ + + * @license The MIT License + */ +interface ResponseFactory +{ + /** + * Generate a JSON response. + * + * @param array $data + * @param int $status + * @param array $headers + * @return \Illuminate\Http\JsonResponse + */ + public function make(array $data, int $status, array $headers = []): JsonResponse; +} \ No newline at end of file diff --git a/src/Contracts/TransformFactory.php b/src/Contracts/TransformFactory.php new file mode 100644 index 0000000..8d47af6 --- /dev/null +++ b/src/Contracts/TransformFactory.php @@ -0,0 +1,26 @@ + + * @license The MIT License + */ +interface TransformFactory +{ + /** + * Transform the given resource, and serialize the data with the given serializer. + * + * @param \League\Fractal\Resource\ResourceInterface $resource + * @param \League\Fractal\Serializer\SerializerAbstract $serializer + * @param array $options + * @return array + */ + public function make(ResourceInterface $resource, SerializerAbstract $serializer, array $options = []): array; +} \ No newline at end of file diff --git a/src/Contracts/Transformable.php b/src/Contracts/Transformable.php index be58289..7d9869c 100644 --- a/src/Contracts/Transformable.php +++ b/src/Contracts/Transformable.php @@ -3,7 +3,7 @@ namespace Flugg\Responder\Contracts; /** - * A contract you can apply to your models to map a specific transformer to a model. + * A contract for making the class transformable. * * @package flugger/laravel-responder * @author Alexander Tømmerås @@ -12,38 +12,9 @@ interface Transformable { /** - * The path to the transformer class. + * Get a transformer for the class. * - * @return string + * @return \Flugg\Responder\Transformers\Transformer|callable|string|null */ - public static function transformer(); - - /** - * Get the table associated with the model. - * - * @return string - */ - public function getTable(); - - /** - * Get the table associated with the model. - * - * @return string - */ - public function getRelations(); - - /** - * Determine if the given relation is loaded. - * - * @param string $key - * @return bool - */ - public function relationLoaded($key); - - /** - * Convert the model instance to an array. - * - * @return array - */ - public function toArray(); + public function transformer(); } \ No newline at end of file diff --git a/src/Contracts/Transformer.php b/src/Contracts/Transformer.php new file mode 100644 index 0000000..3089f93 --- /dev/null +++ b/src/Contracts/Transformer.php @@ -0,0 +1,24 @@ + + * @license The MIT License + */ +interface Transformer +{ + /** + * Transform the data without serializing with the given transformer and relations. + * + * @param mixed $data + * @param \Flugg\Responder\Transformers\Transformer|callable|string|null $transformer + * @param string[] $with + * @param string[] $without + * @return array + */ + public function transform($data = null, $transformer = null, array $with = [], array $without = []): array; +} \ No newline at end of file diff --git a/src/Contracts/Transformers/TransformerResolver.php b/src/Contracts/Transformers/TransformerResolver.php new file mode 100644 index 0000000..7b7ebd5 --- /dev/null +++ b/src/Contracts/Transformers/TransformerResolver.php @@ -0,0 +1,39 @@ + + * @license The MIT License + */ +interface TransformerResolver +{ + /** + * Register a transformable to transformer binding. + * + * @param string|array $transformable + * @param string|callback $transformer + * @return void + */ + public function bind($transformable, $transformer); + + /** + * Resolve a transformer. + * + * @param \Flugg\Responder\Transformers\Transformer|string|callable $transformer + * @return \Flugg\Responder\Transformers\Transformer|callable + * @throws \Flugg\Responder\Exceptions\InvalidTransformerException + */ + public function resolve($transformer); + + /** + * Resolve a transformer from the given data. + * + * @param mixed $data + * @return \Flugg\Responder\Transformers\Transformer|callable + */ + public function resolveFromData($data); +} \ No newline at end of file diff --git a/src/ErrorFactory.php b/src/ErrorFactory.php new file mode 100644 index 0000000..6d275bf --- /dev/null +++ b/src/ErrorFactory.php @@ -0,0 +1,53 @@ + + * @license The MIT License + */ +class ErrorFactory implements ErrorFactoryContract +{ + /** + * A resolver for resolving messages from error codes. + * + * @var \Flugg\Responder\Contracts\ErrorMessageResolver + */ + protected $messageResolver; + + /** + * Construct the factory class. + * + * @param \Flugg\Responder\Contracts\ErrorMessageResolver $messageResolver + */ + public function __construct(ErrorMessageResolverContract $messageResolver) + { + $this->messageResolver = $messageResolver; + } + + /** + * Make an error array from the given error code and message. + * + * @param \Flugg\Responder\Contracts\ErrorSerializer $serializer + * @param string|null $errorCode + * @param string|null $message + * @param array|null $data + * @return array + */ + public function make(ErrorSerializer $serializer, string $errorCode = null, string $message = null, array $data = null): array + { + if (isset($errorCode) && ! isset($message)) { + $message = $this->messageResolver->resolve($errorCode); + } + + return $serializer->format($errorCode, $message, $data); + } +} \ No newline at end of file diff --git a/src/ErrorMessageResolver.php b/src/ErrorMessageResolver.php new file mode 100644 index 0000000..c09d681 --- /dev/null +++ b/src/ErrorMessageResolver.php @@ -0,0 +1,73 @@ + + * @license The MIT License + */ +class ErrorMessageResolver implements ErrorMessageResolverContract +{ + /** + * A serivce for resolving messages from language files. + * + * @var \Illuminate\Contracts\Translation\Translator + */ + protected $translator; + + /** + * A list of registered messages mapped to error codes. + * + * @var array + */ + protected $messages = []; + + /** + * Construct the resolver class. + * + * @param \Illuminate\Contracts\Translation\Translator $translator + */ + public function __construct(Translator $translator) + { + $this->translator = $translator; + } + + /** + * Register a message mapped to an error code. + * + * @param string $errorCode + * @param string $message + * @return void + */ + public function register(string $errorCode, string $message) + { + $this->messages = array_merge($this->messages, is_array($errorCode) ? $errorCode : [ + $errorCode => $message, + ]); + } + + /** + * Resolve a message from the given error code. + * + * @param string $errorCode + * @return string|null + */ + public function resolve(string $errorCode) + { + if (key_exists($errorCode, $this->messages)) { + return $this->messages[$errorCode]; + } + + if ($this->translator->has($errorCode = "errors.$errorCode")) { + return $this->translator->trans($errorCode); + } + + return null; + } +} \ No newline at end of file diff --git a/src/Exceptions/ConvertsExceptions.php b/src/Exceptions/ConvertsExceptions.php new file mode 100644 index 0000000..454ad09 --- /dev/null +++ b/src/Exceptions/ConvertsExceptions.php @@ -0,0 +1,90 @@ + + * @license The MIT License + */ +trait ConvertsExceptions +{ + /** + * A list of default exception types that should not be converted. + * + * @var array + */ + protected $dontConvert = []; + + /** + * Convert an exception to another exception + * + * @param \Exception $exception + * @param array $convert + * @return void + */ + protected function convert(Exception $exception, array $convert) + { + foreach ($convert as $source => $target) { + if ($exception instanceof $source) { + if (is_callable($target)) { + $target($exception); + } + + throw new $target; + } + } + } + + /** + * Convert a default exception to an API exception. + * + * @param \Exception $exception + * @return void + */ + protected function convertDefaultException(Exception $exception) + { + $this->convert($exception, array_diff_key([ + AuthenticationException::class => UnauthenticatedException::class, + AuthorizationException::class => UnauthorizedException::class, + NotFoundHttpException::class => PageNotFoundException::class, + ModelNotFoundException::class => PageNotFoundException::class, + BaseRelationNotFoundException::class => RelationNotFoundException::class, + ValidationException::class => function ($exception) { + throw new ValidationFailedException($exception->validator); + }, + ], array_flip($this->dontConvert))); + } + + /** + * Render an error response from an API exception. + * + * @param \Flugg\Responder\Exceptions\Http\HttpException $exception + * @return \Illuminate\Http\JsonResponse + */ + protected function renderResponse(HttpException $exception): JsonResponse + { + return app(Responder::class) + ->error($exception->errorCode(), $exception->message()) + ->data($exception->data()) + ->respond($exception->statusCode()); + } +} \ No newline at end of file diff --git a/src/Exceptions/Handler.php b/src/Exceptions/Handler.php index cb3a748..a63a52a 100644 --- a/src/Exceptions/Handler.php +++ b/src/Exceptions/Handler.php @@ -3,27 +3,33 @@ namespace Flugg\Responder\Exceptions; use Exception; -use Flugg\Responder\Exceptions\Http\ApiException; -use Flugg\Responder\Traits\HandlesApiErrors; +use Flugg\Responder\Exceptions\Http\HttpException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +/** + * An exception handler responsible for handling exceptions. + * + * @package flugger/laravel-responder + * @author Alexander Tømmerås + * @license The MIT License + */ class Handler extends ExceptionHandler { - use HandlesApiErrors; + use ConvertsExceptions; /** * Render an exception into an HTTP response. * * @param \Illuminate\Http\Request $request - * @param Exception $exception - * @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse + * @param \Exception $exception + * @return \Symfony\Component\HttpFoundation\Response */ public function render($request, Exception $exception) { - $this->transformException($exception); + $this->convertDefaultException($exception); - if ($exception instanceof ApiException) { - return $this->renderApiError($exception); + if ($exception instanceof HttpException) { + return $this->renderResponse($exception); } return parent::render($request, $exception); diff --git a/src/Exceptions/Http/ApiException.php b/src/Exceptions/Http/ApiException.php deleted file mode 100644 index 1bfe4cd..0000000 --- a/src/Exceptions/Http/ApiException.php +++ /dev/null @@ -1,78 +0,0 @@ - - * @license The MIT License - */ -abstract class ApiException extends HttpException -{ - /** - * The HTTP status code. - * - * @var int - */ - protected $statusCode = 500; - - /** - * The error code. - * - * @var string - */ - protected $errorCode = 'error_occurred'; - - /** - * The error message. - * - * @var string - */ - protected $message; - - /** - * Create a new exception instance. - * - * @param mixed $message - */ - public function __construct($message = null) - { - parent::__construct($this->statusCode, $this->message ?? $message); - } - - /** - * Get the HTTP status code, - * - * @return int - */ - public function getStatusCode() - { - return $this->statusCode; - } - - /** - * Get the error code. - * - * @return string - */ - public function getErrorCode() - { - return $this->errorCode; - } - - /** - * Get the error data. - * - * @return array|null - */ - public function getData() - { - return null; - } -} diff --git a/src/Exceptions/Http/HttpException.php b/src/Exceptions/Http/HttpException.php new file mode 100644 index 0000000..085e345 --- /dev/null +++ b/src/Exceptions/Http/HttpException.php @@ -0,0 +1,111 @@ + + * @license The MIT License + */ +abstract class HttpException extends BaseHttpException +{ + /** + * An HTTP status code. + * + * @var int + */ + protected $status = 500; + + /** + * An error code. + * + * @var string|null + */ + protected $errorCode = null; + + /** + * An error message. + * + * @var string|null + */ + protected $message = null; + + /** + * Additional error data. + * + * @var array|null + */ + protected $data = null; + + /** + * Attached headers. + * + * @var array + */ + protected $headers = []; + + /** + * Construct the exception class. + * + * @param string|null $message + * @param array|null $headers + */ + public function __construct(string $message = null, array $headers = null) + { + parent::__construct($this->status, $message ?? $this->message, null, $headers ?? $this->headers); + } + + /** + * Retrieve the HTTP status code, + * + * @return int + */ + public function statusCode(): int + { + return $this->status; + } + + /** + * Retrieve the error code. + * + * @return string|null + */ + public function errorCode() + { + return $this->errorCode; + } + + /** + * Retrieve the error message. + * + * @return string|null + */ + public function message() + { + return $this->message; + } + + /** + * Retrieve additional error data. + * + * @return array|null + */ + public function data() + { + return $this->data; + } + + /** + * Retrieve attached headers. + * + * @return array|null + */ + public function headers() + { + return $this->headers; + } +} diff --git a/src/Exceptions/Http/PageNotFoundException.php b/src/Exceptions/Http/PageNotFoundException.php new file mode 100644 index 0000000..cf9d6e6 --- /dev/null +++ b/src/Exceptions/Http/PageNotFoundException.php @@ -0,0 +1,27 @@ + + * @license The MIT License + */ +class PageNotFoundException extends HttpException +{ + /** + * An HTTP status code. + * + * @var int + */ + protected $status = 404; + + /** + * An error code. + * + * @var string|null + */ + protected $errorCode = 'page_not_found'; +} \ No newline at end of file diff --git a/src/Exceptions/Http/RelationNotFoundException.php b/src/Exceptions/Http/RelationNotFoundException.php index 8793e4c..11d2ac1 100644 --- a/src/Exceptions/Http/RelationNotFoundException.php +++ b/src/Exceptions/Http/RelationNotFoundException.php @@ -3,25 +3,25 @@ namespace Flugg\Responder\Exceptions\Http; /** - * An exception replacing Laravel's Illuminate\Database\Eloquent\RelationNotFoundException. + * An exception thrown whan a relation is not found. * * @package flugger/laravel-responder * @author Alexander Tømmerås * @license The MIT License */ -class RelationNotFoundException extends ApiException +class RelationNotFoundException extends HttpException { /** - * The HTTP status code. + * An HTTP status code. * * @var int */ - protected $statusCode = 422; + protected $status = 422; /** - * The error code used for API responses. + * An error code. * - * @var string + * @var string|null */ protected $errorCode = 'relation_not_found'; } \ No newline at end of file diff --git a/src/Exceptions/Http/ResourceNotFoundException.php b/src/Exceptions/Http/ResourceNotFoundException.php deleted file mode 100644 index 6817f74..0000000 --- a/src/Exceptions/Http/ResourceNotFoundException.php +++ /dev/null @@ -1,27 +0,0 @@ - - * @license The MIT License - */ -class ResourceNotFoundException extends ApiException -{ - /** - * The HTTP status code. - * - * @var int - */ - protected $statusCode = 404; - - /** - * The error code used for API responses. - * - * @var string - */ - protected $errorCode = 'resource_not_found'; -} \ No newline at end of file diff --git a/src/Exceptions/Http/UnauthenticatedException.php b/src/Exceptions/Http/UnauthenticatedException.php index b1cd4b8..d61233f 100644 --- a/src/Exceptions/Http/UnauthenticatedException.php +++ b/src/Exceptions/Http/UnauthenticatedException.php @@ -3,25 +3,26 @@ namespace Flugg\Responder\Exceptions\Http; /** - * An exception replacing Laravel's \Illuminate\Auth\AuthenticationException. + * An exception thrown whan a user is unauthenticated. This exception replaces Laravel's + * [\Illuminate\Auth\AuthenticationException]. * * @package flugger/laravel-responder * @author Alexander Tømmerås * @license The MIT License */ -class UnauthenticatedException extends ApiException +class UnauthenticatedException extends HttpException { /** - * The HTTP status code. + * An HTTP status code. * * @var int */ - protected $statusCode = 401; + protected $status = 401; /** - * The error code used for API responses. + * The error code. * - * @var string + * @var string|null */ protected $errorCode = 'unauthenticated'; } \ No newline at end of file diff --git a/src/Exceptions/Http/UnauthorizedException.php b/src/Exceptions/Http/UnauthorizedException.php index bded6ce..22690a3 100644 --- a/src/Exceptions/Http/UnauthorizedException.php +++ b/src/Exceptions/Http/UnauthorizedException.php @@ -3,25 +3,26 @@ namespace Flugg\Responder\Exceptions\Http; /** - * An exception replacing Laravel's \Illuminate\Auth\Access\AuthorizationException. + * An exception thrown whan a user is unauthorized. This exception replaces Laravel's + * [\Illuminate\Auth\Access\AuthorizationException]. * * @package flugger/laravel-responder * @author Alexander Tømmerås * @license The MIT License */ -class UnauthorizedException extends ApiException +class UnauthorizedException extends HttpException { /** - * The HTTP status code. + * An HTTP status code. * * @var int */ - protected $statusCode = 403; + protected $status = 403; /** - * The error code used for API responses. + * An error code. * - * @var string + * @var string|null */ protected $errorCode = 'unauthorized'; } \ No newline at end of file diff --git a/src/Exceptions/Http/ValidationFailedException.php b/src/Exceptions/Http/ValidationFailedException.php index ec84734..7e93ef8 100644 --- a/src/Exceptions/Http/ValidationFailedException.php +++ b/src/Exceptions/Http/ValidationFailedException.php @@ -5,39 +5,40 @@ use Illuminate\Contracts\Validation\Validator; /** - * An exception replacing Laravel's \Illuminate\Validation\ValidationException. + * An exception thrown whan validation fails. This exception replaces Laravel's + * [\Illuminate\Validation\ValidationException]. * * @package flugger/laravel-responder * @author Alexander Tømmerås * @license The MIT License */ -class ValidationFailedException extends ApiException +class ValidationFailedException extends HttpException { /** - * The HTTP status code. + * An HTTP status code. * * @var int */ - protected $statusCode = 422; + protected $status = 422; /** - * The error code used for API responses. + * An error code. * - * @var string + * @var string|null */ protected $errorCode = 'validation_failed'; /** - * The validator instance. + * A validator for fetching validation messages. * - * @var Validator + * @var \Illuminate\Contracts\Validation\Validator */ protected $validator; /** - * Create a new exception instance. + * Construct the exception class. * - * @param Validator $validator + * @param \Illuminate\Contracts\Validation\Validator $validator */ public function __construct(Validator $validator) { @@ -47,12 +48,12 @@ public function __construct(Validator $validator) } /** - * Get the error data. + * Retrieve the error data. * * @return array|null */ - public function getData() + public function data() { - return ['fields' => $this->validator->getMessageBag()->toArray()]; + return [$this->validator->getMessageBag()->toArray()]; } } \ No newline at end of file diff --git a/src/Exceptions/InvalidErrorSerializerException.php b/src/Exceptions/InvalidErrorSerializerException.php new file mode 100644 index 0000000..90f6548 --- /dev/null +++ b/src/Exceptions/InvalidErrorSerializerException.php @@ -0,0 +1,24 @@ + + * @license The MIT License + */ +class InvalidErrorSerializerException extends RuntimeException +{ + /** + * Construct the exception class. + */ + public function __construct() + { + parent::__construct('Serializer must be an instance of [' . ErrorSerializer::class . '].'); + } +} \ No newline at end of file diff --git a/src/Exceptions/InvalidSerializerException.php b/src/Exceptions/InvalidSuccessSerializerException.php similarity index 52% rename from src/Exceptions/InvalidSerializerException.php rename to src/Exceptions/InvalidSuccessSerializerException.php index 9806d2f..861f450 100644 --- a/src/Exceptions/InvalidSerializerException.php +++ b/src/Exceptions/InvalidSuccessSerializerException.php @@ -6,19 +6,19 @@ use RuntimeException; /** - * An exception thrown when the given serializer is not a valid serializer class. + * An exception thrown when given invalid serializers. * * @package flugger/laravel-responder * @author Alexander Tømmerås * @license The MIT License */ -class InvalidSerializerException extends RuntimeException +class InvalidSuccessSerializerException extends RuntimeException { /** - * Create a new exception instance. + * Construct the exception class. */ public function __construct() { - parent::__construct('Given serializer is not an instance of [' . SerializerAbstract::class . '].'); + parent::__construct('Serializer must be an instance of [' . SerializerAbstract::class . '].'); } } \ No newline at end of file diff --git a/src/Exceptions/InvalidTransformerException.php b/src/Exceptions/InvalidTransformerException.php index ddbb7e0..0e3aa90 100644 --- a/src/Exceptions/InvalidTransformerException.php +++ b/src/Exceptions/InvalidTransformerException.php @@ -2,11 +2,11 @@ namespace Flugg\Responder\Exceptions; -use Illuminate\Database\Eloquent\Model; +use Flugg\Responder\Transformers\Transformer; use RuntimeException; /** - * An exception thrown when the given serializer is not a valid serializer class. + * An exception thrown when given invalid transformers. * * @package flugger/laravel-responder * @author Alexander Tømmerås @@ -15,12 +15,10 @@ class InvalidTransformerException extends RuntimeException { /** - * Create a new exception instance. - * - * @param Model $model + * Construct the exception class. */ - public function __construct(Model $model) + public function __construct() { - parent::__construct('The given transformer does not exist for model [' . get_class($model) . '].'); + parent::__construct('Transformer must be a callable or an instance of [' . Transformer::class . '].'); } } \ No newline at end of file diff --git a/src/Facades/Responder.php b/src/Facades/Responder.php index 22d2afe..6230fdd 100644 --- a/src/Facades/Responder.php +++ b/src/Facades/Responder.php @@ -2,15 +2,17 @@ namespace Flugg\Responder\Facades; -use Flugg\Responder\Responder as ResponderService; +use Flugg\Responder\Contracts\Responder as ResponderContract; use Illuminate\Support\Facades\Facade; /** - * A facade you can register in config/app.php to quickly get access to the responder. + * A facade class responsible for giving easy access to the responder service. * * @package flugger/laravel-responder * @author Alexander Tømmerås * @license The MIT License + * + * @see \Flugg\Responder\Responder */ class Responder extends Facade { @@ -21,6 +23,6 @@ class Responder extends Facade */ protected static function getFacadeAccessor() { - return ResponderService::class; + return ResponderContract::class; } } \ No newline at end of file diff --git a/src/Facades/Transformer.php b/src/Facades/Transformer.php new file mode 100644 index 0000000..73987d9 --- /dev/null +++ b/src/Facades/Transformer.php @@ -0,0 +1,28 @@ + + * @license The MIT License + * + * @see \Flugg\Responder\Transformer + */ +class Transformer extends Facade +{ + /** + * Get the registered name of the component. + * + * @return string + */ + protected static function getFacadeAccessor() + { + return TransformerContract::class; + } +} \ No newline at end of file diff --git a/src/FractalTransformFactory.php b/src/FractalTransformFactory.php new file mode 100644 index 0000000..a5673ff --- /dev/null +++ b/src/FractalTransformFactory.php @@ -0,0 +1,143 @@ + + * @license The MIT License + */ +class FractalTransformFactory implements TransformFactory +{ + /** + * A manager for executing transforms. + * + * @var \League\Fractal\Manager + */ + protected $manager; + + /** + * Construct the factory class. + * + * @param \League\Fractal\Manager $manager + */ + public function __construct(Manager $manager) + { + $this->manager = $manager; + } + + /** + * Transform the given resource, and serialize the data with the given serializer. + * + * @param \League\Fractal\Resource\ResourceInterface $resource + * @param \League\Fractal\Serializer\SerializerAbstract $serializer + * @param array $options + * @return array + */ + public function make(ResourceInterface $resource, SerializerAbstract $serializer, array $options = []): array + { + $options = $this->parseOptions($options, $resource); + + return $this->manager->setSerializer($serializer) + ->parseIncludes($options['includes']) + ->parseExcludes($options['excludes']) + ->parseFieldsets($options['fieldsets']) + ->createData($resource) + ->toArray(); + } + + /** + * Parse the transformation options. + * + * @param array $options + * @param \League\Fractal\Resource\ResourceInterface $resource + * @return array + */ + protected function parseOptions(array $options, ResourceInterface $resource): array + { + $options = array_merge([ + 'includes' => [], + 'excludes' => [], + 'fieldsets' => [], + ], $options); + + if (! empty($options['fieldsets'])) { + if (is_null($resourceKey = $resource->getResourceKey())) { + throw new LogicException('Filtering fields using sparse fieldsets require resource key to be set.'); + } + + $options['fieldsets'] = $this->parseFieldsets($options['fieldsets'], $resourceKey, $options['includes']); + } + + return $options; + } + + /** + * Parse the fieldsets for Fractal. + * + * @param array $fieldsets + * @param string $resourceKey + * @param array $includes + * @return array + */ + protected function parseFieldsets(array $fieldsets, string $resourceKey, array $includes): array + { + $includes = array_map(function ($include) use ($resourceKey) { + return "$resourceKey.$include"; + }, $includes); + + foreach ($fieldsets as $key => $fields) { + if (is_numeric($key)) { + unset($fieldsets[$key]); + $key = $resourceKey; + } + + $fieldsets[$key] = $this->parseFieldset($key, (array) $fields, $includes); + } + + return $fieldsets; + } + + /** + * Parse the given fieldset and append any related resource keys. + * + * @param string $key + * @param array $fields + * @param array $includes + * @return string + */ + protected function parseFieldset(string $key, array $fields, array $includes): string + { + $childIncludes = array_reduce($includes, function ($segments, $include) use ($key) { + return array_merge($segments, $this->resolveChildIncludes($key, $include)); + }, []); + + return implode(',', array_merge($fields, array_unique($childIncludes))); + } + + /** + * Resolve included segments that are a direct child to the given resource key. + * + * @param string $key + * @param string $include + * @return array + */ + protected function resolveChildIncludes($key, string $include): array + { + if (count($segments = explode('.', $include)) <= 1) { + return []; + } + + $relation = $key === array_shift($segments) ? [$segments[0]] : []; + + return array_merge($relation, $this->resolveChildIncludes($key, implode('.', $segments))); + } +} \ No newline at end of file diff --git a/src/Http/ErrorResponseBuilder.php b/src/Http/ErrorResponseBuilder.php deleted file mode 100644 index 5076856..0000000 --- a/src/Http/ErrorResponseBuilder.php +++ /dev/null @@ -1,174 +0,0 @@ - - * @license The MIT License - */ -class ErrorResponseBuilder extends ResponseBuilder -{ - /** - * Optional error data appended with the response. - * - * @var array - */ - protected $data = []; - - /** - * The error code used to identify the error. - * - * @var string - */ - protected $errorCode; - - /** - * A descriptive error message explaining what went wrong. - * - * @var string - */ - protected $message; - - /** - * Any parameters used to build the error message. - * - * @var array - */ - protected $parameters = []; - - /** - * The HTTP status code for the response. - * - * @var int - */ - protected $statusCode = 500; - - /** - * Translator service used for translating stuff. - * - * @var \Symfony\Component\Translation\TranslatorInterface|Illuminate\Contracts\Translation\Translator - */ - protected $translator; - - /** - * Constructor. - * - * @param \Illuminate\Contracts\Routing\ResponseFactory|\Laravel\Lumen\Http\ResponseFactory $responseFactory - * @param \Symfony\Component\Translation\TranslatorInterface|Illuminate\Contracts\Translation\Translator $translator - */ - public function __construct($responseFactory, $translator) - { - $this->translator = $translator; - - parent::__construct($responseFactory); - } - - /** - * Add additonal data appended to the error object. - * - * @param array $data - * @return self - */ - public function addData(array $data):ErrorResponseBuilder - { - $this->data = array_merge($this->data, $data); - - return $this; - } - - /** - * Set the error code and optionally an error message. - * - * @param mixed|null $errorCode - * @param string|array|null $message - * @return self - */ - public function setError($errorCode = null, $message = null):ErrorResponseBuilder - { - $this->errorCode = $errorCode; - - if (is_array($message)) { - $this->parameters = $message; - } else { - $this->message = $message; - } - - return $this; - } - - /** - * Set the HTTP status code for the response. - * - * @param int $statusCode - * @return self - * @throws \InvalidArgumentException - */ - public function setStatus(int $statusCode):ResponseBuilder - { - if ($statusCode < 400 || $statusCode >= 600) { - throw new InvalidArgumentException("{$statusCode} is not a valid error HTTP status code."); - } - return parent::setStatus($statusCode); - } - - /** - * Return response success flag as true - * - * @return bool - */ - protected function isSuccessResponse():bool - { - return false; - } - - /** - * Serialize the data and return as an array. - * - * @return array - */ - public function toArray():array - { - return [ - 'error' => $this->buildErrorData() - ]; - } - - /** - * Build the error object of the serialized response data. - * - * @return array|null - */ - protected function buildErrorData() - { - if (is_null($this->errorCode)) { - return null; - } - - $data = [ - 'code' => $this->errorCode, - 'message' => $this->message ?: $this->resolveMessage() - ]; - - return array_merge($data, $this->data); - } - - /** - * Resolve an error message from the translator. - * - * @return string|null - */ - protected function resolveMessage() - { - if (! $this->translator->has($code = "errors.$this->errorCode")) { - return null; - } - - return $this->translator->trans($code, $this->parameters); - } -} \ No newline at end of file diff --git a/src/Http/MakesResponses.php b/src/Http/MakesResponses.php new file mode 100644 index 0000000..5e772cc --- /dev/null +++ b/src/Http/MakesResponses.php @@ -0,0 +1,42 @@ + + * @license The MIT License + */ +trait MakesResponses +{ + /** + * Build a successful response. + * + * @param mixed $data + * @param callable|string|\Flugg\Responder\Transformers\Transformer|null $transformer + * @param string|null $resourceKey + * @return \Flugg\Responder\Http\Responses\SuccessResponseBuilder + */ + public function success($data = null, $transformer = null, string $resourceKey = null): SuccessResponseBuilder + { + return app(Responder::class)->success(...func_get_args()); + } + + /** + * Build an error response. + * + * @param string|null $errorCode + * @param string|null $message + * @return \Flugg\Responder\Http\Responses\ErrorResponseBuilder + */ + public function error(string $errorCode = null, string $message = null): ErrorResponseBuilder + { + return app(Responder::class)->error(...func_get_args()); + } +} \ No newline at end of file diff --git a/src/Http/Middleware/ConvertToSnakeCase.php b/src/Http/Middleware/ConvertToSnakeCase.php new file mode 100644 index 0000000..8c21133 --- /dev/null +++ b/src/Http/Middleware/ConvertToSnakeCase.php @@ -0,0 +1,43 @@ + + * @license The MIT License + * + * @see \Flugg\Responder\Responder + */ +class ConvertToSnakeCase extends TransformsRequest +{ + /** + * A list of attributes that shouldn't be converted. + * + * @var array + */ + protected $except = [ + // + ]; + + /** + * Clean the data in the given array. + * + * @param array $data + * @return array + */ + protected function cleanArray(array $data) + { + $parameters = []; + + foreach ($data as $key => $value) { + $parameters[in_array($key, $this->except) ? $key : snake_case($key)] = $value; + } + + return $parameters; + } +} diff --git a/src/Http/ResponseBuilder.php b/src/Http/ResponseBuilder.php deleted file mode 100644 index cc9333e..0000000 --- a/src/Http/ResponseBuilder.php +++ /dev/null @@ -1,193 +0,0 @@ - - * @license The MIT License - */ -abstract class ResponseBuilder implements Arrayable, Jsonable, JsonSerializable -{ - - /** - * Flag indicating if success flag should be added to the serialized data. - * - * @var bool - */ - protected $includeSuccessFlag; - - /** - * Flag indicating if status code should be added to the serialized data. - * - * @var bool - */ - protected $includeStatusCode; - - /** - * The HTTP status code for the response. - * - * @var int - */ - protected $statusCode; - - /** - * Response factory used to generate JSON responses. - * - * @var \Illuminate\Contracts\Routing\ResponseFactory|\Laravel\Lumen\Http\ResponseFactory $responseFactory - */ - protected $responseFactory; - - /** - * Constructor. - * - * @param \Illuminate\Contracts\Routing\ResponseFactory|\Laravel\Lumen\Http\ResponseFactory $responseFactory - */ - public function __construct($responseFactory) - { - $this->responseFactory = $responseFactory; - } - - /** - * Serialize the data and wrap it in a JSON response object. - * - * @param int|null $statusCode - * @param array $headers - * @return \Illuminate\Http\JsonResponse - */ - public function respond(int $statusCode = null, array $headers = []):JsonResponse - { - if (! is_null($statusCode)) { - $this->setStatus($statusCode); - } - - $data = $this->toArray(); - $data = $this->includeStatusCode($data); - $data = $this->includeSuccessFlag($data); - - return $this->responseFactory->json($data, $this->statusCode, $headers); - } - - /** - * Set the HTTP status code for the response. - * - * @param int $statusCode - * @return self - */ - public function setStatus(int $statusCode):ResponseBuilder - { - $this->statusCode = $statusCode; - - return $this; - } - - /** - * Return response success flag - * - * @return bool - */ - abstract protected function isSuccessResponse():bool; - - /** - * Set a flag indicating if success should be added to the response. - * - * @param bool $includeSuccessFlag - * @return self - */ - public function setIncludeSuccessFlag(bool $includeSuccessFlag):ResponseBuilder - { - $this->includeSuccessFlag = $includeSuccessFlag; - - return $this; - } - - /** - * Set a flag indicating if status code should be added to the response. - * - * @param bool $includeStatusCode - * @return self - */ - public function setIncludeStatusCode(bool $includeStatusCode):ResponseBuilder - { - $this->includeStatusCode = $includeStatusCode; - - return $this; - } - - /** - * Convert the response to an Illuminate collection. - * - * @return \Illuminate\Support\Collection - */ - public function toCollection():Collection - { - return new Collection($this->toArray()); - } - - /** - * Convert the response to JSON. - * - * @param int $options - * @return string - */ - public function toJson($options = 0) - { - return json_encode($this->jsonSerialize(), $options); - } - - /** - * Convert the object into something JSON serializable. - * - * @return array - */ - public function jsonSerialize() - { - return $this->toArray(); - } - - /** - * Convert the response to an array. - * - * @return array - */ - abstract public function toArray():array; - - /** - * Include a status code to the serialized data if enabled. - * - * @param array $data - * @return array - */ - protected function includeSuccessFlag(array $data):array - { - if (! $this->includeSuccessFlag) { - return $data; - } - - return array_merge(['success' => $this->isSuccessResponse()], $data); - } - - /** - * Include a status code to the serialized data if enabled. - * - * @param array $data - * @return array - */ - protected function includeStatusCode(array $data):array - { - if (! $this->includeStatusCode) { - return $data; - } - - return array_merge(['status' => $this->statusCode], $data); - } -} \ No newline at end of file diff --git a/src/Http/Responses/Decorators/ResponseDecorator.php b/src/Http/Responses/Decorators/ResponseDecorator.php new file mode 100644 index 0000000..1e28191 --- /dev/null +++ b/src/Http/Responses/Decorators/ResponseDecorator.php @@ -0,0 +1,43 @@ + + * @license The MIT License + */ +abstract class ResponseDecorator implements ResponseFactory +{ + /** + * The factory being decorated. + * + * @var \Flugg\Responder\Contracts\ResponseFactory + */ + protected $factory; + + /** + * Construct the decorator class. + * + * @param \Flugg\Responder\Contracts\ResponseFactory $factory + */ + public function __construct(ResponseFactory $factory) + { + $this->factory = $factory; + } + + /** + * Generate a JSON response. + * + * @param array $data + * @param int $status + * @param array $headers + * @return \Illuminate\Http\JsonResponse + */ + abstract public function make(array $data, int $status, array $headers = []): JsonResponse; +} diff --git a/src/Http/Responses/Decorators/StatusCodeDecorator.php b/src/Http/Responses/Decorators/StatusCodeDecorator.php new file mode 100644 index 0000000..506f221 --- /dev/null +++ b/src/Http/Responses/Decorators/StatusCodeDecorator.php @@ -0,0 +1,30 @@ + + * @license The MIT License + */ +class StatusCodeDecorator extends ResponseDecorator +{ + /** + * Generate a JSON response. + * + * @param array $data + * @param int $status + * @param array $headers + * @return \Illuminate\Http\JsonResponse + */ + public function make(array $data, int $status, array $headers = []): JsonResponse + { + return $this->factory->make(array_merge([ + 'status' => $status, + ], $data), $status, $headers); + } +} diff --git a/src/Http/Responses/Decorators/SuccessFlagDecorator.php b/src/Http/Responses/Decorators/SuccessFlagDecorator.php new file mode 100644 index 0000000..6e2b758 --- /dev/null +++ b/src/Http/Responses/Decorators/SuccessFlagDecorator.php @@ -0,0 +1,30 @@ + + * @license The MIT License + */ +class SuccessFlagDecorator extends ResponseDecorator +{ + /** + * Generate a JSON response. + * + * @param array $data + * @param int $status + * @param array $headers + * @return \Illuminate\Http\JsonResponse + */ + public function make(array $data, int $status, array $headers = []): JsonResponse + { + return $this->factory->make(array_merge([ + 'success' => $status >= 100 && $status < 400, + ], $data), $status, $headers); + } +} diff --git a/src/Http/Responses/ErrorResponseBuilder.php b/src/Http/Responses/ErrorResponseBuilder.php new file mode 100644 index 0000000..a477165 --- /dev/null +++ b/src/Http/Responses/ErrorResponseBuilder.php @@ -0,0 +1,148 @@ + + * @license The MIT License + */ +class ErrorResponseBuilder extends ResponseBuilder +{ + /** + * A factory for building error data output. + * + * @var \Flugg\Responder\Contracts\ErrorFactory + */ + private $errorFactory; + + /** + * A serializer for formatting error data. + * + * @var \Flugg\Responder\Contracts\ErrorSerializer + */ + protected $serializer; + + /** + * A code representing the error. + * + * @var string|null + */ + protected $errorCode = null; + + /** + * A message descibing the error. + * + * @var string|null + */ + protected $message = null; + + /** + * Additional data included with the error. + * + * @var array|null + */ + protected $data = null; + + /** + * A HTTP status code for the response. + * + * @var int + */ + protected $status = 500; + + /** + * Construct the builder class. + * + * @param \Flugg\Responder\Contracts\ResponseFactory $responseFactory + * @param \Flugg\Responder\Contracts\ErrorFactory $errorFactory + */ + public function __construct(ResponseFactory $responseFactory, ErrorFactory $errorFactory) + { + $this->errorFactory = $errorFactory; + + parent::__construct($responseFactory); + } + + /** + * Set the error code and message. + * + * @param string|null $errorCode + * @param string|null $message + * @return $this + */ + public function error(string $errorCode = null, string $message = null) + { + $this->errorCode = $errorCode; + $this->message = $message; + + return $this; + } + + /** + * Add additional data to the error. + * + * @param array|null $data + * @return $this + */ + public function data(array $data = null) + { + $this->data = array_merge((array) $this->data, $data); + + return $this; + } + + /** + * Set the error serializer. + * + * @param \Flugg\Responder\Contracts\ErrorSerializer|string $serializer + * @return $this + * @throws \Flugg\Responder\Exceptions\InvalidErrorSerializerException + */ + public function serializer($serializer) + { + if (is_string($serializer)) { + $serializer = new $serializer; + } + + if (! $serializer instanceof ErrorSerializer) { + throw new InvalidErrorSerializerException; + } + + $this->serializer = $serializer; + + return $this; + } + + /** + * Get the serialized response output. + * + * @return array + */ + protected function getOutput(): array + { + return $this->errorFactory->make($this->serializer, $this->errorCode, $this->message, $this->data); + } + + /** + * Validate the HTTP status code for the response. + * + * @param int $status + * @return void + * @throws \InvalidArgumentException + */ + protected function validateStatusCode(int $status) + { + if ($status < 400 || $status >= 600) { + throw new InvalidArgumentException("{$status} is not a valid error HTTP status code."); + } + } +} diff --git a/src/Http/Responses/Factories/LaravelResponseFactory.php b/src/Http/Responses/Factories/LaravelResponseFactory.php new file mode 100644 index 0000000..935d77c --- /dev/null +++ b/src/Http/Responses/Factories/LaravelResponseFactory.php @@ -0,0 +1,47 @@ + + * @license The MIT License + */ +class LaravelResponseFactory implements ResponseFactory +{ + /** + * The Laravel factory for making responses. + * + * @var \Illuminate\Contracts\Routing\ResponseFactory + */ + protected $factory; + + /** + * Construct the factory class. + * + * @param \Illuminate\Contracts\Routing\ResponseFactory $factory + */ + public function __construct(BaseLaravelResponseFactory $factory) + { + $this->factory = $factory; + } + + /** + * Generate a JSON response. + * + * @param array $data + * @param int $status + * @param array $headers + * @return \Illuminate\Http\JsonResponse + */ + public function make(array $data, int $status, array $headers = []): JsonResponse + { + return $this->factory->json($data, $status, $headers); + } +} \ No newline at end of file diff --git a/src/Http/Responses/Factories/LumenResponseFactory.php b/src/Http/Responses/Factories/LumenResponseFactory.php new file mode 100644 index 0000000..17b05a7 --- /dev/null +++ b/src/Http/Responses/Factories/LumenResponseFactory.php @@ -0,0 +1,47 @@ + + * @license The MIT License + */ +class LumenResponseFactory implements ResponseFactory +{ + /** + * The Lumen factory for making responses. + * + * @var \Laravel\Lumen\Http\ResponseFactory + */ + protected $factory; + + /** + * Construct the factory class. + * + * @param \Laravel\Lumen\Http\ResponseFactory $factory + */ + public function __construct(BaseLumenResponseFactory $factory) + { + $this->factory = $factory; + } + + /** + * Generate a JSON response. + * + * @param array $data + * @param int $status + * @param array $headers + * @return \Illuminate\Http\JsonResponse + */ + public function make(array $data, int $status, array $headers = []): JsonResponse + { + return $this->factory->json($data, $status, $headers); + } +} diff --git a/src/Http/Responses/ResponseBuilder.php b/src/Http/Responses/ResponseBuilder.php new file mode 100644 index 0000000..9b5ae2b --- /dev/null +++ b/src/Http/Responses/ResponseBuilder.php @@ -0,0 +1,133 @@ + + * @license The MIT License + */ +abstract class ResponseBuilder implements Arrayable, Jsonable +{ + /** + * A factory for making responses. + * + * @var \Flugg\Responder\Contracts\ResponseFactory + */ + protected $responseFactory; + + /** + * A HTTP status code for the response. + * + * @var int + */ + protected $status; + + /** + * Construct the builder class. + * + * @param \Flugg\Responder\Contracts\ResponseFactory $responseFactory + */ + public function __construct(ResponseFactory $responseFactory) + { + $this->responseFactory = $responseFactory; + } + + /** + * Decorate the response with the given decorator. + * + * @param string[]|string $decorator + * @return $this + */ + public function decorator($decorator) + { + $decorators = is_array($decorator) ? $decorator : func_get_args(); + + foreach ($decorators as $decorator) { + $this->responseFactory = new $decorator($this->responseFactory); + }; + + return $this; + } + + /** + * Respond with a successful response. + * + * @param int|null $status + * @param array $headers + * @return \Illuminate\Http\JsonResponse + */ + public function respond(int $status = null, array $headers = []): JsonResponse + { + if (! is_null($status)) { + $this->setStatusCode($status); + } + + return $this->responseFactory->make($this->getOutput(), $this->status, $headers); + } + + /** + * Convert the response to an array. + * + * @return array + */ + public function toArray(): array + { + return $this->respond()->getData(true); + } + + /** + * Convert the response to an Illuminate collection. + * + * @return \Illuminate\Support\Collection + */ + public function toCollection(): Collection + { + return new Collection($this->toArray()); + } + + /** + * Convert the response to JSON. + * + * @param int $options + * @return string + */ + public function toJson($options = 0): string + { + return json_encode($this->toArray(), $options); + } + + /** + * Set the HTTP status code for the response. + * + * @param int $status + * @return void + */ + protected function setStatusCode(int $status) + { + $this->validateStatusCode($this->status = $status); + } + + /** + * Get the serialized response output. + * + * @return array + */ + abstract protected function getOutput(): array; + + /** + * Convert the response to an array. + * + * @param int $status + * @return void + */ + abstract protected function validateStatusCode(int $status); +} diff --git a/src/Http/Responses/SuccessResponseBuilder.php b/src/Http/Responses/SuccessResponseBuilder.php new file mode 100644 index 0000000..aca87b5 --- /dev/null +++ b/src/Http/Responses/SuccessResponseBuilder.php @@ -0,0 +1,112 @@ + + * @license The MIT License + * + * @method $this meta(array $meta) + * @method $this with(array | string $relations) + * @method $this without(array | string $relations) + * @method $this serializer(SerializerAbstract | string $serializer) + * @method $this paginator(IlluminatePaginatorAdapter $paginator) + * @method $this cursor(Cursor $cursor) + */ +class SuccessResponseBuilder extends ResponseBuilder +{ + /** + * A builder for building transformed arrays. + * + * @var \Flugg\Responder\TransformBuilder + */ + protected $transformBuilder; + + /** + * A HTTP status code for the response. + * + * @var int + */ + protected $status = 200; + + /** + * Construct the builder class. + * + * @param \Flugg\Responder\Contracts\ResponseFactory $responseFactory + * @param \Flugg\Responder\TransformBuilder $transformBuilder + */ + public function __construct(ResponseFactory $responseFactory, TransformBuilder $transformBuilder) + { + $this->transformBuilder = $transformBuilder; + + parent::__construct($responseFactory); + } + + /** + * Set resource data for the transformation. + * + * @param mixed $data + * @param \Flugg\Responder\Transformers\Transformer|callable|string|null $transformer + * @param string|null $resourceKey + * @return self + */ + public function transform($data = null, $transformer = null, string $resourceKey = null): SuccessResponseBuilder + { + $this->transformBuilder->resource($data, $transformer, $resourceKey); + + return $this; + } + + /** + * Dynamically send calls to the transform builder. + * + * @param string $name + * @param array $arguments + * @return self|void + */ + public function __call($name, $arguments) + { + if (in_array($name, ['meta', 'with', 'without', 'serializer', 'paginator', 'cursor'])) { + $this->transformBuilder->$name(...$arguments); + + return $this; + } + + throw new BadMethodCallException; + } + + /** + * Get the serialized response output. + * + * @return array + */ + protected function getOutput(): array + { + return $this->transformBuilder->transform(); + } + + /** + * Validate the HTTP status code for the response. + * + * @param int $status + * @return void + * @throws \InvalidArgumentException + */ + protected function validateStatusCode(int $status) + { + if ($status < 100 || $status >= 400) { + throw new InvalidArgumentException("{$status} is not a valid success HTTP status code."); + } + } +} diff --git a/src/Http/SuccessResponseBuilder.php b/src/Http/SuccessResponseBuilder.php deleted file mode 100644 index dca92c9..0000000 --- a/src/Http/SuccessResponseBuilder.php +++ /dev/null @@ -1,388 +0,0 @@ - - * @license The MIT License - */ -class SuccessResponseBuilder extends ResponseBuilder -{ - /** - * The manager responsible for transforming and serializing data. - * - * @var \League\Fractal\Manager - */ - protected $manager; - - /** - * The meta data appended to the serialized data. - * - * @var array - */ - protected $meta = []; - - /** - * The included relations. - * - * @var array - */ - protected $relations = []; - - /** - * The Fractal resource instance containing the data and transformer. - * - * @var \League\Fractal\Resource\ResourceInterface - */ - protected $resource; - - /** - * The resource factory used to generate resource instances. - * - * @var \Flugg\Responder\ResourceFactory - */ - protected $resourceFactory; - - /** - * The HTTP status code for the response. - * - * @var int - */ - protected $statusCode = 200; - - /** - * SuccessResponseBuilder constructor. - * - * @param \Illuminate\Contracts\Routing\ResponseFactory|\Laravel\Lumen\Http\ResponseFactory $responseFactory - * @param \Flugg\Responder\ResourceFactory $resourceFactory - * @param \League\Fractal\Manager $manager - */ - public function __construct($responseFactory, ResourceFactory $resourceFactory, Manager $manager) - { - $this->resourceFactory = $resourceFactory; - $this->manager = $manager; - $this->resource = $this->resourceFactory->make(); - - parent::__construct($responseFactory); - } - - /** - * Add data to the meta data appended to the response data. - * - * @param array $data - * @return self - */ - public function addMeta(array $data):SuccessResponseBuilder - { - $this->meta = array_merge($this->meta, $data); - - return $this; - } - - /** - * Set the serializer used to serialize the resource data. - * - * @param array|string $relations - * @return self - */ - public function include($relations):SuccessResponseBuilder - { - if (is_string($relations)) { - $relations = explode(',', $relations); - } - - $this->relations = array_merge($this->relations, (array) $relations); - - return $this; - } - - /** - * Set the serializer used to serialize the resource data. - * - * @param \League\Fractal\Serializer\SerializerAbstract|string $serializer - * @return self - */ - public function serializer($serializer):SuccessResponseBuilder - { - $this->manager->setSerializer($this->resolveSerializer($serializer)); - - return $this; - } - - /** - * Set the HTTP status code for the response. - * - * @param int $statusCode - * @return self - * @throws \InvalidArgumentException - */ - public function setStatus(int $statusCode):ResponseBuilder - { - if ($statusCode < 100 || $statusCode >= 400) { - throw new InvalidArgumentException("{$statusCode} is not a valid success HTTP status code."); - } - - return parent::setStatus($statusCode); - } - - /** - * Return response success flag as true - * - * @return bool - */ - protected function isSuccessResponse():bool - { - return true; - } - - /** - * Set the transformation data. This will set a new resource instance on the response - * builder depending on what type of data is provided. - * - * @param mixed|null $data - * @param callable|string|null $transformer - * @param string|null $resourceKey - * @return self - */ - public function transform($data = null, $transformer = null, string $resourceKey = null):SuccessResponseBuilder - { - $resource = $this->resourceFactory->make($data); - - if (! is_null($resource->getData())) { - $model = $this->resolveModel($resource->getData()); - $transformer = $this->resolveTransformer($model, $transformer); - $resourceKey = $this->resolveResourceKey($model, $resourceKey); - } - - if ($transformer instanceof Transformer) { - $this->include($relations = $this->resolveNestedRelations($resource->getData())); - - if ($transformer->allRelationsAllowed()) { - $transformer->setRelations($relations); - } - } - - $this->resource = $resource->setTransformer($transformer)->setResourceKey($resourceKey); - - return $this; - } - - /** - * Convert the response to an array. - * - * @return array - */ - public function toArray():array - { - return $this->serialize($this->getResource()); - } - - /** - * Get the Fractal resource instance. - * - * @return \League\Fractal\Resource\ResourceInterface - */ - public function getResource():ResourceInterface - { - $this->manager->parseIncludes($this->relations); - $transformer = $this->resource->getTransformer(); - - if ($transformer instanceof Transformer && $transformer->allRelationsAllowed()) { - $this->resource->setTransformer($transformer->setRelations($this->manager->getRequestedIncludes())); - } - - return $this->resource->setMeta($this->meta); - } - - /** - * Get the Fractal manager responsible for transforming and serializing the data. - * - * @return \League\Fractal\Manager - */ - public function getManager():Manager - { - return $this->manager; - } - - /** - * Resolve a serializer instance from the value. - * - * @param \League\Fractal\Serializer\SerializerAbstract|string $serializer - * @return \League\Fractal\Serializer\SerializerAbstract - * @throws \Flugg\Responder\Exceptions\InvalidSerializerException - */ - protected function resolveSerializer($serializer):SerializerAbstract - { - if (is_string($serializer)) { - $serializer = new $serializer; - } - - if (! $serializer instanceof SerializerAbstract) { - throw new InvalidSerializerException(); - } - - return $serializer; - } - - /** - * Resolve a model instance from the data. - * - * @param \Illuminate\Database\Eloquent\Model|array $data - * @return \Illuminate\Database\Eloquent\Model - * @throws \InvalidArgumentException - */ - protected function resolveModel($data):Model - { - if ($data instanceof Model) { - return $data; - } - - $model = array_values($data)[0]; - if (! $model instanceof Model) { - throw new InvalidArgumentException('You can only transform data containing Eloquent models.'); - } - - return $model; - } - - /** - * Resolve a transformer. - * - * @param \Illuminate\Database\ELoquent\Model $model - * @param \Flugg\Responder\Transformer|callable|null $transformer - * @return \Flugg\Responder\Transformer|callable - */ - protected function resolveTransformer(Model $model, $transformer = null) - { - $transformer = $transformer ?: $this->resolveTransformerFromModel($model); - - if (is_string($transformer)) { - $transformer = new $transformer; - } - - return $this->parseTransformer($transformer, $model); - } - - /** - * Resolve a transformer from the model. If the model is not transformable, a closure - * based transformer will be created instead, from the model's fillable attributes. - * - * @param \Illuminate\Database\ELoquent\Model $model - * @return \Flugg\Responder\Transformer|callable - */ - protected function resolveTransformerFromModel(Model $model) - { - if (! $model instanceof Transformable) { - return function ($model) { - return $model->toArray(); - }; - } - - return $model::transformer(); - } - - /** - * Parse a transformer class and set relations. - * - * @param \Flugg\Responder\Transformer|callable $transformer - * @param \Illuminate\Database\ELoquent\Model $model - * @return \Flugg\Responder\Transformer|callable - * @throws \InvalidTransformerException - */ - protected function parseTransformer($transformer, Model $model) - { - if ($transformer instanceof Transformer) { - $relations = $transformer->allRelationsAllowed() ? $this->resolveRelations($model) : $transformer->getRelations(); - $transformer = $transformer->setRelations($relations); - - } elseif (! is_callable($transformer)) { - throw new InvalidTransformerException($model); - } - - return $transformer; - } - - /** - * Resolve eager loaded relations from the model. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @return array - */ - protected function resolveRelations(Model $model):array - { - return array_keys($model->getRelations()); - } - - /** - * Resolve eager loaded relations from the model including any nested relations. - * - * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model $data - * @return array - */ - protected function resolveNestedRelations($data):array - { - if (is_null($data)) { - return []; - } - - $data = $data instanceof Model ? [$data] : $data; - - return collect($data)->flatMap(function ($model) { - $relations = collect($model->getRelations()); - - return $relations->keys()->merge($relations->flatMap(function ($relation, $key) { - return collect($this->resolveNestedRelations($relation))->map(function ($nestedRelation) use ($key) { - return $key . '.' . $nestedRelation; - }); - })); - })->unique()->toArray(); - } - - /** - * Resolve the resource key from the model. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @param string|null $resourceKey - * @return string - */ - protected function resolveResourceKey(Model $model, string $resourceKey = null):string - { - if (! is_null($resourceKey)) { - return $resourceKey; - } - - if (method_exists($model, 'getResourceKey')) { - return $model->getResourceKey(); - } - - return $model->getTable(); - } - - /** - * Serialize the transformation data. - * - * @param ResourceInterface $resource - * @return array - */ - protected function serialize(ResourceInterface $resource):array - { - return $this->manager->createData($resource)->toArray(); - } -} diff --git a/src/Pagination/CursorPaginator.php b/src/Pagination/CursorPaginator.php new file mode 100644 index 0000000..b0ad784 --- /dev/null +++ b/src/Pagination/CursorPaginator.php @@ -0,0 +1,159 @@ + + * @license The MIT License + */ +class CursorPaginator +{ + /** + * A list of the items being paginated. + * + * @var \Illuminate\Support\Collection + */ + protected $items; + + /** + * The current cursor reference. + * + * @var int|string|null + */ + protected $cursor; + + /** + * The previous cursor reference. + * + * @var int|string|null + */ + protected $previousCursor; + + /** + * The next cursor reference. + * + * @var int|string|null + */ + protected $nextCursor; + + /** + * The current cursor resolver callback. + * + * @var \Closure|null + */ + protected static $currentCursorResolver; + + /** + * Create a new paginator instance. + * + * @param \Illuminate\Support\Collection|array|null $data + * @param int|string|null $cursor + * @param int|string|null $previousCursor + * @param int|string|null $nextCursor + */ + public function __construct($data, $cursor, $previousCursor, $nextCursor) + { + $this->cursor = $cursor; + $this->previousCursor = $previousCursor; + $this->nextCursor = $nextCursor; + + $this->set($data); + } + + /** + * Retrieve the current cursor reference. + * + * @return int|string|null + */ + public function cursor() + { + return $this->cursor; + } + + /** + * Retireve the next cursor reference. + * + * @return int|string|null + */ + public function previous() + { + return $this->previousCursor; + } + + /** + * Retireve the next cursor reference. + * + * @return int|string|null + */ + public function next() + { + return $this->nextCursor; + } + + /** + * Get the slice of items being paginated. + * + * @return array + */ + public function items(): array + { + return $this->items->all(); + } + + /** + * Get the paginator's underlying collection. + * + * @return \Illuminate\Support\Collection + */ + public function get(): Collection + { + return $this->items; + } + + /** + * Set the paginator's underlying collection. + * + * @param \Illuminate\Support\Collection|array|null $data + * @return self + */ + public function set($data): CursorPaginator + { + $this->items = $data instanceof Collection ? $data : Collection::make($data);; + + return $this; + } + + /** + * Resolve the current cursor using the cursor resolver. + * + * @param string $name + * @return mixed + * @throws \LogicException + */ + public static function resolveCursor(string $name = 'cursor') + { + if (isset(static::$currentCursorResolver)) { + return call_user_func(static::$currentCursorResolver, $name); + } + + throw new LogicException("Could not resolve cursor with the name [{$name}]."); + } + + /** + * Set the current cursor resolver callback. + * + * @param \Closure $resolver + * @return void + */ + public static function cursorResolver(Closure $resolver) + { + static::$currentCursorResolver = $resolver; + } +} diff --git a/src/Pagination/PaginatorFactory.php b/src/Pagination/PaginatorFactory.php new file mode 100644 index 0000000..860b359 --- /dev/null +++ b/src/Pagination/PaginatorFactory.php @@ -0,0 +1,60 @@ + + * @license The MIT License + */ +class PaginatorFactory implements PaginatorFactoryContract +{ + /** + * A list of query string values appended to the paginator links. + * + * @var array + */ + protected $parameters; + + /** + * Construct the factory class. + * + * @param array $parameters + */ + public function __construct(array $parameters) + { + $this->parameters = $parameters; + } + + /** + * Make a Fractal paginator adapter from a Laravel paginator. + * + * @param \Illuminate\Contracts\Pagination\LengthAwarePaginator $paginator + * @return \League\Fractal\Pagination\PaginatorInterface + */ + public function make(LengthAwarePaginator $paginator): PaginatorInterface + { + $paginator->appends($this->parameters); + + return new IlluminatePaginatorAdapter($paginator); + } + + /** + * Make a Fractal paginator adapter from a Laravel paginator. + * + * @param \Flugg\Responder\Pagination\CursorPaginator $paginator + * @return \League\Fractal\Pagination\Cursor + */ + public function makeCursor(CursorPaginator $paginator): Cursor + { + return new Cursor($paginator->cursor(), $paginator->previous(), $paginator->next(), $paginator->get()->count()); + } +} diff --git a/src/ResourceFactory.php b/src/ResourceFactory.php deleted file mode 100644 index 585bf9e..0000000 --- a/src/ResourceFactory.php +++ /dev/null @@ -1,165 +0,0 @@ - - * @license The MIT License - */ -class ResourceFactory -{ - /** - * Mappings of supported data types with corresponding make methods. - * - * @var array - */ - const MAKE_METHODS = [ - Builder::class => 'makeFromBuilder', - Collection::class => 'makeFromCollection', - Pivot::class => 'makeFromPivot', - Model::class => 'makeFromModel', - Paginator::class => 'makeFromPaginator', - Relation::class => 'makeFromRelation' - ]; - - /** - * Build a resource instance from the given data. - * - * @param mixed|null $data - * @return \League\Fractal\Resource\ResourceInterface - */ - public function make($data = null) - { - if (is_null($data)) { - return new NullResource(); - } elseif (is_array($data)) { - return static::makeFromArray($data); - } - - $method = static::getMakeMethod($data); - - return static::$method($data); - } - - /** - * Resolve which make method to call from the given date type. - * - * @param mixed $data - * @return string - * @throws \InvalidArgumentException - */ - protected function getMakeMethod($data):string - { - foreach (static::MAKE_METHODS as $class => $method) { - if ($data instanceof $class) { - return $method; - } - } - - throw new InvalidArgumentException('Given data cannot be transformed.'); - } - - /** - * Make resource from an Eloquent model. - * - * @param \Illuminate\Database\Eloquent\Model $model - * @return \League\Fractal\Resource\ResourceInterface - */ - protected function makeFromModel(Model $model):ResourceInterface - { - return new ItemResource($model); - } - - /** - * Make resource from a collection of Eloquent models. - * - * @param array $array - * @return \League\Fractal\Resource\ResourceInterface - */ - protected function makeFromArray(array $array):ResourceInterface - { - return empty($array) ? new NullResource() : new CollectionResource($array); - } - - /** - * Make resource from a collection. - * - * @param \Illuminate\Support\Collection $collection - * @return \League\Fractal\Resource\ResourceInterface - */ - protected function makeFromCollection(Collection $collection):ResourceInterface - { - return static::makeFromArray($collection->all()); - } - - /** - * Make resource from an Eloquent query builder. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \League\Fractal\Resource\ResourceInterface - */ - protected function makeFromBuilder(Builder $query):ResourceInterface - { - return static::makeFromCollection($query->get()); - } - - /** - * Make resource from an Eloquent paginator. - * - * @param \Illuminate\Contracts\Pagination\LengthAwarePaginator $paginator - * @return \League\Fractal\Resource\ResourceInterface - */ - protected function makeFromPaginator(Paginator $paginator):ResourceInterface - { - $resource = static::makeFromCollection($paginator->getCollection()); - - if ($resource instanceof CollectionResource) { - $queryParams = array_diff_key(app('request')->all(), array_flip(['page'])); - $paginator->appends($queryParams); - - $resource->setPaginator(new IlluminatePaginatorAdapter($paginator)); - } - - return $resource; - } - - /** - * Make resource from an Eloquent pivot table. - * - * @param \Illuminate\Database\Eloquent\Relations\Pivot $pivot - * @return \League\Fractal\Resource\ResourceInterface - */ - protected function makeFromPivot(Pivot $pivot):ResourceInterface - { - return static::makeFromModel($pivot); - } - - /** - * Make resource from an Eloquent query builder. - * - * @param \Illuminate\Database\Eloquent\Relations\Relation $relation - * @return \League\Fractal\Resource\ResourceInterface - */ - protected function makeFromRelation(Relation $relation):ResourceInterface - { - return static::makeFromCollection($relation->get()); - } -} diff --git a/src/Resources/DataNormalizer.php b/src/Resources/DataNormalizer.php new file mode 100644 index 0000000..a2deede --- /dev/null +++ b/src/Resources/DataNormalizer.php @@ -0,0 +1,75 @@ + + * @license The MIT License + */ +class DataNormalizer +{ + /** + * Normalize the data for a resource. + * + * @param mixed $data + * @return mixed + */ + public function normalize($data = null) + { + if ($this->isInstanceOf($data, [Builder::class, EloquentBuilder::class, CursorPaginator::class])) { + return $data->get(); + } elseif ($data instanceof Paginator) { + return $data->getCollection(); + } elseif ($data instanceof Relation) { + return $this->normalizeRelation($data); + } + + return $data; + } + + /** + * Normalize a relationship. + * + * @param \Illuminate\Database\Eloquent\Relations\Relation $relation + * @return \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|null + */ + protected function normalizeRelation(Relation $relation) + { + if ($this->isInstanceOf($relation, [BelongsTo::class, HasOne::class, MorphOne::class, MorphTo::class])) { + return $relation->first(); + } + + return $relation->get(); + } + + /** + * Indicates if the given data is an instance of any of the given class names. + * + * @param mixed $data + * @param array $classes + * @return bool + */ + protected function isInstanceOf($data, array $classes): bool + { + foreach ($classes as $class) { + if ($data instanceof $class) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/Resources/ResourceFactory.php b/src/Resources/ResourceFactory.php new file mode 100644 index 0000000..3effd9d --- /dev/null +++ b/src/Resources/ResourceFactory.php @@ -0,0 +1,119 @@ + + * @license The MIT License + */ +class ResourceFactory implements ResourceFactoryContract +{ + /** + * A service class, used to normalize data. + * + * @var \Flugg\Responder\Resources\DataNormalizer + */ + protected $normalizer; + + /** + * A manager class, used to manage transformers. + * + * @var \Flugg\Responder\Contracts\Transformers\TransformerResolver + */ + protected $transformerResolver; + + /** + * Construct the factory class. + * + * @param \Flugg\Responder\Resources\DataNormalizer $normalizer + * @param \Flugg\Responder\Contracts\Transformers\TransformerResolver $transformerResolver + */ + public function __construct(DataNormalizer $normalizer, TransformerResolver $transformerResolver) + { + $this->normalizer = $normalizer; + $this->transformerResolver = $transformerResolver; + } + + /** + * Make resource from the given data. + * + * @param mixed $data + * @param \Flugg\Responder\Transformers\Transformer|string|callable|null $transformer + * @param string|null $resourceKey + * @return \League\Fractal\Resource\ResourceInterface + */ + public function make($data = null, $transformer = null, string $resourceKey = null): ResourceInterface + { + if ($data instanceof ResourceInterface) { + return $data->setTransformer($this->resolveTransformer($data->getData(), $transformer ?: $data->getTransformer())); + } elseif (is_null($data = $this->normalizer->normalize($data))) { + return $this->instatiateResource($data); + } + + $transformer = $this->resolveTransformer($data, $transformer); + + return $this->instatiateResource($data, $transformer, $resourceKey); + } + + /** + * Resolve a transformer. + * + * @param mixed $data + * @param \Flugg\Responder\Transformers\Transformer|string|callable|null $transformer + * @return \Flugg\Responder\Transformers\Transformer|callable + */ + protected function resolveTransformer($data, $transformer) + { + if (isset($transformer)) { + return $this->transformerResolver->resolve($transformer); + } + + return $this->transformerResolver->resolveFromData($data); + } + + /** + * Instatiate a new resource instance. + * + * @param mixed $data + * @param \Flugg\Responder\Transformers\Transformer|callable|null $transformer + * @param string|null $resourceKey + * @return \League\Fractal\Resource\ResourceInterface + */ + protected function instatiateResource($data, $transformer = null, string $resourceKey = null): ResourceInterface + { + if (is_null($data)) { + return new NullResource; + } elseif ($this->shouldCreateCollection($data)) { + return new CollectionResource($data, $transformer, $resourceKey); + } + + return new ItemResource($data, $transformer, $resourceKey); + } + + /** + * Indicates if the data belongs to a collection resource. + * + * @param mixed $data + * @return bool + */ + protected function shouldCreateCollection($data): bool + { + if (is_array($data)) { + return ! is_scalar(Arr::first($data)); + } + + return $data instanceof Traversable; + } +} \ No newline at end of file diff --git a/src/Responder.php b/src/Responder.php index 99e119c..de3106f 100644 --- a/src/Responder.php +++ b/src/Responder.php @@ -2,96 +2,67 @@ namespace Flugg\Responder; -use Flugg\Responder\ErrorResponse; -use Flugg\Responder\Http\ErrorResponseBuilder; -use Flugg\Responder\Http\SuccessResponseBuilder; -use Flugg\Responder\SuccessResponse; -use Illuminate\Http\JsonResponse; +use Flugg\Responder\Contracts\Responder as ResponderContract; +use Flugg\Responder\Http\Responses\ErrorResponseBuilder; +use Flugg\Responder\Http\Responses\SuccessResponseBuilder; /** - * The responder service. This class is responsible for generating JSON API responses. + * A service class responsible for building responses. * * @package flugger/laravel-responder * @author Alexander Tømmerås * @license The MIT License */ -class Responder +class Responder implements ResponderContract { /** - * The response builder used to build success responses. + * A builder for building success responses. * - * @var \Flugg\Responder\Http\SuccessResponseBuilder + * @var \Flugg\Responder\Http\Responses\SuccessResponseBuilder */ - protected $successResponse; + protected $successResponseBuilder; /** - * The response builder used to build error responses. + * A builder for building error responses. * - * @var \Flugg\Responder\Http\ErrorResponseBuilder + * @var \Flugg\Responder\Http\Responses\ErrorResponseBuilder */ - protected $errorResponse; + protected $errorResponseBuilder; /** - * Constructor. + * Construct the service class. * - * @param \Flugg\Responder\Http\ErrorResponseBuilder $errorResponse - * @param \Flugg\Responder\Http\SuccessResponseBuilder $successResponse + * @param \Flugg\Responder\Http\Responses\SuccessResponseBuilder $successResponseBuilder + * @param \Flugg\Responder\Http\Responses\ErrorResponseBuilder $errorResponseBuilder */ - public function __construct(SuccessResponseBuilder $successResponse, ErrorResponseBuilder $errorResponse) + public function __construct(SuccessResponseBuilder $successResponseBuilder, ErrorResponseBuilder $errorResponseBuilder) { - $this->successResponse = $successResponse; - $this->errorResponse = $errorResponse; + $this->successResponseBuilder = $successResponseBuilder; + $this->errorResponseBuilder = $errorResponseBuilder; } /** - * Generate an error JSON response. + * Build a successful response. * - * @param mixed|null $errorCode - * @param int|null $statusCode - * @param mixed $message - * @return \Illuminate\Http\JsonResponse + * @param mixed $data + * @param callable|string|\Flugg\Responder\Transformers\Transformer|null $transformer + * @param string|null $resourceKey + * @return \Flugg\Responder\Http\Responses\SuccessResponseBuilder */ - public function error($errorCode = null, int $statusCode = null, $message = null):JsonResponse + public function success($data = null, $transformer = null, string $resourceKey = null): SuccessResponseBuilder { - if ($exception = config("responder.exceptions.$errorCode")) { - if (class_exists($exception)) { - throw new $exception(); - } - } - - return $this->errorResponse->setError($errorCode, $message)->respond($statusCode); - } - - /** - * Generate a successful JSON response. - * - * @param mixed|null $data - * @param int|null $statusCode - * @param array $meta - * @return \Illuminate\Http\JsonResponse - */ - public function success($data = null, $statusCode = null, array $meta = []):JsonResponse - { - if (is_integer($data)) { - list($data, $statusCode, $meta) = [null, $data, is_array($statusCode) ? $statusCode : []]; - } - - if (is_array($statusCode)) { - list($statusCode, $meta) = [200, $statusCode]; - } - - return $this->successResponse->transform($data)->addMeta($meta)->respond($statusCode); + return $this->successResponseBuilder->transform($data, $transformer, $resourceKey); } /** - * Transform the data and return a success response builder. + * Build an error response. * - * @param mixed|null $data - * @param callable|string|null $transformer - * @return \Flugg\Responder\Http\SuccessResponseBuilder + * @param string|null $errorCode + * @param string|null $message + * @return \Flugg\Responder\Http\Responses\ErrorResponseBuilder */ - public function transform($data = null, $transformer = null):SuccessResponseBuilder + public function error(string $errorCode = null, string $message = null): ErrorResponseBuilder { - return $this->successResponse->transform($data, $transformer); + return $this->errorResponseBuilder->error($errorCode, $message); } -} +} \ No newline at end of file diff --git a/src/ResponderServiceProvider.php b/src/ResponderServiceProvider.php index 85da8c4..57cdccb 100644 --- a/src/ResponderServiceProvider.php +++ b/src/ResponderServiceProvider.php @@ -3,9 +3,24 @@ namespace Flugg\Responder; use Flugg\Responder\Console\MakeTransformer; -use Flugg\Responder\Contracts\Manager as ManagerContract; -use Flugg\Responder\Http\ErrorResponseBuilder; -use Flugg\Responder\Http\SuccessResponseBuilder; +use Flugg\Responder\Contracts\ErrorFactory as ErrorFactoryContract; +use Flugg\Responder\Contracts\ErrorMessageResolver as ErrorMessageResolverContract; +use Flugg\Responder\Contracts\ErrorSerializer as ErrorSerializerContract; +use Flugg\Responder\Contracts\Pagination\PaginatorFactory as PaginatorFactoryContract; +use Flugg\Responder\Contracts\Resources\ResourceFactory as ResourceFactoryContract; +use Flugg\Responder\Contracts\Responder as ResponderContract; +use Flugg\Responder\Contracts\ResponseFactory as ResponseFactoryContract; +use Flugg\Responder\Contracts\Transformer as TransformerContract; +use Flugg\Responder\Contracts\Transformers\TransformerResolver as TransformerResolverContract; +use Flugg\Responder\Contracts\TransformFactory as TransformFactoryContract; +use Flugg\Responder\Http\Responses\ErrorResponseBuilder; +use Flugg\Responder\Http\Responses\Factories\LaravelResponseFactory; +use Flugg\Responder\Http\Responses\Factories\LumenResponseFactory; +use Flugg\Responder\Pagination\PaginatorFactory; +use Flugg\Responder\Resources\ResourceFactory; +use Flugg\Responder\Transformers\Transformer as BaseTransformer; +use Flugg\Responder\Transformers\TransformerResolver; +use Illuminate\Contracts\Container\Container; use Illuminate\Foundation\Application as Laravel; use Illuminate\Http\Request; use Illuminate\Support\ServiceProvider as BaseServiceProvider; @@ -14,7 +29,7 @@ use League\Fractal\Serializer\SerializerAbstract; /** - * The Laravel Responder service provider. This is where the package is bootstrapped. + * A service provider class responsible for bootstrapping the parts of the Laravel package. * * @package flugger/laravel-responder * @author Alexander Tømmerås @@ -22,13 +37,6 @@ */ class ResponderServiceProvider extends BaseServiceProvider { - /** - * Keeps a quick reference to the Responder config. - * - * @var \Illuminate\Config\Repository - */ - protected $config; - /** * Indicates if loading of the provider is deferred. * @@ -37,116 +45,210 @@ class ResponderServiceProvider extends BaseServiceProvider protected $defer = false; /** - * Bootstrap the application events. + * Register the service provider. * * @return void */ - public function boot() + public function register() { - if ($this->app instanceof Laravel && $this->app->runningInConsole()) { - $this->bootLaravelApplication(); - + if ($this->app instanceof Laravel) { + $this->registerLaravelBindings(); } elseif ($this->app instanceof Lumen) { - $this->bootLumenApplication(); + $this->registerLumenBindings(); } - $this->mergeConfigFrom(__DIR__ . '/../resources/config/responder.php', 'responder'); - - $this->commands([ - MakeTransformer::class - ]); - - include __DIR__ . '/helpers.php'; + $this->registerSerializerBindings(); + $this->registerErrorBindings(); + $this->registerFractalBindings(); + $this->registerResourceBindings(); + $this->registerPaginationBindings(); + $this->registerTransformationBindings(); + $this->registerTransformerBindings(); + $this->registerServiceBindings(); } /** - * Register the service provider. + * Register Laravel bindings. * * @return void */ - public function register() + protected function registerLaravelBindings() { - $this->registerFractal(); - $this->registerResponseBuilders(); - - $this->app->bind(Responder::class, function ($app) { - return new Responder($app[SuccessResponseBuilder::class], $app[ErrorResponseBuilder::class]); + $this->app->singleton(ResponseFactoryContract::class, function ($app) { + return $this->decorateResponseFactory($app->make(LaravelResponseFactory::class)); }); + } - $this->registerAliases(); + /** + * Register Lumen bindings. + * + * @return void + */ + protected function registerLumenBindings() + { + $this->app->singleton(ResponseFactoryContract::class, function ($app) { + return $this->decorateResponseFactory($app->make(LumenResponseFactory::class)); + }); } /** - * Get the services provided by the provider. + * Decorate response factories. * - * @return array + * @param \Flugg\Responder\Contracts\ResponseFactory $factory + * @return void */ - public function provides() + protected function decorateResponseFactory(ResponseFactoryContract $factory) { - return ['responder', 'responder.success', 'responder.error', 'responder.manager', 'responder.serializer']; + foreach ($this->app->config['responder.decorators'] as $decorator) { + $factory = new $decorator($factory); + }; + + return $factory; } /** - * Register Fractal serializer, manager and a factory to generate Fractal - * resource instances. + * Register serializer bindings. * * @return void */ - protected function registerFractal() + protected function registerSerializerBindings() { + $this->app->bind(ErrorSerializerContract::class, function ($app) { + return $app->make($app->config['responder.serializers.error']); + }); + $this->app->bind(SerializerAbstract::class, function ($app) { - $serializer = $app->config->get('responder.serializer'); + return $app->make($app->config['responder.serializers.success']); + }); + } + + /** + * Register error bindings. + * + * @return void + */ + protected function registerErrorBindings() + { + $this->app->singleton(ErrorMessageResolverContract::class, function ($app) { + return $app->make(ErrorMessageResolver::class); + }); - return new $serializer; + $this->app->singleton(ErrorFactoryContract::class, function ($app) { + return $app->make(ErrorFactory::class); }); + $this->app->bind(ErrorResponseBuilder::class, function ($app) { + return (new ErrorResponseBuilder($app->make(ResponseFactoryContract::class), $app->make(ErrorFactoryContract::class))) + ->serializer($app->make(ErrorSerializerContract::class)); + }); + } + + /** + * Register Fractal bindings. + * + * @return void + */ + protected function registerFractalBindings() + { $this->app->bind(Manager::class, function ($app) { - return (new Manager())->setSerializer($app[SerializerAbstract::class]); + return (new Manager)->setRecursionLimit($app->config['responder.recursion_limit']); }); + } - $this->app->bind(ResourceFactory::class, function () { - return new ResourceFactory(); + /** + * Register pagination bindings. + * + * @return void + */ + protected function registerResourceBindings() + { + $this->app->singleton(ResourceFactoryContract::class, function ($app) { + return $app->make(ResourceFactory::class); + }); + } + + /** + * Register pagination bindings. + * + * @return void + */ + protected function registerPaginationBindings() + { + $this->app->singleton(PaginatorFactoryContract::class, function ($app) { + return new PaginatorFactory($app->make(Request::class)->query()); }); } /** - * Register success and error response builders. + * Register transformation bindings. * * @return void */ - protected function registerResponseBuilders() + protected function registerTransformationBindings() { - $this->app->bind(SuccessResponseBuilder::class, function ($app) { - $builder = new SuccessResponseBuilder(response(), $app[ResourceFactory::class], $app[Manager::class]); + $this->app->singleton(TransformFactoryContract::class, function ($app) { + return $app->make(FractalTransformFactory::class); + }); - if ($parameter = $app->config->get('responder.load_relations_from_parameter')) { - $builder->include($this->app[Request::class]->input($parameter, [])); - } + $this->app->bind(TransformBuilder::class, function ($app) { + return (new TransformBuilder($app->make(ResourceFactoryContract::class), $app->make(TransformFactoryContract::class), $app->make(PaginatorFactoryContract::class))) + ->serializer($app->make(SerializerAbstract::class)) + ->with($app->make(Request::class)->input($app->config['responder.load_relations_parameter'], [])) + ->only($app->make(Request::class)->input($app->config['responder.filter_fields_parameter'], [])); + }); - $builder->setIncludeSuccessFlag($app->config->get('responder.include_success_flag')); - return $builder->setIncludeStatusCode($app->config->get('responder.include_status_code')); + $this->app->singleton(TransformerResolverContract::class, function ($app) { + return $app->make(TransformerResolver::class); }); + } - $this->app->bind(ErrorResponseBuilder::class, function ($app) { - $builder = new ErrorResponseBuilder(response(), $app['translator']); + /** + * Register transformer bindings. + * + * @return void + */ + protected function registerTransformerBindings() + { + $this->app->singleton(TransformerResolverContract::class, function ($app) { + return $app->make(TransformerResolver::class); + }); + + BaseTransformer::containerResolver(function () { + return $this->app->make(Container::class); + }); + } + + /** + * Register service bindings. + * + * @return void + */ + protected function registerServiceBindings() + { + $this->app->singleton(ResponderContract::class, function ($app) { + return $app->make(Responder::class); + }); - $builder->setIncludeSuccessFlag($app->config->get('responder.include_success_flag')); - return $builder->setIncludeStatusCode($app->config->get('responder.include_status_code')); + $this->app->singleton(TransformerContract::class, function ($app) { + return $app->make(Transformer::class); }); } /** - * Set aliases for the provided services. + * Bootstrap the application events. * * @return void */ - protected function registerAliases() + public function boot() { - $this->app->alias(Responder::class, 'responder'); - $this->app->alias(SuccessResponseBuilder::class, 'responder.success'); - $this->app->alias(ErrorResponseBuilder::class, 'responder.error'); - $this->app->alias(Manager::class, 'responder.manager'); - $this->app->alias(Manager::class, 'responder.serializer'); + if ($this->app instanceof Laravel) { + $this->bootLaravelApplication(); + } elseif ($this->app instanceof Lumen) { + $this->bootLumenApplication(); + } + + $this->mergeConfigFrom(__DIR__ . '/../config/responder.php', 'responder'); + $this->commands(MakeTransformer::class); } /** @@ -156,13 +258,14 @@ protected function registerAliases() */ protected function bootLaravelApplication() { - $this->publishes([ - __DIR__ . '/../resources/config/responder.php' => config_path('responder.php') - ], 'config'); - - $this->publishes([ - __DIR__ . '/../resources/lang/en/errors.php' => base_path('resources/lang/en/errors.php') - ], 'lang'); + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__ . '/../config/responder.php' => config_path('responder.php'), + ], 'config'); + $this->publishes([ + __DIR__ . '/../resources/lang/en/errors.php' => base_path('resources/lang/en/errors.php'), + ], 'lang'); + } } /** @@ -174,4 +277,4 @@ protected function bootLumenApplication() { $this->app->configure('responder'); } -} +} \ No newline at end of file diff --git a/src/Serializers/ErrorSerializer.php b/src/Serializers/ErrorSerializer.php new file mode 100644 index 0000000..11f3dec --- /dev/null +++ b/src/Serializers/ErrorSerializer.php @@ -0,0 +1,34 @@ + + * @license The MIT License + */ +class ErrorSerializer implements ErrorSerializerContract +{ + /** + * Format the error data. + * + * @param string|null $errorCode + * @param string|null $message + * @param array|null $data + * @return array + */ + public function format(string $errorCode = null, string $message = null, array $data = null): array + { + return [ + 'error' => [ + 'code' => $errorCode, + 'message' => $message, + 'data' => $data, + ], + ]; + } +} diff --git a/src/Serializers/NullSerializer.php b/src/Serializers/NullSerializer.php new file mode 100644 index 0000000..54ef1c1 --- /dev/null +++ b/src/Serializers/NullSerializer.php @@ -0,0 +1,95 @@ + + * @license The MIT License + */ +class NullSerializer extends SuccessSerializer +{ + /** + * Serialize collection resources. + * + * @param string $resourceKey + * @param array $data + * @return array + */ + public function collection($resourceKey, array $data) + { + return $data; + } + + /** + * Serialize item resources. + * + * @param string $resourceKey + * @param array $data + * @return array + */ + public function item($resourceKey, array $data) + { + return $data; + } + + /** + * Serialize null resources. + * + * @return array + */ + public function null() + { + return []; + } + + /** + * Format meta data. + * + * @param array $meta + * @return array + */ + public function meta(array $meta) + { + return []; + } + + /** + * Format pagination data. + * + * @param \League\Fractal\Pagination\PaginatorInterface $paginator + * @return array + */ + public function paginator(PaginatorInterface $paginator) + { + return []; + } + + /** + * Format cursor data. + * + * @param \League\Fractal\Pagination\CursorInterface $cursor + * @return array + */ + public function cursor(CursorInterface $cursor) + { + return []; + } + + /** + * Merge includes into data. + * + * @param array $transformedData + * @param array $includedData + * @return array + */ + public function mergeIncludes($transformedData, $includedData) + { + return array_merge($transformedData, $includedData); + } +} diff --git a/src/Serializers/ApiSerializer.php b/src/Serializers/SuccessSerializer.php similarity index 53% rename from src/Serializers/ApiSerializer.php rename to src/Serializers/SuccessSerializer.php index 5bfcdb5..6d9ee98 100644 --- a/src/Serializers/ApiSerializer.php +++ b/src/Serializers/SuccessSerializer.php @@ -2,21 +2,22 @@ namespace Flugg\Responder\Serializers; +use League\Fractal\Pagination\CursorInterface; use League\Fractal\Pagination\PaginatorInterface; use League\Fractal\Resource\ResourceInterface; use League\Fractal\Serializer\ArraySerializer; /** - * This class is the package's own implementation of Fractal's serializers. + * A serializer class responsible for formatting success data. * * @package flugger/laravel-responder * @author Alexander Tømmerås * @license The MIT License */ -class ApiSerializer extends ArraySerializer +class SuccessSerializer extends ArraySerializer { /** - * Serialize a collection. + * Serialize collection resources. * * @param string $resourceKey * @param array $data @@ -24,11 +25,11 @@ class ApiSerializer extends ArraySerializer */ public function collection($resourceKey, array $data) { - return $this->item($resourceKey, $data); + return ['data' => $data]; } /** - * Serialize an item. + * Serialize item resources. * * @param string $resourceKey * @param array $data @@ -36,25 +37,21 @@ public function collection($resourceKey, array $data) */ public function item($resourceKey, array $data) { - return array_merge($this->null(), [ - 'data' => $data - ]); + return ['data' => $data]; } /** - * Serialize a null resource. + * Serialize null resources. * * @return array */ public function null() { - return [ - 'data' => null - ]; + return ['data' => null]; } /** - * Serialize the meta. + * Format meta data. * * @param array $meta * @return array @@ -65,26 +62,43 @@ public function meta(array $meta) } /** - * Serialize the paginator. - * - * @param PaginatorInterface $paginator + * Format pagination data. * + * @param \League\Fractal\Pagination\PaginatorInterface $paginator * @return array */ public function paginator(PaginatorInterface $paginator) { $pagination = parent::paginator($paginator)['pagination']; - $data = [ - 'total' => $pagination['total'], - 'count' => $pagination['count'], - 'perPage' => $pagination['per_page'], - 'currentPage' => $pagination['current_page'], - 'totalPages' => $pagination['total_pages'], - 'links' => $pagination['links'], + return [ + 'pagination' => [ + 'count' => $pagination['count'], + 'total' => $pagination['total'], + 'perPage' => $pagination['per_page'], + 'currentPage' => $pagination['current_page'], + 'totalPages' => $pagination['total_pages'], + 'links' => $pagination['links'], + ], ]; + } - return ['pagination' => $data]; + /** + * Format cursor data. + * + * @param \League\Fractal\Pagination\CursorInterface $cursor + * @return array + */ + public function cursor(CursorInterface $cursor) + { + return [ + 'cursor' => [ + 'current' => $cursor->getCurrent(), + 'previous' => $cursor->getPrev(), + 'next' => $cursor->getNext(), + 'count' => (int) $cursor->getCount(), + ], + ]; } /** @@ -98,7 +112,7 @@ public function sideloadIncludes() } /** - * Merges any relations into the data. The 'data' field is also removed. + * Merge includes into data. * * @param array $transformedData * @param array $includedData @@ -106,9 +120,7 @@ public function sideloadIncludes() */ public function mergeIncludes($transformedData, $includedData) { - $keys = array_keys($includedData); - - foreach ($keys as $key) { + foreach (array_keys($includedData) as $key) { $includedData[$key] = $includedData[$key]['data']; } @@ -116,11 +128,10 @@ public function mergeIncludes($transformedData, $includedData) } /** - * Serialize the included data. - * - * @param ResourceInterface $resource - * @param array $data + * Format the included data. * + * @param \League\Fractal\Resource\ResourceInterface $resource + * @param array $data * @return array */ public function includedData(ResourceInterface $resource, array $data) diff --git a/src/Traits/MakesApiRequests.php b/src/Testing/MakesApiRequests.php similarity index 90% rename from src/Traits/MakesApiRequests.php rename to src/Testing/MakesApiRequests.php index 1af872f..3f3feee 100644 --- a/src/Traits/MakesApiRequests.php +++ b/src/Testing/MakesApiRequests.php @@ -6,8 +6,7 @@ use Illuminate\Http\JsonResponse; /** - * Use this trait in your base test case to give you some helper methods for - * integration testing the API responses generated by the package. + * A trait to be used by test case classes to give access to additional assertion methods. * * @package flugger/laravel-responder * @author Alexander Tømmerås @@ -54,7 +53,7 @@ protected function seeSuccessEquals($data = null, $status = 200) protected function seeSuccessStructure($data = null) { $this->seeJsonStructure([ - 'data' => $data + 'data' => $data, ]); return $this; @@ -65,15 +64,15 @@ protected function seeSuccessStructure($data = null) * * @param mixed $data * @param int $status - * @return JsonResponse + * @return \Illuminate\Http\JsonResponse */ - protected function seeSuccessResponse($data = null, $status = 200):JsonResponse + protected function seeSuccessResponse($data = null, $status = 200): JsonResponse { - $response = app(Responder::class)->success($data, $status); + $response = $this->app->make(Responder::class)->success($data, $status); $this->seeStatusCode($response->getStatusCode())->seeJson([ 'success' => true, - 'status' => $response->getStatusCode() + 'status' => $response->getStatusCode(), ])->seeJsonStructure(['data']); return $response; @@ -138,16 +137,16 @@ protected function seeError(string $error, int $status = null) if ($this->app->config->get('responder.status_code')) { $this->seeJson([ - 'status' => $status + 'status' => $status, ]); } return $this->seeJson([ - 'success' => false + 'success' => false, ])->seeJsonSubset([ 'error' => [ - 'code' => $error - ] + 'code' => $error, + ], ]); } diff --git a/src/Traits/ConvertsParameters.php b/src/Traits/ConvertsParameters.php deleted file mode 100644 index b48685e..0000000 --- a/src/Traits/ConvertsParameters.php +++ /dev/null @@ -1,175 +0,0 @@ - - * @license The MIT License - */ -trait ConvertsParameters -{ - /** - * Check if an input element is set on the request. - * - * @param string $key - * @return bool - */ - public function __isset($key) - { - return parent::__isset(snake_case($key)); - } - - /** - * Get an input element from the request. - * - * @param string $key - * @return mixed - */ - public function __get($key) - { - return parent::__get(snake_case($key)); - } - - /** - * Get the validator instance for the request. - * - * @return \Illuminate\Contracts\Validation\Validator - */ - protected function getValidatorInstance() - { - $this->getInputSource()->replace($this->getConvertedParameters()); - - return parent::getValidatorInstance(); - } - - /** - * Get the input source for the request. - * - * @return \Symfony\Component\HttpFoundation\ParameterBag - */ - abstract protected function getInputSource(); - - /** - * Cast and convert parameters. - * - * @return array - */ - protected function getConvertedParameters():array - { - $parameters = $this->all(); - $parameters = $this->castBooleans($parameters); - $parameters = $this->convertToSnakeCase($parameters); - - if (method_exists($this, 'convertParameters')) { - $parameters = $this->convertParameters($parameters); - } - - return $parameters; - } - - /** - * Get all of the input and files for the request. - * - * @return array - */ - abstract public function all(); - - /** - * Cast all string booleans to real boolean values. - * - * @param mixed $input - * @return array - */ - protected function castBooleans($input):array - { - if ($this->castToBooleanIsDisabled()) { - return []; - } - - $casted = []; - - foreach ($input as $key => $value) { - $casted[$key] = $this->castValueToBoolean($value); - } - - return $casted; - } - - /** - * Checks if the user wants to cast to booleans. - * - * @return bool - */ - protected function castToBooleanIsDisabled():bool - { - return isset($this->castBooleans) && ! $this->castBooleans; - } - - /** - * Cast a given value to a boolean if it is in fact a boolean. - * - * @param mixed $value - * @return mixed - */ - protected function castValueToBoolean($value) - { - if (in_array($value, ['true', 'false'])) { - return filter_var($value, FILTER_VALIDATE_BOOLEAN); - } - - return $value; - } - - /** - * Convert a string or array to snake case. - * - * @param mixed $input - * @return mixed - */ - protected function convertToSnakeCase($input) - { - if ($this->convertToSnakeCaseIsDisabled()) { - return; - } - - if (is_null($input)) { - return null; - } elseif (is_array($input)) { - return $this->convertArrayToSnakeCase($input); - } - - return snake_case($input); - } - - /** - * Checks if the user wants to convert to snake case. - * - * @return bool - */ - protected function convertToSnakeCaseIsDisabled():bool - { - return isset($this->convertToSnakeCase) && ! $this->convertToSnakeCase; - } - - /** - * Convert all keys of an array to snake case. - * - * @param array $input - * @return array - */ - protected function convertArrayToSnakeCase(array $input):array - { - $converted = []; - - foreach ($input as $key => $value) { - $converted[snake_case($key)] = $value; - } - - return $converted; - } -} diff --git a/src/Traits/HandlesApiErrors.php b/src/Traits/HandlesApiErrors.php deleted file mode 100644 index 0780e1b..0000000 --- a/src/Traits/HandlesApiErrors.php +++ /dev/null @@ -1,110 +0,0 @@ - - * @license The MIT License - */ -trait HandlesApiErrors -{ - /** - * Transform a Laravel exception into an API exception. - * - * @param Exception $exception - * @return void - */ - protected function transformException(Exception $exception) - { - if (Request::capture()->wantsJson()) { - $this->transformAuthException($exception); - $this->transformEloquentException($exception); - $this->transformValidationException($exception); - } - } - - /** - * Transform a Laravel auth exception into an API exception. - * - * @param Exception $exception - * @return void - * @throws UnauthenticatedException - * @throws UnauthorizedException - */ - protected function transformAuthException(Exception $exception) - { - if ($exception instanceof AuthenticationException) { - throw new UnauthenticatedException(); - } - - if ($exception instanceof AuthorizationException) { - throw new UnauthorizedException(); - } - } - - /** - * Transform an Eloquent exception into an API exception. - * - * @param Exception $exception - * @return void - * @throws ResourceNotFoundException - * @throws RelationNotFoundException - */ - protected function transformEloquentException(Exception $exception) - { - if ($exception instanceof ModelNotFoundException) { - throw new ResourceNotFoundException(); - } - - if ($exception instanceof EloquentRelationNotFoundException) { - throw new RelationNotFoundException(); - } - } - - /** - * Transform a Laravel validation exception into an API exception. - * - * @param Exception $exception - * @return void - * @throws ValidationFailedException - */ - protected function transformValidationException(Exception $exception) - { - if ($exception instanceof ValidationException) { - throw new ValidationFailedException($exception->validator); - } - } - - /** - * Renders any API exception into a JSON error response. - * - * @param ApiException $exception - * @return JsonResponse - */ - protected function renderApiError(ApiException $exception):JsonResponse - { - return app('responder.error') - ->setError($exception->getErrorCode(), $exception->getMessage()) - ->addData($exception->getData() ?: []) - ->respond($exception->getStatusCode()); - } -} diff --git a/src/Traits/RespondsWithJson.php b/src/Traits/RespondsWithJson.php deleted file mode 100644 index 60da7c7..0000000 --- a/src/Traits/RespondsWithJson.php +++ /dev/null @@ -1,56 +0,0 @@ - - * @license The MIT License - */ -trait RespondsWithJson -{ - /** - * Generate an error JSON response. - * - * @param string|null $errorCode - * @param int|null $statusCode - * @param mixed $message - * @return JsonResponse - */ - public function errorResponse(string $errorCode = null, int $statusCode = null, $message = null):JsonResponse - { - return app(Responder::class)->error($errorCode, $statusCode, $message); - } - - /** - * Generate a successful JSON response. - * - * @param mixed|null $data - * @param int|null $statusCode - * @param array $meta - * @return \Illuminate\Http\JsonResponse - */ - public function successResponse($data = null, $statusCode = null, array $meta = []):JsonResponse - { - return app(Responder::class)->success($data, $statusCode, $meta); - } - - /** - * Transform the data and return a success response builder. - * - * @param mixed|null $data - * @param callable|string|null $transformer - * @return \Flugg\Responder\Http\SuccessResponse - */ - public function transform($data = null, $transformer = null):SuccessResponseBuilder - { - return app(Responder::class)->transform($data, $transformer); - } -} \ No newline at end of file diff --git a/src/Traits/ThrowsApiErrors.php b/src/Traits/ThrowsApiErrors.php deleted file mode 100644 index 14c6a7f..0000000 --- a/src/Traits/ThrowsApiErrors.php +++ /dev/null @@ -1,43 +0,0 @@ - - * @license The MIT License - */ -trait ThrowsApiErrors -{ - /** - * Handle a failed validation attempt. - * - * @param \Illuminate\Contracts\Validation\Validator $validator - * @return void - * @throws ValidationFailedException - */ - protected function failedValidation(Validator $validator) - { - throw new ValidationFailedException($validator); - } - - /** - * Handle a failed authorization attempt. - * - * @return void - * @throws UnauthorizedException - */ - protected function failedAuthorization() - { - throw new UnauthorizedException(); - } -} \ No newline at end of file diff --git a/src/TransformBuilder.php b/src/TransformBuilder.php new file mode 100644 index 0000000..c0b7081 --- /dev/null +++ b/src/TransformBuilder.php @@ -0,0 +1,290 @@ + + * @license The MIT License + */ +class TransformBuilder +{ + /** + * A factory class for making Fractal resources. + * + * @var \Flugg\Responder\Contracts\Resources\ResourceFactory + */ + protected $resourceFactory; + + /** + * A factory for making transformed arrays. + * + * @var \Flugg\Responder\Contracts\TransformFactory + */ + private $transformFactory; + + /** + * A factory used to build Fractal paginator adapters. + * + * @var \Flugg\Responder\Contracts\Pagination\PaginatorFactory + */ + protected $paginatorFactory; + + /** + * The resource that's being built. + * + * @var \League\Fractal\Resource\ResourceInterface + */ + protected $resource; + + /** + * A serializer for formatting data after transforming. + * + * @var \League\Fractal\Serializer\SerializerAbstract + */ + protected $serializer; + + /** + * A list of included relations. + * + * @var array + */ + protected $with = []; + + /** + * A list of excluded relations. + * + * @var array + */ + protected $without = []; + + /** + * A list of sparse fieldsets. + * + * @var array + */ + protected $only = []; + + /** + * Construct the builder class. + * + * @param \Flugg\Responder\Contracts\Resources\ResourceFactory $resourceFactory + * @param \Flugg\Responder\Contracts\TransformFactory $transformFactory + * @param \Flugg\Responder\Contracts\Pagination\PaginatorFactory $paginatorFactory + */ + public function __construct(ResourceFactory $resourceFactory, TransformFactory $transformFactory, PaginatorFactory $paginatorFactory) + { + $this->resourceFactory = $resourceFactory; + $this->transformFactory = $transformFactory; + $this->paginatorFactory = $paginatorFactory; + } + + /** + * Make a resource from the given data and transformer and set the resource key. + * + * @param mixed $data + * @param \Flugg\Responder\Transformers\Transformer|callable|string|null $transformer + * @param string|null $resourceKey + * @return $this + */ + public function resource($data = null, $transformer = null, string $resourceKey = null) + { + $this->resource = $this->resourceFactory->make($data, $transformer, $resourceKey); + + if ($data instanceof CursorPaginator) { + $this->cursor($this->paginatorFactory->makeCursor($data)); + } elseif ($data instanceof LengthAwarePaginator) { + $this->paginator($this->paginatorFactory->make($data)); + } + + return $this; + } + + /** + * Manually set the cursor on the resource. + * + * @param \League\Fractal\Pagination\Cursor $cursor + * @return $this + */ + public function cursor(Cursor $cursor) + { + if ($this->resource instanceof CollectionResource) { + $this->resource->setCursor($cursor); + } + + return $this; + } + + /** + * Manually set the paginator on the resource. + * + * @param \League\Fractal\Pagination\IlluminatePaginatorAdapter $paginator + * @return $this + */ + public function paginator(IlluminatePaginatorAdapter $paginator) + { + if ($this->resource instanceof CollectionResource) { + $this->resource->setPaginator($paginator); + } + + return $this; + } + + /** + * Add meta data appended to the response data. + * + * @param array $data + * @return $this + */ + public function meta(array $data) + { + $this->resource->setMeta($data); + + return $this; + } + + /** + * Include relations to the transform. + * + * @param string[]|string $relations + * @return $this + */ + public function with($relations) + { + $this->with = array_merge($this->with, is_array($relations) ? $relations : func_get_args()); + + return $this; + } + + /** + * Exclude relations from the transform. + * + * @param string[]|string $relations + * @return $this + */ + public function without($relations) + { + $this->without = array_merge($this->without, is_array($relations) ? $relations : func_get_args()); + + return $this; + } + + /** + * Filter fields to output using sparse fieldsets. + * + * @param string[]|string $fields + * @return $this + */ + public function only($fields) + { + $this->only = array_merge($this->only, is_array($fields) ? $fields : func_get_args()); + + return $this; + } + + /** + * Set the serializer. + * + * @param \League\Fractal\Serializer\SerializerAbstract|string $serializer + * @return $this + * @throws \Flugg\Responder\Exceptions\InvalidSuccessSerializerException + */ + public function serializer($serializer) + { + if (is_string($serializer)) { + $serializer = new $serializer; + } + + if (! $serializer instanceof SerializerAbstract) { + throw new InvalidSuccessSerializerException; + } + + $this->serializer = $serializer; + + return $this; + } + + /** + * Transform and serialize the data and return the transformed array. + * + * @return array + */ + public function transform(): array + { + $this->prepareRelations($this->resource->getData(), $this->resource->getTransformer()); + + return $this->transformFactory->make($this->resource ?: new NullResource, $this->serializer, [ + 'includes' => $this->with, + 'excludes' => $this->without, + 'fieldsets' => $this->only, + ]); + } + + /** + * Prepare requested relations for the transformation. + * + * @param mixed $data + * @param \Flugg\Responder\Transformers\Transformer|callable|string|null $transformer + * @return void + */ + protected function prepareRelations($data, $transformer) + { + if ($transformer instanceof BaseTransformer) { + $this->includeTransformerRelations($transformer); + } + + if ($data instanceof Model || $data instanceof Collection) { + $data->load($this->with); + } + + $this->with = $this->stripEagerLoadConstraints($this->with); + } + + /** + * Include default relationships and add eager load constraints from transformer. + * + * @param \Flugg\Responder\Transformers\Transformer $transformer + * @return void + */ + protected function includeTransformerRelations(BaseTransformer $transformer) + { + $relations = array_filter(array_keys($this->with), function ($relation) { + return ! is_numeric($relation); + }); + + $this->with(Collection::make($transformer->defaultRelations()) + ->filter(function ($constrain, $relation) use ($relations) { + return ! in_array(is_numeric($relation) ? $constrain : $relation, $relations); + })->all()); + } + + /** + * Remove eager load constraint functions from the given relations. + * + * @param array $relations + * @return array + */ + protected function stripEagerLoadConstraints(array $relations): array + { + return collect($relations)->map(function ($value, $key) { + return is_numeric($key) ? $value : $key; + })->values()->all(); + } +} \ No newline at end of file diff --git a/src/Transformer.php b/src/Transformer.php index eb7f21e..644b99e 100644 --- a/src/Transformer.php +++ b/src/Transformer.php @@ -2,110 +2,50 @@ namespace Flugg\Responder; -use Illuminate\Database\Eloquent\Relations\Pivot; -use League\Fractal\Resource\ResourceAbstract; -use League\Fractal\Scope; -use League\Fractal\TransformerAbstract; +use Flugg\Responder\Contracts\Transformer as TransformerContract; +use Flugg\Responder\Serializers\NullSerializer; /** - * An abstract base transformer. Your transformers should extend this class, and this - * class itself extends Fractal's transformer. + * A service class responsible for transforming data without serializing. * * @package flugger/laravel-responder * @author Alexander Tømmerås * @license The MIT License */ -abstract class Transformer extends TransformerAbstract +class Transformer implements TransformerContract { /** - * A list of all available relations. + * A builder used to build transformed arrays. * - * @var array + * @var \Flugg\Responder\TransformBuilder */ - protected $relations = ['*']; + protected $transformBuilder; /** - * Get relations set on the transformer. + * Construct the service class. * - * @return array - */ - public function getRelations():array - { - $relations = array_unique(array_merge($this->getAvailableIncludes(), $this->relations)); - - return array_filter($relations, function($relation) { - return $relation !== '*'; - }); - } - - /** - * Set relations on the transformer. - * - * @param array|string $relations - * @return self + * @param \Flugg\Responder\TransformBuilder $transformBuilder */ - public function setRelations($relations) + public function __construct(TransformBuilder $transformBuilder) { - $this->setAvailableIncludes(array_unique(array_merge($this->availableIncludes, (array) $relations))); - - return $this; + $this->transformBuilder = $transformBuilder; } /** - * Check if the transformer has whitelisted all relations. + * Transform the data without serializing with the given transformer and relations. * - * @return bool - */ - public function allRelationsAllowed():bool - { - return $this->relations == ['*']; - } - - /** - * Call method for retrieving a relation. This method overrides Fractal's own - * [callIncludeMethod] method to load relations directly from your models. - * - * @param Scope $scope - * @param string $includeName - * @param mixed $data - * @return \League\Fractal\Resource\ResourceInterface|bool - * @throws \Exception - */ - protected function callIncludeMethod(Scope $scope, $includeName, $data) - { - if ($includeName === 'pivot') { - return $this->includePivot($data->$includeName); - } - - $params = $scope->getManager()->getIncludeParams($scope->getIdentifier($includeName)); - - if (method_exists($this, $includeName)) { - $include = call_user_func([$this, $includeName], $data, $params); - - if ($include instanceof ResourceAbstract) { - return $include; - } - - return app(Responder::class)->transform($include)->getResource(); - } else { - return app(Responder::class)->transform($data->$includeName)->getResource(); - } - } - - /** - * Include pivot table data to the response. - * - * @param Pivot $pivot - * @return \League\Fractal\Resource\ResourceInterface|bool + * @param mixed $data + * @param \Flugg\Responder\Transformers\Transformer|callable|string|null $transformer + * @param string[] $with + * @param string[] $without + * @return array */ - protected function includePivot(Pivot $pivot) + public function transform($data = null, $transformer = null, array $with = [], array $without = []): array { - if (! method_exists($this, 'transformPivot')) { - return false; - } - - return app(Responder::class)->transform($pivot, function ($pivot) { - return $this->transformPivot($pivot); - })->getResource(); + return $this->transformBuilder->resource($data, $transformer) + ->with($with) + ->without($without) + ->serializer(new NullSerializer) + ->transform(); } } \ No newline at end of file diff --git a/src/Transformers/Concerns/HasRelationships.php b/src/Transformers/Concerns/HasRelationships.php new file mode 100644 index 0000000..95f75ce --- /dev/null +++ b/src/Transformers/Concerns/HasRelationships.php @@ -0,0 +1,143 @@ + + * @license The MIT License + */ +trait HasRelationships +{ + /** + * List of available relations. + * + * @var string[] + */ + protected $relations = ['*']; + + /** + * A list of autoloaded default relations. + * + * @var array + */ + protected $load = []; + + /** + * Get a list of default relations with eager load constraints. + * + * @return array + */ + public function defaultRelations(): array + { + $this->load = $this->normalizeRelations($this->load); + + $relations = $this->addEagerLoadConstraints(array_keys($this->load)); + + return array_merge($relations, $this->getNestedDefaultRelations()); + } + + /** + * Normalize relations to force a key value structure. + * + * @param array $relations + * @return array + */ + protected function normalizeRelations(array $relations): array + { + $normalized = []; + + foreach ($relations as $relation => $transformer) { + if (is_numeric($relation)) { + $relation = $transformer; + $transformer = null; + } + + $normalized[$relation] = $transformer; + } + + return $normalized; + } + + /** + * Add eager load constraints to a list of relations. + * + * @param array $relations + * @return array + */ + protected function addEagerLoadConstraints(array $relations): array + { + $eagerLoads = []; + + foreach ($relations as $relation) { + if (method_exists($this, $method = 'load' . ucfirst($relation))) { + $eagerLoads[$relation] = function ($query) use ($method) { + return $this->$method($query); + }; + } else { + $eagerLoads[] = $relation; + } + } + + return $eagerLoads; + } + + /** + * Get a list of nested default relationships with eager load constraints. + * + * @return array + */ + protected function getNestedDefaultRelations(): array + { + return Collection::make($this->load)->filter(function ($transformer) { + return ! is_null($transformer); + })->flatMap(function ($transformer, $relation) { + return array_map(function ($nestedRelation) use ($relation) { + return "$relation.$nestedRelation"; + }, $this->resolveTransformer($transformer)->defaultRelations()); + })->all(); + } + + /** + * Resolve a relationship from a model instance. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $identifier + * @return mixed + */ + protected function resolveRelation(Model $model, string $identifier) + { + if (method_exists($this, $method = 'filter' . ucfirst($identifier))) { + return $this->$method($model->$identifier); + } + + return $model->$identifier; + } + + /** + * Resolve a related transformer from a class name string. + * + * @param string $transformer + * @return mixed + */ + protected function resolveTransformer(string $transformer) + { + $resolver = $this->resolveContainer()->make(TransformerResolver::class); + + return $resolver->resolve($transformer); + } + + /** + * Resolve a container using the resolver callback. + * + * @return \Illuminate\Contracts\Container\Container + */ + protected abstract function resolveContainer(): Container; +} \ No newline at end of file diff --git a/src/Transformers/Concerns/MakesResources.php b/src/Transformers/Concerns/MakesResources.php new file mode 100644 index 0000000..82014a0 --- /dev/null +++ b/src/Transformers/Concerns/MakesResources.php @@ -0,0 +1,98 @@ + + * @license The MIT License + */ +trait MakesResources +{ + /** + * A list of cached related resources. + * + * @var \League\Fractal\ResourceInterface[] + */ + protected $resources = []; + + /** + * Make a resource. + * + * @param null $data + * @param \Flugg\Responder\Transformers\Transformer|string|callable|null $transformer + * @param string|null $resourceKey + * @return \League\Fractal\Resource\ResourceInterface + */ + protected function resource($data = null, $transformer = null, string $resourceKey = null): ResourceInterface + { + $resourceFactory = $this->resolveContainer()->make(ResourceFactory::class); + + return $resourceFactory->make($data, $transformer, $resourceKey); + } + + /** + * Include a related resource. + * + * @param string $identifier + * @param mixed $data + * @param array $parameters + * @return \League\Fractal\Resource\ResourceInterface + * @throws \LogicException + */ + protected function includeResource(string $identifier, $data, array $parameters): ResourceInterface + { + if (method_exists($this, $method = 'include' . ucfirst($identifier))) { + $resource = $this->$method($data, $parameters); + } elseif ($data instanceof Model) { + $resource = $this->includeResourceFromModel($data, $identifier); + } else { + throw new LogicException('Relation [' . $identifier . '] not found in [' . get_class($this) . '].'); + } + + return $resource; + } + + /** + * Include a related resource from a model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $identifier + * @return \League\Fractal\Resource\ResourceInterface + */ + protected function includeResourceFromModel(Model $model, string $identifier): ResourceInterface + { + $data = $this->resolveRelation($model, $identifier); + + if (! count($data)) { + return $this->resource($data, null, $identifier); + } elseif (key_exists($identifier, $this->resources)) { + return $this->resources[$identifier]->setData($data); + } + + return $this->resources[$identifier] = $this->resource($data, null, $identifier); + } + + /** + * Resolve a container using the resolver callback. + * + * @return \Illuminate\Contracts\Container\Container + */ + protected abstract function resolveContainer(): Container; + + /** + * Resolve relation data from a model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $identifier + * @return mixed + */ + protected abstract function resolveRelation(Model $model, string $identifier); +} \ No newline at end of file diff --git a/src/Transformers/Concerns/OverridesFractal.php b/src/Transformers/Concerns/OverridesFractal.php new file mode 100644 index 0000000..9b532b1 --- /dev/null +++ b/src/Transformers/Concerns/OverridesFractal.php @@ -0,0 +1,109 @@ + + * @license The MIT License + */ +trait OverridesFractal +{ + /** + * Overrides Fractal's getter for available includes. + * + * @return array + */ + public function getAvailableIncludes() + { + if (! isset($this->relations)) { + return []; + } elseif ($this->relations == ['*']) { + return $this->resolveScopedIncludes($this->getCurrentScope()); + } + + return array_filter($this->relations, function ($relation) { + return $relation != '*'; + }); + } + + /** + * Overrides Fractal's getter for default includes. + * + * @return array + */ + public function getDefaultIncludes() + { + return Collection::make($this->load)->map(function ($transformer, $relation) { + return is_numeric($relation) ? $transformer : $relation; + })->all(); + } + + /** + * Overrides Fractal's method for including a relation. + * + * @param \League\Fractal\Scope $scope + * @param string $identifier + * @param mixed $data + * @return \League\Fractal\Resource\ResourceInterface + */ + protected function callIncludeMethod(Scope $scope, $identifier, $data) + { + $parameters = $this->resolveScopedParameters($scope, $identifier); + + return $this->includeResource($identifier, $data, $parameters); + } + + /** + * Resolve scoped includes for the given scope. + * + * @param \League\Fractal\Scope $scope + * @return array + */ + protected function resolveScopedIncludes(Scope $scope): array + { + $level = count($scope->getParentScopes()); + $includes = $scope->getManager()->getRequestedIncludes(); + + return Collection::make($includes)->map(function ($include) { + return explode('.', $include); + })->filter(function ($include) use ($level) { + return count($include) > $level; + })->pluck($level)->unique()->all(); + } + + /** + * Resolve scoped parameters for the given scope. + * + * @param \League\Fractal\Scope $scope + * @param string $identifier + * @return array + */ + protected function resolveScopedParameters(Scope $scope, string $identifier): array + { + return iterator_to_array($scope->getManager()->getIncludeParams($scope->getIdentifier($identifier))); + } + + /** + * Get the current scope of the transformer. + * + * @return \League\Fractal\Scope + */ + public abstract function getCurrentScope(); + + /** + * Include a related resource. + * + * @param string $identifier + * @param mixed $data + * @param array $parameters + * @return \League\Fractal\Resource\ResourceInterface + */ + protected abstract function includeResource(string $identifier, $data, array $parameters): ResourceInterface; +} \ No newline at end of file diff --git a/src/Transformers/Transformer.php b/src/Transformers/Transformer.php new file mode 100644 index 0000000..2feee43 --- /dev/null +++ b/src/Transformers/Transformer.php @@ -0,0 +1,52 @@ + + * @license The MIT License + */ +abstract class Transformer extends TransformerAbstract +{ + use HasRelationships; + use MakesResources; + use OverridesFractal; + + /** + * The container resolver callback. + * + * @var \Closure|null + */ + protected static $containerResolver; + + /** + * Set a container using a resolver callback. + * + * @param \Closure $resolver + * @return void + */ + public static function containerResolver(Closure $resolver) + { + static::$containerResolver = $resolver; + } + + /** + * Resolve a container using the resolver callback. + * + * @return \Illuminate\Contracts\Container\Container + */ + protected function resolveContainer(): Container + { + return call_user_func(static::$containerResolver); + } +} \ No newline at end of file diff --git a/src/Transformers/TransformerResolver.php b/src/Transformers/TransformerResolver.php new file mode 100644 index 0000000..82e0190 --- /dev/null +++ b/src/Transformers/TransformerResolver.php @@ -0,0 +1,140 @@ + + * @license The MIT License + */ +class TransformerResolver implements TransformerResolverContract +{ + /** + * A container used to resolve transformers. + * + * @var \Illuminate\Contracts\Container\Container + */ + protected $container; + + /** + * Transformable to transformer mappings. + * + * @var array + */ + protected $bindings = []; + + /** + * Construct the resolver class. + * + * @param \Illuminate\Contracts\Container\Container $container + */ + public function __construct(Container $container) + { + $this->container = $container; + } + + /** + * Register a transformable to transformer binding. + * + * @param string|array $transformable + * @param string|callback|null $transformer + * @return void + */ + public function bind($transformable, $transformer = null) + { + $this->bindings = array_merge($this->bindings, is_array($transformable) ? $transformable : [ + $transformable => $transformer, + ]); + } + + /** + * Resolve a transformer. + * + * @param \Flugg\Responder\Transformers\Transformer|string|callable $transformer + * @return \Flugg\Responder\Transformers\Transformer|callable + * @throws \Flugg\Responder\Exceptions\InvalidTransformerException + */ + public function resolve($transformer) + { + if (is_string($transformer)) { + return $this->container->make($transformer); + } + + if (! is_callable($transformer) && ! $transformer instanceof Transformer) { + throw new InvalidTransformerException; + } + + return $transformer; + } + + /** + * Resolve a transformer from the given data. + * + * @param mixed $data + * @return \Flugg\Responder\Transformers\Transformer|callable + */ + public function resolveFromData($data) + { + $transformable = $this->resolveTransformable($data); + $transformer = $this->resolveTransformer($transformable); + + return $this->resolve($transformer); + } + + /** + * Resolve a transformable from the given data. + * + * @param mixed $data + * @return mixed + */ + protected function resolveTransformable($data) + { + if (is_array($data) || $data instanceof Traversable) { + foreach ($data as $item) { + return $item; + } + } + + return $data; + } + + /** + * Resolve a transformer from the transformable element. + * + * @param mixed $transformable + * @return \Flugg\Responder\Contracts\Transformable|callable + */ + protected function resolveTransformer($transformable) + { + if (is_object($transformable) && key_exists(get_class($transformable), $this->bindings)) { + return $this->bindings[get_class($transformable)]; + } + + if ($transformable instanceof Transformable) { + return $transformable->transformer(); + } + + return $this->resolveFallbackTransformer(); + } + + /** + * Resolve a fallback closure transformer just returning the data directly. + * + * @return callable + */ + protected function resolveFallbackTransformer() + { + return function ($data) { + return $data instanceof Arrayable ? $data->toArray() : $data; + }; + } +} \ No newline at end of file diff --git a/src/helpers.php b/src/helpers.php index 56d7340..26a8688 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -1,16 +1,34 @@ transform($data, $transformer, $with, $without); + } +} diff --git a/tests/Integration/AccessResponderTest.php b/tests/Integration/AccessResponderTest.php deleted file mode 100644 index 40d9ada..0000000 --- a/tests/Integration/AccessResponderTest.php +++ /dev/null @@ -1,82 +0,0 @@ - - * @license The MIT License - */ -class AccessResponderTest extends TestCase -{ - /** - * Test that you can resolve the responder service from the Laravel's IoC container. - * - * @test - */ - public function youCanResolveFromServiceContainer() - { - // Arrange... - $responder = app(Responder::class); - - // Assert... - $this->assertInstanceOf(Responder::class, $responder); - } - - /** - * Test that you can access the responder service from the Laravel's IoC container. - * - * @test - */ - public function youCanAccessThroughFacade() - { - // Arrange... - $fruit = $this->createModel(); - $responder = $this->mockResponder(); - - // Act... - ResponderFacade::success($fruit, 200); - - // Assert... - $responder->shouldHaveReceived('success')->with($fruit, 200); - } - - /** - * Test that you can access the responder service from the helper methid. - * - * @test - */ - public function youCanAccessThroughHelperMethod() - { - // Arrange... - $responder = responder(); - - // Assert... - $this->assertInstanceOf(Responder::class, $responder); - } - - /** - * Test that you can access the responder service from the controller trait. - * - * @test - */ - public function youCanAccessThroughControllerTrait() - { - // Arrange... - $fruit = $this->createModel(); - $controller = $this->createTestController(); - $responder = $this->mockResponder(); - - // Act... - (new $controller)->successAction($fruit); - - // Assert... - $responder->shouldHaveReceived('success')->with($fruit, null, []); - } -} \ No newline at end of file diff --git a/tests/Integration/MakeErrorResponseTest.php b/tests/Integration/MakeErrorResponseTest.php deleted file mode 100644 index a9be3fd..0000000 --- a/tests/Integration/MakeErrorResponseTest.php +++ /dev/null @@ -1,108 +0,0 @@ - - * @license The MIT License - */ -class MakeErrorResponseTest extends TestCase -{ - /** - * Test that you can generate error responses using the responder service. - * - * @test - */ - public function youCanMakeErrorResponses() - { - // Act... - $response = $this->responder->error('test_error', 400, 'Test error.'); - - // Assert... - $this->assertInstanceOf(JsonResponse::class, $response); - $this->assertEquals($response->getStatusCode(), 400); - $this->assertEquals($response->getData(true), [ - 'success' => false, - 'error' => [ - 'code' => 'test_error', - 'message' => 'Test error.' - ] - ]); - } - - /** - * Test that you can generate error responses using the helper method. - * - * @test - */ - public function youCanMakeErrorResponsesUsingHelperMethod() - { - // Arrange... - $responder = $this->mockResponder(); - - // Expect... - $responder->shouldReceive('error')->with('test_error', 400, 'Test error.')->once(); - - // Act... - responder()->error('test_error', 400, 'Test error.'); - } - - /** - * Test that you can generate error responses using the facade. - * - * @test - */ - public function youCanMakeErrorResponsesUsingFacade() - { - // Arrange... - $responder = $this->mockResponder(); - - // Expect... - $responder->shouldReceive('error')->with('test_error', 400, 'Test error.')->once(); - - // Act... - Responder::error('test_error', 400, 'Test error.'); - } - - /** - * Test that you can generate error responses using the RespondsWithJson trait. - * - * @test - */ - public function youCanMakeErrorResponsesUsingTrait() - { - // Arrange... - $controller = $this->createTestController(); - $responder = $this->mockResponder(); - - // Expect... - $responder->shouldReceive('error')->with('test_error', 400, 'Test error.')->once(); - - // Act... - (new $controller)->errorAction(); - } - - /** - * Test that it uses error messages from the package language file based on error code. - * - * @test - */ - public function youCanUseLangFilesForErrorMessages() - { - // Arrange... - $this->mockTranslator('Test error'); - $responder = $this->app->make('responder'); - - // Act... - $response = $responder->error('test_error', 400); - - // Assert... - $this->assertEquals('Test error', $response->getData(true)['error']['message']); - } -} \ No newline at end of file diff --git a/tests/Integration/MakeSuccessResponseTest.php b/tests/Integration/MakeSuccessResponseTest.php deleted file mode 100644 index 1632105..0000000 --- a/tests/Integration/MakeSuccessResponseTest.php +++ /dev/null @@ -1,234 +0,0 @@ - - * @license The MIT License - */ -class MakeSuccessResponseTest extends TestCase -{ - /** - * Test that you can generate success responses using the responder service. - * - * @test - */ - public function youCanMakeSuccessResponses() - { - // Arrange... - $fruit = $this->createModel(); - - // Act... - $response = $this->responder->success($fruit); - - // Assert... - $this->assertInstanceOf(JsonResponse::class, $response); - $this->assertEquals($response->getStatusCode(), 200); - $this->assertEquals($response->getData(true), [ - 'success' => true, - 'data' => [ - 'name' => 'Mango', - 'price' => 10, - 'isRotten' => false - ] - ]); - } - - /** - * Test that you can change the status code using a second argument. - * - * @test - */ - public function youCanPassInStatusCode() - { - // Arrange... - $fruit = $this->createModel(); - - // Act... - $response = $this->responder->success($fruit, 201); - - // Assert... - $this->assertEquals($response->getStatusCode(), 201); - } - - /** - * Test that you can add meta data using a third argument. - * - * @test - */ - public function youCanPassInMetaData() - { - // Arrange... - $fruit = $this->createModel(); - $meta = [ - 'foo' => 'bar' - ]; - - // Act... - $response = $this->responder->success($fruit, 200, $meta); - - // Assert... - $this->assertEquals($response->getStatusCode(), 200); - $this->assertContains($meta, $response->getData(true)); - } - - /** - * Test that you can omit the first parameter. - * - * @test - */ - public function youCanOmitData() - { - // Arrange... - $meta = [ - 'foo' => 'bar' - ]; - - // Act... - $response = $this->responder->success(200, $meta); - - // Assert... - $this->assertEquals($response->getStatusCode(), 200); - $this->assertContains($meta, $response->getData(true)); - } - - /** - * Test that you can omit the second parameter. - * - * @test - */ - public function youCanOmitStatusCode() - { - // Arrange... - $fruit = $this->createModel(); - $meta = [ - 'foo' => 'bar' - ]; - - // Act... - $response = $this->responder->success($fruit, $meta); - - // Assert... - $this->assertEquals($response->getStatusCode(), 200); - $this->assertContains($meta, $response->getData(true)); - } - - /** - * Test that you may pass in an Eloquent collection as the data. - * - * @test - */ - public function youCanUseACollectionAsData() - { - // Arrange... - $mango = $this->createModel(); - $apple = $this->createModel([ - 'name' => 'Apple', - 'price' => 5 - ]); - - $fruits = collect([$mango, $apple]); - - // Act... - $response = $this->responder->success($fruits); - - // Assert... - $this->assertEquals($response->getData(true), [ - 'success' => true, - 'data' => [ - [ - 'name' => 'Mango', - 'price' => 10, - 'isRotten' => false - ], - [ - 'name' => 'Apple', - 'price' => 5, - 'isRotten' => false - ] - ] - ]); - } - - /** - * Test that you may pass in an Eloquent builder as the data. - * - * @test - */ - public function youCanUseABuilderAsData() - { - // Arrange... - $fruit = $this->createModel()->newQuery(); - - // Act... - $response = $this->responder->success($fruit); - - // Assert... - $this->assertEquals($response->getData(true), [ - 'success' => true, - 'data' => [ - [ - 'name' => 'Mango', - 'price' => 10, - 'isRotten' => false - ] - ] - ]); - } - - /** - * Test that you may pass in a Laravel paginator as the data. - * - * @test - */ - public function youCanUseAPaginatorAsData() - { - // Arrange... - $fruit = $this->createModel()->newQuery()->paginate(1); - - // Act... - $response = $this->responder->success($fruit); - - // Assert... - $this->assertEquals($response->getData(true), [ - 'success' => true, - 'data' => [ - [ - 'name' => 'Mango', - 'price' => 10, - 'isRotten' => false - ] - ], - 'pagination' => [ - 'total' => 1, - 'count' => 1, - 'perPage' => 1, - 'currentPage' => 1, - 'totalPages' => 1, - 'links' => [] - ] - ]); - } - - /** - * Test that you may pass in a Laravel paginator as the data. - * - * @test - */ - public function youCanUseNullAsData() - { - // Act... - $response = $this->responder->success(null); - - // Assert... - $this->assertEquals($response->getData(true), [ - 'success' => true, - 'data' => null - ]); - } -} diff --git a/tests/TestCase.php b/tests/TestCase.php index 377fcfe..517f478 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,31 +2,23 @@ namespace Flugg\Responder\Tests; -use Flugg\Responder\Contracts\Transformable; -use Flugg\Responder\Http\SuccessResponseBuilder; -use Flugg\Responder\ResourceFactory; -use Flugg\Responder\Responder; +use Flugg\Responder\Contracts\ResponseFactory; +use Flugg\Responder\Http\Responses\ErrorResponseBuilder; +use Flugg\Responder\Http\Responses\SuccessResponseBuilder; +use Flugg\Responder\Resources\ResourceBuilder; use Flugg\Responder\ResponderServiceProvider; -use Flugg\Responder\Traits\RespondsWithJson; -use Flugg\Responder\Transformer; -use Illuminate\Database\Eloquent\Builder as EloquentBuilder; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\Pivot; -use Illuminate\Database\Eloquent\Relations\Relation; -use Illuminate\Database\Schema\Blueprint; -use Illuminate\Database\Schema\Builder; +use Flugg\Responder\TransformBuilder; +use Flugg\Responder\Transformers\Transformer; use Illuminate\Http\JsonResponse; -use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Routing\Controller; -use Illuminate\Support\Collection; -use Illuminate\Translation\Translator; -use League\Fractal\Resource\ResourceInterface; +use League\Fractal\Manager; +use League\Fractal\Resource\Collection; use Mockery; +use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; +use Mockery\MockInterface; use Orchestra\Testbench\TestCase as BaseTestCase; /** - * This is the base test case class and is where the testing environment bootstrapping - * takes place. All other testing classes should extend from this class. + * The base test case class, responsible for bootstrapping the testing environment. * * @package flugger/laravel-responder * @author Alexander Tømmerås @@ -34,42 +26,7 @@ */ abstract class TestCase extends BaseTestCase { - /** - * Save an instance of the schema builder for easy access. - * - * @var Builder - */ - protected $schema; - - /** - * An instance of the responder service responsible for generating API responses. - * - * @var Responder - */ - protected $responder; - - /** - * Setup the test environment. - * - * @return void - */ - public function setUp() - { - parent::setUp(); - - $this->app['config']->set('responder.include_success_flag', true); - $this->app['config']->set('responder.include_status_code', false); - $this->responder = $this->app[Responder::class]; - - $this->createTestTransformer(); - - $this->schema = $this->app['db']->connection()->getSchemaBuilder(); - $this->runTestMigrations(); - - $this->beforeApplicationDestroyed(function () { - $this->rollbackTestMigrations(); - }); - } + use MockeryPHPUnitIntegration; /** * Define environment setup. @@ -82,367 +39,136 @@ protected function getEnvironmentSetUp($app) $app['config']->set('database.default', 'testbench'); $app['config']->set('database.connections.testbench', [ 'driver' => 'sqlite', - 'database' => ':memory:' + 'database' => ':memory:', ]); } /** * Get package service providers. * + * @param \Illuminate\Foundation\Application $app * @return array */ protected function getPackageProviders($app) { return [ - ResponderServiceProvider::class + ResponderServiceProvider::class, ]; } /** - * Run migrations for tables only used for testing purposes. - * - * @return void - */ - protected function runTestMigrations() - { - if (! $this->schema->hasTable('fruits')) { - $this->schema->create('fruits', function (Blueprint $table) { - $table->increments('id'); - $table->string('name'); - $table->integer('price'); - $table->boolean('is_rotten'); - $table->timestamps(); - }); - } - } - - /** - * Rollback migrations for tables only used for testing purposes. - * - * @return void - */ - protected function rollbackTestMigrations() - { - $this->schema->drop('fruits'); - } - - /** - * Creates a controller class with the RespondsWithJson trait. - * - * @return Controller - */ - protected function createTestController() - { - return new class extends Controller - { - use RespondsWithJson; - - public function successAction($fruit) - { - return $this->successResponse($fruit); - } - - public function errorAction() - { - return $this->errorResponse('test_error', 400, 'Test error.'); - } - }; - } - - /** - * Creates a new transformer for testing purposes. - * - * @return void - */ - protected function createTestTransformer() - { - $transformer = new class extends Transformer - { - public function transform($model):array - { - return [ - 'name' => (string) $model->name, - 'price' => (int) $model->price, - 'isRotten' => (bool) false - ]; - } - }; - - $this->app->bind('test.transformer', function () use ($transformer) { - return new $transformer(); - }); - } - - /** - * Makes a new transformer for testing purposes. - * - * @return Transformer - */ - protected function makeTransformer():Transformer - { - return new class extends Transformer - { - public function transform($model):array - { - return $model->toArray; - } - }; - } - - /** - * Makes a new empty model for testing purposes. - * - * @return Model - */ - protected function makeModel(array $attributes = []):Model - { - $model = new class extends Model - { - protected $guarded = []; - }; - - return $model->newInstance($attributes); - } - - /** - * Makes a new empty model with a resource key set. + * Create a mock of a [Transformer] returning the data directly. * - * @param string $resourceKey - * @return Model + * @return \Mockery\MockInterface */ - protected function makeModelWithResourceKey(string $resourceKey):Model + protected function mockTransformer(): MockInterface { - $this->app->bind('tests.resource_key', function () use ($resourceKey) { - return $resourceKey; - }); + $transformer = Mockery::mock(Transformer::class); - return new class extends Model - { - public static function getResourceKey() - { - return app('tests.resource_key'); - } - }; - } - - /** - * Makes a new empty transformable model with a transformer set. - * - * @return Model - */ - protected function makeModelWithTransformer($transformer):Model - { - $this->app->bind('tests.model_transformer', function () use ($transformer) { - return new $transformer; + $transformer->shouldReceive('transform')->andReturnUsing(function ($data) { + return $data; }); - return new class extends Model implements Transformable - { - protected $table = 'foo'; - - public static function transformer() - { - return app('tests.model_transformer'); - } - }; - } - - /** - * Creates a new adjustable model for testing purposes. - * - * @param array $attributes - * @return Model - */ - protected function createModel(array $attributes = []):Model - { - $model = new class extends Model implements Transformable - { - protected $fillable = ['name', 'price', 'is_rotten']; - protected $table = 'fruits'; - - public static function transformer():string - { - return get_class(app('test.transformer')); - } - }; - - return $this->storeModel($model, $attributes); - } - - /** - * Creates a new adjustable model without an attached transformer for testing purposes. - * - * @param array $attributes - * @return Model - */ - protected function createTestModelWithNoTransformer(array $attributes = []):Model - { - $model = new class extends Model - { - protected $fillable = ['name', 'price', 'is_rotten']; - protected $table = 'fruits'; - }; - - return $this->storeModel($model, $attributes); + return $transformer; } /** - * Creates a new adjustable model with a null transformer for testing purposes. + * Create a mock of a [TransformBuilder]. * - * @param array $attributes - * @return Model - */ - protected function createTestModelWithNullTransformer(array $attributes = []):Model - { - $model = new class extends Model implements Transformable - { - protected $fillable = ['name', 'price', 'is_rotten']; - protected $table = 'fruits'; - - public static function transformer() - { - return null; - } - }; - - return $this->storeModel($model, $attributes); - } - - /** - * Stores an actual instance of an adjustable model to the database. - * - * @param Model $model - * @param array $attributes - * @return Model - */ - protected function storeModel(Model $model, array $attributes = []):Model - { - return $model->create(array_merge([ - 'name' => 'Mango', - 'price' => 10, - 'is_rotten' => false - ], $attributes)); - } - - /** - * Create a mock of a resource factory. - * - * @param $resource * @return \Mockery\MockInterface */ - protected function mockResourceFactory(ResourceInterface $resource) + protected function mockTransformBuilder(): MockInterface { - $resourceFactory = Mockery::spy(ResourceFactory::class); - $resourceFactory->shouldReceive('make')->andReturn($resource); + $transformBuilder = Mockery::mock(TransformBuilder::class); - $this->app->instance(ResourceFactory::class, $resourceFactory); + $transformBuilder->shouldReceive('resource')->andReturnSelf(); + $transformBuilder->shouldReceive('meta')->andReturnSelf(); + $transformBuilder->shouldReceive('with')->andReturnSelf(); + $transformBuilder->shouldReceive('without')->andReturnSelf(); + $transformBuilder->shouldReceive('serializer')->andReturnSelf(); - return $resourceFactory; + return $transformBuilder; } /** - * Create a mock of the Eloquent builder with a mock of the [get] method which returns - * the given data. + * Create a mock of a [ResponseFactory]]. * - * @param array $data * @return \Mockery\MockInterface */ - protected function mockBuilder(array $data = null) + protected function mockResponseFactory(): MockInterface { - $builder = Mockery::spy(EloquentBuilder::class); - $builder->shouldReceive('get')->andReturn(collect($data)); + $responseFactory = Mockery::mock(ResponseFactory::class); - return $builder; - } - - /** - * Create a mock of the Eloquent builder with a mock of the [paginate] method which - * returns an instance of [\Illuminate\Pagination\LengthAwarePaginator]. - * - * @param array $data - * @return \Mockery\MockInterface - */ - protected function mockBuilderWithPaginator(array $data = null) - { - $paginator = new LengthAwarePaginator($data, count($data), 15); - $builder = $this->mockBuilder($data); - $builder->shouldReceive('paginate')->andReturn($paginator); + $responseFactory->shouldReceive('make')->andReturnUsing(function ($data, $status, $headers) { + return new JsonResponse($data, $status, $headers); + }); - return $builder; + return $responseFactory; } /** - * Create a mock of an Eloquent relationship. + * Create a mock of an [ErrorResponseBuilder]. * - * @param Collection|Model|null $data * @return \Mockery\MockInterface */ - protected function mockRelation($data = null) + protected function mockErrorResponseBuilder(): MockInterface { - $relation = Mockery::spy(Relation::class); - $relation->shouldReceive('get')->andReturn(collect($data)); + $responseBuilder = Mockery::mock(ErrorResponseBuilder::class); - return $relation; - } + $responseBuilder->shouldReceive('error')->andReturnSelf(); + $responseBuilder->shouldReceive('data')->andReturnSelf(); - /** - * Create a mock of a pivot. - * - * @return \Mockery\MockInterface - */ - protected function mockPivot() - { - return Mockery::spy(Pivot::class); + return $responseBuilder; } /** - * Create a mock of the responder and binds it to the service container. + * Create a mock of a [SuccessResponseBuilder]. * * @return \Mockery\MockInterface */ - protected function mockResponder() + protected function mockSuccessResponseBuilder(): MockInterface { - $responder = Mockery::spy(Responder::class); - $responder->shouldReceive('success')->andReturn(new JsonResponse()); + $responseBuilder = Mockery::mock(SuccessResponseBuilder::class); - $this->app->instance(Responder::class, $responder); + $responseBuilder->shouldReceive('transform')->andReturnSelf(); + $responseBuilder->shouldReceive('meta')->andReturnSelf(); - return $responder; + return $responseBuilder; } /** - * Create a mock of Laravel's translator and binds it to the service container. + * Create a mock of a Fractal [Manager]. * - * @param string $message * @return \Mockery\MockInterface */ - protected function mockTranslator(string $message) + protected function mockFractalManager(): MockInterface { - $translator = Mockery::spy(Translator::class); - - $translator->shouldReceive('has')->andReturn(true); - $translator->shouldReceive('trans')->andReturn($message); + $responseBuilder = Mockery::mock(Manager::class); - $this->app->loadDeferredProvider('translator'); - $this->app->instance('translator', $translator); + $responseBuilder->shouldReceive('setSerializer')->andReturnSelf()->byDefault(); + $responseBuilder->shouldReceive('parseIncludes')->andReturnSelf()->byDefault(); + $responseBuilder->shouldReceive('parseExcludes')->andReturnSelf()->byDefault(); + $responseBuilder->shouldReceive('parseFieldsets')->andReturnSelf()->byDefault(); - return $translator; + return $responseBuilder; } /** - * Create a mock of a success response builder. + * Create a mock of a [ResourceInterface]. * + * @param string|null $className * @return \Mockery\MockInterface */ - protected function mockSuccessBuilder() + protected function mockResource(string $className = null): MockInterface { - $successBuilder = Mockery::spy(SuccessResponseBuilder::class); - $successBuilder->shouldReceive('transform')->andReturnSelf(); - $successBuilder->shouldReceive('addMeta')->andReturnSelf(); - $successBuilder->shouldReceive('respond')->andReturn(new JsonResponse); + $resource = Mockery::mock($className ?: Collection::class); - $this->app->instance(SuccessResponseBuilder::class, $successBuilder); + $resource->shouldReceive('getData')->andReturnNull()->byDefault(); + $resource->shouldReceive('getTransformer')->andReturnNull()->byDefault(); + $resource->shouldReceive('setMeta')->andReturnSelf()->byDefault(); + $resource->shouldReceive('setCursor')->andReturnSelf()->byDefault(); + $resource->shouldReceive('setPaginator')->andReturnSelf()->byDefault(); - return $successBuilder; + return $resource; } } \ No newline at end of file diff --git a/tests/Unit/ErrorFactoryTest.php b/tests/Unit/ErrorFactoryTest.php new file mode 100644 index 0000000..ed00a7a --- /dev/null +++ b/tests/Unit/ErrorFactoryTest.php @@ -0,0 +1,94 @@ + + * @license The MIT License + */ +class ErrorFactoryTest extends TestCase +{ + /** + * A mock of an [ErrorMessageResolver] class. + * + * @var \Mockery\MockInterface + */ + protected $messageResolver; + + /** + * A mock of an [ErrorSerializer] class. + * + * @var \Mockery\MockInterface + */ + protected $serializer; + + /** + * The [ErrorFactory] class being tested. + * + * @var \Flugg\Responder\ErrorFactory + */ + protected $factory; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->messageResolver = Mockery::mock(ErrorMessageResolver::class); + $this->serializer = Mockery::mock(ErrorSerializer::class); + $this->factory = new ErrorFactory($this->messageResolver); + } + + /** + * Assert that the [make] method uses the [ErrorSerializer] to serialize the error data. + */ + public function testMakeMethodSerializesErrorDataUsingTheSerializer() + { + $this->serializer->shouldReceive('format')->andReturn($error = ['bar' => 2]); + + $result = $this->factory->make($this->serializer, $code = 'test_error', $message = 'A test error has occured.', $data = ['foo' => 1]); + + $this->assertEquals($error, $result); + $this->serializer->shouldHaveReceived('format')->with($code, $message, $data)->once(); + } + + /** + * Assert that the [make] method resolves a message using the [ErrorMessageResolver] when + * none is given. + */ + public function testMakeMethodShouldResolveMessageFromMessageResolver() + { + $this->serializer->shouldReceive('format')->andReturn([]); + $this->messageResolver->shouldReceive('resolve')->andReturn($message = 'A test error has occured.'); + + $this->factory->make($this->serializer, $code = 'test_error'); + + $this->serializer->shouldHaveReceived('format')->with($code, $message, null)->once(); + $this->messageResolver->shouldHaveReceived('resolve')->with($code)->once(); + } + + /** + * Assert that the [make] method allows skipping all parameters except serializer. + */ + public function testMakeMethodAllowsPassingOnlySerializer() + { + $this->serializer->shouldReceive('format')->andReturn($error = ['foo' => 1]); + + $result = $this->factory->make($this->serializer); + + $this->assertEquals($error, $result); + } +} \ No newline at end of file diff --git a/tests/Unit/ErrorMessageResolverTest.php b/tests/Unit/ErrorMessageResolverTest.php new file mode 100644 index 0000000..7cef2da --- /dev/null +++ b/tests/Unit/ErrorMessageResolverTest.php @@ -0,0 +1,72 @@ + + * @license The MIT License + */ +class ErrorMessageResolverTest extends TestCase +{ + /** + * A mock of a [Translator] service class. + * + * @var \Mockery\MockInterface + */ + protected $translator; + + /** + * The [ErrorMessageResolver] class being tested. + * + * @var \Flugg\Responder\ErrorMessageResolver + */ + protected $messageResolver; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->translator = Mockery::mock(Translator::class); + $this->messageResolver = new ErrorMessageResolver($this->translator); + } + + /** + * Assert that the [resolve] method uses the translator to resolve a message. + */ + public function testResolveMethodShouldResolveMessageFromTranslator() + { + $this->translator->shouldReceive('has')->andReturn(true); + $this->translator->shouldReceive('trans')->andReturn($message = 'A test error has occured.'); + + $result = $this->messageResolver->resolve($code = 'test_error'); + + $this->assertEquals($message, $result); + $this->translator->shouldHaveReceived('has')->with("errors.$code")->once(); + $this->translator->shouldHaveReceived('trans')->with("errors.$code")->once(); + } + + /** + * Assert that the [resolve] method returns [null] if the translator can't resolve message. + */ + public function testResolveMethodReturnsNullIfNoTranslatorKeyIsSet() + { + $this->translator->shouldReceive('has')->andReturn(false); + + $message = $this->messageResolver->resolve('test_error'); + + $this->assertNull($message); + } +} \ No newline at end of file diff --git a/tests/Unit/ErrorResponseBuilderTest.php b/tests/Unit/ErrorResponseBuilderTest.php deleted file mode 100644 index ad23643..0000000 --- a/tests/Unit/ErrorResponseBuilderTest.php +++ /dev/null @@ -1,313 +0,0 @@ - - * @license The MIT License - */ -class ErrorResponseBuilderTest extends TestCase -{ - /** - * Test that you can resolve an instance of [\Flugg\Responder\ErrorResponseBuilder] - * from the service container. - * - * @test - */ - public function youCanResolveASuccessResponseBuilderFromTheContainer() - { - // Act... - $responseBuilder = $this->app->make('responder.error'); - - // Assert... - $this->assertInstanceOf(ErrorResponseBuilder::class, $responseBuilder); - } - - /** - * Test that the [respond] method converts the error response into an instance of - * [\Illuminate\Http\JsonResponse] with a default status code of 500. - * - * @test - */ - public function respondMethodShouldReturnAJsonResponse() - { - // Arrange... - $responseBuilder = $this->app->make('responder.error'); - - // Act... - $response = $responseBuilder->respond(); - $responseArray = json_decode($response->content(), true); - - // Assert... - $this->assertInstanceOf(JsonResponse::class, $response); - $this->assertEquals($response->status(), 500); - $this->assertArrayHasKey('success', $responseArray); - $this->assertEquals(false, $responseArray['success']); - } - - /** - * Test that the [respond] method does not respond with success flag - * - * @test - */ - public function respondMethodShouldNotOutputSuccessFlagWhenDisabled() - { - $this->app['config']->set('responder.include_success_flag', false); - $responseBuilder = $this->app->make('responder.error'); - - $response = $responseBuilder->respond(); - $responseArray = json_decode($response->content(), true); - - $this->assertArrayNotHasKey('success', $responseArray); - } - - /** - * Test that the [respond] method allows passing a status code as the first parameter. - * - * @test - */ - public function respondMethodShouldAllowSettingStatusCode() - { - // Arrange... - $responseBuilder = $this->app->make('responder.error'); - - // Act... - $response = $responseBuilder->respond(400); - - // Assert... - $this->assertEquals($response->status(), 400); - } - - /** - * Test that you can set any headers to the JSON response by passing a second argument - * to the [respond] method. - * - * @test - */ - public function respondMethodShouldAllowSettingHeaders() - { - // Arrange... - $responseBuilder = $this->app->make('responder.error'); - - // Act... - $response = $responseBuilder->respond(400, [ - 'x-foo' => true - ]); - - // Assert... - $this->assertArrayHasKey('x-foo', $response->headers->all()); - } - - /** - * Test that the [setStatus] method sets the HTTP status code on the response, providing - * an alternative, more explicit way of setting the status code. - * - * @test - */ - public function setStatusMethodShouldSetStatusCode() - { - // Arrange... - $responseBuilder = $this->app->make('responder.error'); - - // Act... - $responseBuilder->setStatus(400); - - // Assert... - $this->assertEquals($responseBuilder->respond()->status(), 400); - } - - /** - * Test that the [setStatus] method throws an [\InvalidArgumentException] when the status - * code given is not a valid error HTTP status code. - * - * @test - */ - public function setStatusMethodShouldFailIfStatusCodeIsInvalid() - { - // Arrange... - $responseBuilder = $this->app->make('responder.error'); - $this->expectException(InvalidArgumentException::class); - - // Act... - $responseBuilder->setStatus(200); - } - - /** - * Test that the [setStatus] method returns the response builder, allowing for fluent - * method chaining. - * - * @test - */ - public function setStatusMethodShouldReturnItself() - { - // Arrange... - $responseBuilder = $this->app->make('responder.error'); - - // Act... - $result = $responseBuilder->setStatus(400); - - // Assert... - $this->assertSame($responseBuilder, $result); - } - - /** - * Test that the [toArray] method serializes the data given, using the default serializer - * and no data. - * - * @test - */ - public function toArrayMethodShouldSerializeData() - { - // Arrange... - $responseBuilder = $this->app->make('responder.error'); - - // Act... - $array = $responseBuilder->toArray(); - - // Assert... - $this->assertEquals([ - 'error' => null - ], $array); - } - - /** - * Test that error data is added when an error code is set using the [setError] method. - * - * @test - */ - public function setErrorMethodShouldAddErrorData() - { - // Arrange... - $responseBuilder = $this->app->make('responder.error'); - - // Act... - $responseBuilder->setError('testing_error'); - - // Assert... - $this->assertEquals([ - 'error' => [ - 'code' => 'testing_error', - 'message' => null - ] - ], $responseBuilder->toArray()); - } - - /** - * Test that the [setError] method attempts to resolve an error message from the translator. - * - * @test - */ - public function setErrorMethodShouldResolveErrorMessageFromTranslator() - { - // Arrange... - $this->mockTranslator('Testing error'); - $responseBuilder = $this->app->make('responder.error'); - - // Act... - $responseBuilder->setError('testing_error'); - - // Assert... - $this->assertEquals([ - 'error' => [ - 'code' => 'testing_error', - 'message' => 'Testing error' - ] - ], $responseBuilder->toArray()); - } - - /** - * Test that the [setError] method should allow passing any parameters to the translator - * when resolving the error message. - * - * @test - */ - public function setErrorMethodShouldAllowAddingParametersToMessage() - { - // Arrange... - $translator = $this->mockTranslator('Testing error foo'); - $responseBuilder = $this->app->make('responder.error'); - $parameters = ['name' => 'foo']; - - // Act... - $responseBuilder->setError('testing_error', $parameters); - - // Assert... - $this->assertEquals([ - 'error' => [ - 'code' => 'testing_error', - 'message' => 'Testing error foo' - ] - ], $responseBuilder->toArray()); - $translator->shouldHaveReceived('trans')->with('errors.testing_error', $parameters); - } - - /** - * Test that the [setError] method allows passing a string as second argument instead of an - * array of parameters, which will override the error message and set it explicitly. - * - * @test - */ - public function setErrorMethodShouldAllowOverridingErrorMessage() - { - // Arrange... - $this->mockTranslator('Testing error 1'); - $responseBuilder = $this->app->make('responder.error'); - - // Act... - $responseBuilder->setError('testing_error', 'Testing error 2'); - - // Assert... - $this->assertEquals([ - 'error' => [ - 'code' => 'testing_error', - 'message' => 'Testing error 2' - ] - ], $responseBuilder->toArray()); - } - - /** - * Test that the [toCollection] serializes the data into a collection. - * - * @test - */ - public function toCollectionMethodShouldReturnACollection() - { - // Arrange... - $responseBuilder = $this->app->make('responder.error'); - - // Act... - $collection = $responseBuilder->toCollection(); - - // Assert... - $this->assertEquals(collect([ - 'error' => null - ]), $collection); - } - - /** - * Test that the [toJson] serializes the data into a JSON string. - * - * @test - */ - public function toJsonMethodShouldReturnJson() - { - // Arrange... - $responseBuilder = $this->app->make('responder.error'); - - // Act... - $json = $responseBuilder->toCollection(); - - // Assert... - $this->assertEquals(json_encode([ - 'error' => null - ]), $json); - } -} diff --git a/tests/Unit/Exceptions/HandlerTest.php b/tests/Unit/Exceptions/HandlerTest.php new file mode 100644 index 0000000..74e4d82 --- /dev/null +++ b/tests/Unit/Exceptions/HandlerTest.php @@ -0,0 +1,165 @@ + + * @license The MIT License + */ +class HandlerTest extends TestCase +{ + /** + * A mock of a [Request] object. + * + * @var \Mockery\MockInterface + */ + protected $request; + + /** + * A mock of a [Container] class. + * + * @var \Mockery\MockInterface + */ + protected $container; + + /** + * The [Handler] class being tested. + * + * @var \Flugg\Responder\Exceptions\Handler; + */ + protected $handler; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->container = Mockery::mock(Container::class); + $this->request = Mockery::mock(Request::class); + $this->handler = new Handler($this->container); + $this->app->instance(Container::class, $this->container); + } + + /** + * Assert that the [render] method converts [AuthenticationException] exceptions to + * the package's [UnauthenticatedException]. + */ + public function testRenderMethodConvertsUnauthenticationExceptions() + { + $exception = new AuthenticationException; + $this->expectException(UnauthenticatedException::class); + + $this->handler->render($this->request, $exception); + } + + /** + * Assert that the [render] method converts [AuthorizationException] exceptions to + * the package's [UnauthorizedException]. + */ + public function testRenderMethodConvertsUnauthorizedExceptions() + { + $exception = new AuthorizationException; + $this->expectException(UnauthorizedException::class); + + $this->handler->render($this->request, $exception); + } + + /** + * Assert that the [render] method converts [ModelNotFoundException] exceptions to + * the package's [PageNotFoundException]. + */ + public function testRenderMethodConvertsModelNotFoundExceptions() + { + $exception = new ModelNotFoundException; + $this->expectException(PageNotFoundException::class); + + $this->handler->render($this->request, $exception); + } + + /** + * Assert that the [render] method converts [RelationNotFoundException] exceptions to + * the package's [RelationNotFoundException]. + */ + public function testRenderMethodConvertsRelationNotFoundExceptions() + { + $exception = new BaseRelationNotFoundException; + $this->expectException(RelationNotFoundException::class); + + $this->handler->render($this->request, $exception); + } + + /** + * Assert that the [render] method converts [ValidationException] exceptions to + * the package's [ValidationFailedException]. + */ + public function testRenderMethodConvertsValidationExceptions() + { + $exception = new ValidationException($validator = Mockery::mock(Validator::class)); + $this->expectException(ValidationFailedException::class); + + $this->handler->render($this->request, $exception); + } + + /** + * Assert that the [render] method converts [HttpException] exceptions to responses. + */ + public function testRenderMethodConvertsHttpExceptionsToResponses() + { + $exception = Mockery::mock(HttpException::class); + $exception->shouldReceive('errorCode')->andReturn($errorCode = 'test_error'); + $exception->shouldReceive('message')->andReturn($message = 'A test error has occured.'); + $exception->shouldReceive('data')->andReturn($data = ['foo' => 1]); + $exception->shouldReceive('statusCode')->andReturn($status = 404); + $this->app->instance(Responder::class, $responder = Mockery::mock(Responder::class)); + $responder->shouldReceive('error')->andReturn($responseBuilder = $this->mockErrorResponseBuilder()); + $responseBuilder->shouldReceive('respond')->andReturn($response = new JsonResponse); + + $result = $this->handler->render($this->request, $exception); + + $this->assertSame($response, $result); + $responder->shouldHaveReceived('error')->with($errorCode, $message)->once(); + $responseBuilder->shouldHaveReceived('data')->with($data)->once(); + $responseBuilder->shouldHaveReceived('respond')->with($status)->once(); + } + + /** + * Assert that the [render] method leaves other exceptions untouched. + */ + public function testItShouldNotConvertNonHttpExceptions() + { + $result = $this->handler->render($this->request, $exception = new Exception); + + $this->assertInstanceOf(Response::class, $result); + } +} \ No newline at end of file diff --git a/tests/Unit/Exceptions/Http/HttpExceptionTest.php b/tests/Unit/Exceptions/Http/HttpExceptionTest.php new file mode 100644 index 0000000..581bce9 --- /dev/null +++ b/tests/Unit/Exceptions/Http/HttpExceptionTest.php @@ -0,0 +1,93 @@ + + * @license The MIT License + */ +class HttpExceptionTest extends TestCase +{ + /** + * A stub of the package's [Handler] class. + * + * @var \Flugg\Responder\Exceptions\Http\HttpException + */ + protected $exception; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->exception = new class extends HttpException + { + protected $status = 404; + protected $errorCode = 'test_error'; + protected $message = 'An error has occured.'; + protected $data = ['foo' => 1]; + protected $headers = ['x-foo' => true]; + }; + } + + /** + * Assert that the [statusCode] method returns the set status code. + */ + public function testStatusCodeMethodReturnsStatusCode() + { + $status = $this->exception->statusCode(); + + $this->assertEquals(404, $status); + } + + /** + * Assert that the [errorCode] method returns the set error code. + */ + public function testErrorCodeMethodReturnsErrorCode() + { + $errorCode = $this->exception->errorCode(); + + $this->assertEquals('test_error', $errorCode); + } + + /** + * Assert that the [message] method returns the set error message. + */ + public function testMessageMethodReturnsErrorMessage() + { + $message = $this->exception->message(); + + $this->assertEquals('An error has occured.', $message); + } + + /** + * Assert that the [data] method returns the set error data. + */ + public function testDataMethodReturnsErrorData() + { + $data = $this->exception->data(); + + $this->assertEquals(['foo' => 1], $data); + } + + /** + * Assert that the [headers] method returns the attached headers. + */ + public function testHeadersMethodReturnsHeaders() + { + $data = $this->exception->headers(); + + $this->assertEquals(['x-foo' => true], $data); + } +} \ No newline at end of file diff --git a/tests/Unit/Facades/ResponderTest.php b/tests/Unit/Facades/ResponderTest.php new file mode 100644 index 0000000..9626dc7 --- /dev/null +++ b/tests/Unit/Facades/ResponderTest.php @@ -0,0 +1,66 @@ + + * @license The MIT License + */ +class ResponderTest extends TestCase +{ + /** + * A mock of a [Responder] service class. + * + * @var \Mockery\MockInterface + */ + protected $responder; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->responder = Mockery::mock(ResponderContract::class); + $this->app->instance(ResponderContract::class, $this->responder); + } + + /** + * Assert that the parameters sent to the [error] method is forwarded to the + * responder service. + */ + public function testErrorMethodShouldCallOnResponder() + { + $this->responder->shouldReceive('error')->andReturn($responseBuilder = $this->mockErrorResponseBuilder()); + + $result = Responder::error($error = 'error_occured'); + + $this->assertSame($responseBuilder, $result); + $this->responder->shouldHaveReceived('error')->with($error)->once(); + } + + /** + * Assert that the parameters sent to the [success] method is forwarded to the + * responder service. + */ + public function testSuccessMethodShouldCallOnResponder() + { + $this->responder->shouldReceive('success')->andReturn($responseBuilder = $this->mockSuccessResponseBuilder()); + + $result = Responder::success($data = ['foo' => 1]); + + $this->assertSame($responseBuilder, $result); + $this->responder->shouldHaveReceived('success')->with($data)->once(); + } +} \ No newline at end of file diff --git a/tests/Unit/Facades/TransformerTest.php b/tests/Unit/Facades/TransformerTest.php new file mode 100644 index 0000000..806ccd4 --- /dev/null +++ b/tests/Unit/Facades/TransformerTest.php @@ -0,0 +1,52 @@ + + * @license The MIT License + */ +class TransformerTest extends TestCase +{ + /** + * A mock of a [Transformer] service class. + * + * @var \Mockery\MockInterface + */ + protected $transformer; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->transformer = Mockery::mock(TransformerContract::class); + $this->app->instance(TransformerContract::class, $this->transformer); + } + + /** + * Assert that the parameters sent to the [transform] method is forwarded to the + * transformer service. + */ + public function testTransformMethodShouldCallOnTransformer() + { + $this->transformer->shouldReceive('transform')->andReturn($transformedData = ['bar' => 2]); + + $result = Transformer::transform($data = ['foo' => 1]); + + $this->assertEquals($transformedData, $result); + $this->transformer->shouldHaveReceived('transform')->with($data); + } +} \ No newline at end of file diff --git a/tests/Unit/FractalTransformFactoryTest.php b/tests/Unit/FractalTransformFactoryTest.php new file mode 100644 index 0000000..f637974 --- /dev/null +++ b/tests/Unit/FractalTransformFactoryTest.php @@ -0,0 +1,69 @@ + + * @license The MIT License + */ +class FractalTransformFactoryTest extends TestCase +{ + /** + * A mock a Fractal's [Manager] class. + * + * @var \Mockery\MockInterface + */ + protected $manager; + + /** + * The [TransformFactory] class being tested. + * + * @var \Flugg\Responder\FractalTransformFactory + */ + protected $factory; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->manager = $this->mockFractalManager(); + $this->factory = new FractalTransformFactory($this->manager); + } + + /** + * Assert that the [make] method uses the manager to transform data. + */ + public function testMakeMethodShouldCallOnManager() + { + $this->manager->shouldReceive('createData')->andReturn($scope = Mockery::mock(Scope::class)); + $scope->shouldReceive('toArray')->andReturn($data = ['foo' => 1]); + + $result = $this->factory->make($resource = new NullResource(null, null, 'foo'), $serializer = Mockery::mock(SerializerAbstract::class), [ + 'includes' => $with = ['foo'], + 'excludes' => $without = ['bar'], + 'fieldsets' => $fieldsets = [], + ]); + + $this->assertEquals($data, $result); + $this->manager->shouldHaveReceived('setSerializer')->with($serializer)->once(); + $this->manager->shouldHaveReceived('parseIncludes')->with($with)->once(); + $this->manager->shouldHaveReceived('parseExcludes')->with($without)->once(); + $this->manager->shouldHaveReceived('parseFieldsets')->with($fieldsets)->once(); + $this->manager->shouldHaveReceived('createData')->with($resource)->once(); + } +} \ No newline at end of file diff --git a/tests/Unit/HelpersTest.php b/tests/Unit/HelpersTest.php new file mode 100644 index 0000000..dbbd847 --- /dev/null +++ b/tests/Unit/HelpersTest.php @@ -0,0 +1,73 @@ + + * @license The MIT License + */ +class HelpersTest extends TestCase +{ + /** + * A mock of a [Responder] service class. + * + * @var \Mockery\MockInterface + */ + protected $responder; + + /** + * A mock of a [Transformer] service class. + * + * @var \Mockery\MockInterface + */ + protected $transformer; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->responder = Mockery::mock(Responder::class); + $this->app->instance(Responder::class, $this->responder); + + $this->transformer = Mockery::mock(Transformer::class); + $this->app->instance(Transformer::class, $this->transformer); + } + + /** + * Assert that the [responder] function should resolve the responder service from the + * service container. + */ + public function testResponderFunctionShouldResolveResponderService() + { + $result = responder(); + + $this->assertSame($this->responder, $result); + } + + /** + * Assert that the [transform] function should use the transformer service to transform + * the data. + */ + public function testTransformFunctionShouldTransformData() + { + $this->transformer->shouldReceive('transform')->andReturn($transformedData = ['bar' => 2]); + + $result = transform($data = ['foo' => 1], $transformer = $this->mockTransformer(), $with = ['foo'], $without = ['bar']); + + $this->assertEquals($transformedData, $result); + $this->transformer->shouldHaveReceived('transform')->with($data, $transformer, $with, $without); + } +} \ No newline at end of file diff --git a/tests/Unit/Http/MakesResponsesTest.php b/tests/Unit/Http/MakesResponsesTest.php new file mode 100644 index 0000000..a86981b --- /dev/null +++ b/tests/Unit/Http/MakesResponsesTest.php @@ -0,0 +1,74 @@ + + * @license The MIT License + */ +class MakesResponsesTest extends TestCase +{ + /** + * A mock of a [Responder] service class. + * + * @var \Mockery\MockInterface + */ + protected $responder; + + /** + * The [MakesResponses] trait being tested. + * + * @var \Flugg\Responder\Http\MakesResponses + */ + protected $trait; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->responder = Mockery::mock(Responder::class); + $this->app->instance(Responder::class, $this->responder); + $this->trait = $this->getMockForTrait(MakesResponses::class); + } + + /** + * Assert that the parameters sent to the [success] method is forwarded to the + * responder service. + */ + public function testSuccessMethodShouldCallOnResponder() + { + $this->responder->shouldReceive('success')->andReturn($responseBuilder = $this->mockSuccessResponseBuilder()); + + $result = $this->trait->success($data = ['foo' => 1], $transformer = $this->mockTransformer(), $key = 'foo'); + + $this->assertSame($responseBuilder, $result); + $this->responder->shouldHaveReceived('success')->with($data, $transformer, $key)->once(); + } + + /** + * Assert that the parameters sent to the [error] method is forwarded to the + * responder service. + */ + public function testErrorMethodShouldCallOnResponder() + { + $this->responder->shouldReceive('error')->andReturn($responseBuilder = $this->mockErrorResponseBuilder()); + + $result = $this->trait->error($error = 'error_occured', $message = 'An error has occured.', $data = ['foo' => 1]); + + $this->assertSame($responseBuilder, $result); + $this->responder->shouldHaveReceived('error')->with($error, $message, $data)->once(); + } +} \ No newline at end of file diff --git a/tests/Unit/Http/Responses/Decorators/StatusCodeDecoratorTest.php b/tests/Unit/Http/Responses/Decorators/StatusCodeDecoratorTest.php new file mode 100644 index 0000000..9be580d --- /dev/null +++ b/tests/Unit/Http/Responses/Decorators/StatusCodeDecoratorTest.php @@ -0,0 +1,54 @@ + + * @license The MIT License + */ +class StatusCodeDecoratorTest extends TestCase +{ + /** + * A mock of a [ResponseFactory] class. + * + * @var \Mockery\MockInterface + */ + protected $responseFactory; + + /** + * The [StatusCodeResponseDecorator] class being tested. + * + * @var \Flugg\Responder\Http\Responses\Decorators\StatusCodeDecorator + */ + protected $responseDecorator; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->responseFactory = $this->mockResponseFactory(); + $this->responseDecorator = new StatusCodeDecorator($this->responseFactory); + } + + /** + * Assert that the [make] method decorates the response data with information about + * status code. + */ + public function testMakeMethodShouldAppendStatusCodeFieldToResponseData() + { + $response = $this->responseDecorator->make($data = ['foo' => 1], $status = 201); + + $this->assertEquals(json_encode(array_merge(['status' => $status], $data)), $response->getContent()); + } +} \ No newline at end of file diff --git a/tests/Unit/Http/Responses/Decorators/SuccessFlagDecoratorTest.php b/tests/Unit/Http/Responses/Decorators/SuccessFlagDecoratorTest.php new file mode 100644 index 0000000..f1e5e8b --- /dev/null +++ b/tests/Unit/Http/Responses/Decorators/SuccessFlagDecoratorTest.php @@ -0,0 +1,54 @@ + + * @license The MIT License + */ +class SuccessFlagDecoratorTest extends TestCase +{ + /** + * A mock of a [ResponseFactory] class. + * + * @var \Mockery\MockInterface + */ + protected $responseFactory; + + /** + * The [StatusCodeResponseDecorator] class being tested. + * + * @var \Flugg\Responder\Http\Responses\Decorators\SuccessFlagDecorator + */ + protected $responseDecorator; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->responseFactory = $this->mockResponseFactory(); + $this->responseDecorator = new SuccessFlagDecorator($this->responseFactory); + } + + /** + * Assert that the [make] method decorates the response data with information about + * wether or not the response was successful. + */ + public function testMakeMethodShouldAppendSuccessFlagFieldToResponseData() + { + $response = $this->responseDecorator->make($data = ['foo' => 1], $status = 201); + + $this->assertEquals(json_encode(array_merge(['success' => true], $data)), $response->getContent()); + } +} diff --git a/tests/Unit/Http/Responses/ErrorResponseBuilderTest.php b/tests/Unit/Http/Responses/ErrorResponseBuilderTest.php new file mode 100644 index 0000000..f2f1821 --- /dev/null +++ b/tests/Unit/Http/Responses/ErrorResponseBuilderTest.php @@ -0,0 +1,168 @@ + + * @license The MIT License + */ +class ErrorResponseBuilderTest extends TestCase +{ + /** + * A mock of a [ResponseFactory] class. + * + * @var \Mockery\MockInterface + */ + protected $responseFactory; + + /** + * A mock of an [ErrorFactory] class. + * + * @var \Mockery\MockInterface + */ + protected $errorFactory; + + /** + * A mock of a [SerializerAbstract] class. + * + * @var \Mockery\MockInterface + */ + protected $serializer; + + /** + * The [ErrorResponseBuilder] class being tested. + * + * @var \Flugg\Responder\Http\Responses\ErrorResponseBuilder + */ + protected $responseBuilder; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->responseFactory = $this->mockResponseFactory(); + $this->errorFactory = Mockery::mock(ErrorFactory::class); + $this->responseBuilder = new ErrorResponseBuilder($this->responseFactory, $this->errorFactory); + $this->responseBuilder->serializer($this->serializer = Mockery::mock(ErrorSerializer::class)); + } + + /** + * Assert that the [respond] generates JSON responses using the [ResponseFactory]. + */ + public function testRespondMethodShouldMakeJsonResponses() + { + $response = new JsonResponse($error = ['foo' => 1], $status = 400, $headers = ['x-foo' => 1]); + $this->errorFactory->shouldReceive('make')->andReturn($error); + $this->responseFactory->shouldReceive('make')->andReturn($response); + + $result = $this->responseBuilder->respond($status, $headers); + + $this->assertEquals($response, $result); + $this->responseFactory->shouldHaveReceived('make')->with($error, $status, $headers)->once(); + } + + /** + * Assert that the [respond] method throws an [InvalidArgumentException] exception if + * status code is not a valid error code. + */ + public function testRespondMethodThrowsExceptionIfGivenInvalidStatusCode() + { + $this->expectException(InvalidArgumentException::class); + + $this->responseBuilder->respond($status = 200); + } + + /** + * Assert that the [toArray] method formats the error output using the [ErrorFactory] and + * returns the result as an array. + */ + public function testToArrayMethodShouldFormatErrorUsingErrorFactory() + { + $this->errorFactory->shouldReceive('make')->andReturn($error = ['foo' => 1]); + + $result = $this->responseBuilder->toArray(); + + $this->assertEquals($error, $result); + } + + /** + * Assert that the [toCollection] method formats the error output using the [ErrorFactory] + * and returns the result as a collection. + */ + public function testToCollectionMethodShouldFormatErrorAndReturnCollection() + { + $this->errorFactory->shouldReceive('make')->andReturn($error = ['foo' => 1]); + + $result = $this->responseBuilder->toCollection(); + + $this->assertEquals(new Collection($error), $result); + } + + /** + * Assert that the [toJson] method formats the error output using the [ErrorFactory] and + * returns the result as JSON. + */ + public function testToJsonMethodShouldFormatErrorAndReturnJson() + { + $this->errorFactory->shouldReceive('make')->andReturn($error = ['foo' => 1]); + + $result = $this->responseBuilder->toJson(); + + $this->assertEquals(json_encode($error), $result); + } + + /** + * Assert that the [toJson] method accepts an argument for setting encoding options. + */ + public function testToJsonMethodShouldAllowSettingEncodingOptions() + { + $this->errorFactory->shouldReceive('make')->andReturn($error = ['foo' => 1]); + + $result = $this->responseBuilder->toJson(JSON_PRETTY_PRINT); + + $this->assertEquals(json_encode($error, JSON_PRETTY_PRINT), $result); + } + + /** + * Assert that the [error] method sets the error code and message that is sent to the + * [ErrorFactory]. + */ + public function testErrorMethodSetsErrorCodeAndMessage() + { + $this->errorFactory->shouldReceive('make')->andReturn([]); + + $this->responseBuilder->error($code = 'test_error', $message = 'A test error has occured.')->toArray(); + + $this->errorFactory->shouldHaveReceived('make')->with($this->serializer, $code, $message, null)->once(); + } + + /** + * Assert that the [data] method adds error data that is sent to the [ErrorFactory] + */ + public function testDataMethodSetsErrorData() + { + $this->errorFactory->shouldReceive('make')->andReturn([]); + + $this->responseBuilder->data($data = ['foo' => 1])->toArray(); + + $this->errorFactory->shouldHaveReceived('make')->with($this->serializer, null, null, $data)->once(); + } +} \ No newline at end of file diff --git a/tests/Unit/Http/Responses/Factories/LaravelResponseFactoryTest.php b/tests/Unit/Http/Responses/Factories/LaravelResponseFactoryTest.php new file mode 100644 index 0000000..6577429 --- /dev/null +++ b/tests/Unit/Http/Responses/Factories/LaravelResponseFactoryTest.php @@ -0,0 +1,63 @@ + + * @license The MIT License + */ +class LaravelResponseFactoryTest extends TestCase +{ + /** + * A mock of a Laravel's [ResponseFactory]. + * + * @var \Mockery\MockInterface + */ + protected $baseResponseFactory; + + /** + * The [ResponseFactory] adapter class being tested. + * + * @var \Flugg\Responder\Http\Responses\Factories\LaravelResponseFactory + */ + protected $responseFactory; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->baseResponseFactory = Mockery::mock(ResponseFactory::class); + $this->baseResponseFactory->shouldReceive('json')->andReturnUsing(function ($data, $status, $headers) { + return new JsonResponse($data, $status, $headers); + }); + + $this->responseFactory = new LaravelResponseFactory($this->baseResponseFactory); + } + + /** + * Assert that the [make] method creates JSON responses using Laravel's [ResponseFactory]. + */ + public function testMakeMethodShouldCreateJsonResponses() + { + $response = $this->responseFactory->make($data = ['foo' => 1], $status = 201, $headers = ['x-foo' => 1]); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals($data, $response->getData(true)); + $this->assertEquals($status, $response->getStatusCode()); + $this->assertEquals($headers['x-foo'], $response->headers->get('x-foo')); + } +} \ No newline at end of file diff --git a/tests/Unit/Http/Responses/Factories/LumenResponseFactoryTest.php b/tests/Unit/Http/Responses/Factories/LumenResponseFactoryTest.php new file mode 100644 index 0000000..2d3c352 --- /dev/null +++ b/tests/Unit/Http/Responses/Factories/LumenResponseFactoryTest.php @@ -0,0 +1,63 @@ + + * @license The MIT License + */ +class LumenResponseFactoryTest extends TestCase +{ + /** + * A mock of a Lumen's [ResponseFactory] class. + * + * @var \Mockery\MockInterface + */ + protected $baseResponseFactory; + + /** + * The [ResponseFactory] adapter class being tested. + * + * @var \Flugg\Responder\Http\Responses\Factories\LumenResponseFactory + */ + protected $responseFactory; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->baseResponseFactory = Mockery::mock(ResponseFactory::class); + $this->baseResponseFactory->shouldReceive('json')->andReturnUsing(function ($data, $status, $headers) { + return new JsonResponse($data, $status, $headers); + }); + + $this->responseFactory = new LumenResponseFactory($this->baseResponseFactory); + } + + /** + * Assert that the [make] method creates JSON responses using Lumen's [ResponseFactory]. + */ + public function testMakeMethodShouldCreateJsonResponses() + { + $response = $this->responseFactory->make($data = ['foo' => 1], $status = 201, $headers = ['x-foo' => 1]); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals($data, $response->getData(true)); + $this->assertEquals($status, $response->getStatusCode()); + $this->assertEquals($headers['x-foo'], $response->headers->get('x-foo')); + } +} \ No newline at end of file diff --git a/tests/Unit/Http/Responses/SuccessResponseBuilderTest.php b/tests/Unit/Http/Responses/SuccessResponseBuilderTest.php new file mode 100644 index 0000000..ebf1dbc --- /dev/null +++ b/tests/Unit/Http/Responses/SuccessResponseBuilderTest.php @@ -0,0 +1,223 @@ + + * @license The MIT License + */ +class SuccessResponseBuilderTest extends TestCase +{ + /** + * A mock of a [ResponseFactory] class. + * + * @var \Mockery\MockInterface + */ + protected $responseFactory; + + /** + * A mock of a [TransformBuilder] class. + * + * @var \Mockery\MockInterface + */ + protected $transformBuilder; + + /** + * The [SuccessResponseBuilder] class being tested. + * + * @var \Flugg\Responder\Http\Responses\SuccessResponseBuilder + */ + protected $responseBuilder; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->responseFactory = $this->mockResponseFactory(); + $this->transformBuilder = $this->mockTransformBuilder(); + $this->responseBuilder = new SuccessResponseBuilder($this->responseFactory, $this->transformBuilder); + } + + /** + * Assert that the parameters sent to the [transform] method is forwarded to the + * [TransformBuilder]. + */ + public function testTransformMethodShouldMakeResources() + { + $builder = $this->responseBuilder->transform($data = ['foo' => 1], $transformer = $this->mockTransformer(), $key = 'foo'); + + $this->assertSame($builder, $this->responseBuilder); + $this->transformBuilder->shouldHaveReceived('resource')->with($data, $transformer, $key)->once(); + } + + /** + * Assert that the [respond] generates JSON responses using the [ResponseFactory]. + */ + public function testRespondMethodShouldMakeJsonResponses() + { + $response = new JsonResponse($data = ['foo' => 1], $status = 201, $headers = ['x-foo' => 1]); + $this->transformBuilder->shouldReceive('transform')->andReturn($data); + $this->responseFactory->shouldReceive('make')->andReturn($response); + + $result = $this->responseBuilder->respond($status, $headers); + + $this->assertEquals($response, $result); + $this->responseFactory->shouldHaveReceived('make')->with($data, $status, $headers)->once(); + } + + /** + * Assert that the [respond] method throws an [InvalidArgumentException] exception if + * status code is not a valid success code. + */ + public function testRespondMethodThrowsExceptionIfGivenInvalidStatusCode() + { + $this->expectException(InvalidArgumentException::class); + + $this->responseBuilder->respond($status = 400); + } + + /** + * Assert that the [toArray] method formats the success output using the [TransformBuilder] + * and returns the result as an array. + */ + public function testToArrayMethodShouldFormatErrorUsingErrorFactory() + { + $this->transformBuilder->shouldReceive('transform')->andReturn($data = ['foo' => 1]); + + $transformation = $this->responseBuilder->toArray(); + + $this->assertEquals($data, $transformation); + $this->transformBuilder->shouldHaveReceived('transform')->withNoArgs()->once(); + } + + /** + * Assert that the [toCollection] method formats the success output using the [TransformBuilder] + * and returns the result as a collection. + */ + public function testToCollectionMethodShouldFormatErrorAndReturnCollection() + { + $this->transformBuilder->shouldReceive('transform')->andReturn($data = ['foo' => 1]); + + $transformation = $this->responseBuilder->toCollection(); + + $this->assertEquals(new Collection($data), $transformation); + $this->transformBuilder->shouldHaveReceived('transform')->withNoArgs()->once(); + } + + /** + * Assert that the [toJson] method formats the success output using the [TransformBuilder] and + * returns the result as JSON. + */ + public function testToJsonMethodShouldFormatErrorAndReturnJson() + { + $this->transformBuilder->shouldReceive('transform')->andReturn($data = ['foo' => 1]); + + $transformation = $this->responseBuilder->toJson(); + + $this->assertEquals(json_encode($data), $transformation); + $this->transformBuilder->shouldHaveReceived('transform')->withNoArgs()->once(); + } + + /** + * Assert that the [toJson] method accepts an argument for setting encoding options. + */ + public function testToJsonMethodShouldAllowSettingEncodingOptions() + { + $this->transformBuilder->shouldReceive('transform')->andReturn($data = ['foo' => 1]); + + $transformation = $this->responseBuilder->toJson(JSON_PRETTY_PRINT); + + $this->assertEquals(json_encode($data, JSON_PRETTY_PRINT), $transformation); + } + + /** + * Assert that the data sent to the [meta] method is forwarded to the [TransformBuilder]. + */ + public function testMetaMethodShouldAddMetaToTransformBuilder() + { + $responseBuilder = $this->responseBuilder->meta($meta = ['foo' => 1]); + + $this->assertSame($responseBuilder, $this->responseBuilder); + $this->transformBuilder->shouldHaveReceived('meta')->with($meta)->once(); + } + + /** + * Assert that the serializer sent to the [serializer] method is forwarded to the [TransformBuilder]. + */ + public function testSerializerMethodShouldSetSerializerOnTransformBuilder() + { + $responseBuilder = $this->responseBuilder->serializer($serializer = JsonSerializer::class); + + $this->assertSame($responseBuilder, $this->responseBuilder); + $this->transformBuilder->shouldHaveReceived('serializer')->with($serializer)->once(); + } + + /** + * Assert that the relations sent to the [with] method is forwarded to the [TransformBuilder]. + */ + public function testWithMethodShouldAddRelationsToTransformBuilder() + { + $responseBuilder = $this->responseBuilder->with($relations = ['foo', 'bar']); + + $this->assertSame($responseBuilder, $this->responseBuilder); + $this->transformBuilder->shouldHaveReceived('with')->with($relations)->once(); + } + + /** + * Assert that the [with] method allows passing multiple strings instead of an array. + */ + public function testWithMethodShouldAllowMultipleStringArguments() + { + $this->responseBuilder->with(...$relations = ['foo', 'bar']); + + $this->transformBuilder->shouldHaveReceived('with')->with(...$relations)->once(); + } + + /** + * Assert that the relations sent to the [without] method is forwarded to the [TransformBuilder]. + */ + public function testWithoutMethodShouldAddRelationsToTheTransformBuilder() + { + $responseBuilder = $this->responseBuilder->without($relations = ['foo', 'bar']); + + $this->assertSame($responseBuilder, $this->responseBuilder); + $this->transformBuilder->shouldHaveReceived('without')->with($relations)->once(); + } + + /** + * Assert that the [without] method allows passing multiple strings instead of an array. + */ + public function testWithoutMethodShouldAllowMultipleStringArguments() + { + $this->responseBuilder->without(...$relations = ['foo', 'bar']); + + $this->transformBuilder->shouldHaveReceived('without')->with(...$relations)->once(); + } + + /** + * Assert that the [__call] method should throw a [BadMethodCallException] exception if + * given an unknown method name. + */ + public function testUnknownMethodsShouldThrowException() + { + $this->expectException(BadMethodCallException::class); + + $this->responseBuilder->unknownMethod(); + } +} \ No newline at end of file diff --git a/tests/Unit/Pagination/CursorPaginatorTest.php b/tests/Unit/Pagination/CursorPaginatorTest.php new file mode 100644 index 0000000..0d42173 --- /dev/null +++ b/tests/Unit/Pagination/CursorPaginatorTest.php @@ -0,0 +1,81 @@ + + * @license The MIT License + */ +class CursorPaginatorTest extends TestCase +{ + /** + * Assert that the [previous], [cursor] and [next] methods allow you to get information + * about the cursor. + */ + public function testYouCanGetCursorInformationFromPaginator() + { + $paginator = new CursorPaginator(null, $cursor = 2, $previousCursor = 1, $nextCursor = 3); + + $this->assertEquals(1, $paginator->previous()); + $this->assertEquals(2, $paginator->cursor()); + $this->assertEquals(3, $paginator->next()); + } + + /** + * Assert that the [items] and [get] methods allow you to get data from paginator. + */ + public function testYouCanGetDataFromPaginator() + { + $paginator = new CursorPaginator($data = ['foo', 'bar'], null, null, null); + + $this->assertEquals($data, $paginator->items()); + $this->assertEquals(Collection::make($data), $paginator->get()); + } + + /** + * Assert that the [set] method allows you to get override the cursor data. + */ + public function testSetMethodAllowsYouToOverrideData() + { + $paginator = new CursorPaginator(['foo', 'bar'], null, null, null); + + $result = $paginator->set($data = ['bar', 'baz']); + + $this->assertSame($paginator, $result); + $this->assertEquals($data, $paginator->items()); + } + + /** + * Assert that the [resolveCursor] method throws a [LogicException] exception if no + * resolver has been set. + */ + public function testResolveCursorMethodThrowsExceptionIfNoResolverIsFound() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Could not resolve cursor with the name [foo].'); + + CursorPaginator::resolveCursor('foo'); + } + + /** + * Assert that the [cursorResolver] sets a resolver for the [resolveCursor] method. + */ + public function testYouCanSetACursorResolver() + { + CursorPaginator::cursorResolver($resolver = function ($cursor) { + return $cursor; + }); + + $result = CursorPaginator::resolveCursor($cursor = 'foo'); + + $this->assertSame($cursor, $result); + } +} \ No newline at end of file diff --git a/tests/Unit/Pagination/PaginatorFactoryTest.php b/tests/Unit/Pagination/PaginatorFactoryTest.php new file mode 100644 index 0000000..bbc379d --- /dev/null +++ b/tests/Unit/Pagination/PaginatorFactoryTest.php @@ -0,0 +1,58 @@ + + * @license The MIT License + */ +class PaginatorFactoryTest extends TestCase +{ + /** + * Assert that the [make] method creates a paginator adapter from a [LengthAwarePaginator]. + */ + public function testMakeMethodShouldCreatePaginatorAdapters() + { + $factory = new PaginatorFactory($parameters = ['foo' => 1]); + $paginator = Mockery::mock(LengthAwarePaginator::class); + $paginator->shouldReceive('appends')->andReturnSelf(); + + $result = $factory->make($paginator); + + $this->assertInstanceOf(PaginatorInterface::class, $result); + $paginator->shouldHaveReceived('appends')->with($parameters)->once(); + } + + /** + * Assert that the [make] method creates a [Cursor] object from a [CursorPaginator]. + */ + public function testMakeMethodCreatesAFractalCursor() + { + $factory = new PaginatorFactory($parameters = ['foo' => 1]); + $paginator = Mockery::mock(CursorPaginator::class); + $paginator->shouldReceive('cursor')->andReturn($current = 2); + $paginator->shouldReceive('previous')->andReturn($previous = 1); + $paginator->shouldReceive('next')->andReturn($next = 3); + $paginator->shouldReceive('get')->andReturn($collection = Collection::make([1, 2, 3])); + + $result = $factory->makeCursor($paginator); + + $this->assertInstanceOf(CursorInterface::class, $result); + $this->assertEquals($current, $result->getCurrent()); + $this->assertEquals($previous, $result->getPrev()); + $this->assertEquals($next, $result->getNext()); + $this->assertEquals(3, $result->getCount()); + } +} \ No newline at end of file diff --git a/tests/Unit/ResourceFactoryTest.php b/tests/Unit/ResourceFactoryTest.php deleted file mode 100644 index 6a21b5f..0000000 --- a/tests/Unit/ResourceFactoryTest.php +++ /dev/null @@ -1,282 +0,0 @@ - - * @license The MIT License - */ -class ResourceFactoryTest extends TestCase -{ - /** - * Test that the [make] method returns a [\League\Fractal\Resource\NullResouce] instance - * when no data is given. - * - * @test - */ - public function makeMethodShouldReturnNullResourceWhenGivenNull() - { - // Arrange... - $data = null; - - // Act... - $resource = (new ResourceFactory())->make($data); - - // Assert... - $this->assertInstanceOf(NullResource::class, $resource); - } - - /** - * Test that the [make] method returns a [\League\Fractal\Resource\Item] instance when - * you pass in a single model. - * - * @test - */ - public function makeMethodShouldReturnItemResourceWhenGivenModel() - { - // Arrange... - $data = $this->makeModel(); - - // Act... - $resource = (new ResourceFactory())->make($data); - - // Assert... - $this->assertInstanceOf(Item::class, $resource); - $this->assertEquals($data, $resource->getData()); - } - - /** - * Test that the [make] method returns a [\League\Fractal\Resource\Collection] instance - * when you pass in an array. - * - * @test - */ - public function makeMethodShouldReturnCollectionResourceWhenGivenArray() - { - // Arrange... - $data = [$this->makeModel()]; - - // Act... - $resource = (new ResourceFactory())->make($data); - - // Assert... - $this->assertInstanceOf(Collection::class, $resource); - $this->assertEquals($data, $resource->getData()); - } - - /** - * Test that the [make] method returns a [\League\Fractal\Resource\NullResource] instance - * when you pass in an empty array. - * - * @test - */ - public function makeMethodShouldReturnNullResourceWhenGivenEmptyArray() - { - // Arrange... - $data = []; - - // Act... - $resource = (new ResourceFactory())->make($data); - - // Assert... - $this->assertInstanceOf(NullResource::class, $resource); - } - - /** - * Test that the [make] method returns a [\League\Fractal\Resource\Collection] instance - * when you pass in an Illuminate collection. - * - * @test - */ - public function makeMethodShouldReturnCollectionResourceWhenGivenCollection() - { - // Arrange... - $data = collect([$this->makeModel()]); - - // Act... - $resource = (new ResourceFactory())->make($data); - - // Assert... - $this->assertInstanceOf(Collection::class, $resource); - $this->assertEquals($data->all(), $resource->getData()); - } - - /** - * Test that the [make] method returns a [\League\Fractal\Resource\NullResource] instance - * when you pass in an empty Illuminate collection. - * - * @test - */ - public function makeMethodShouldReturnNullResourceWhenGivenEmptyCollection() - { - // Arrange... - $data = collect([]); - - // Act... - $resource = (new ResourceFactory())->make($data); - - // Assert... - $this->assertInstanceOf(NullResource::class, $resource); - } - - /** - * Test that the [make] method returns a [\League\Fractal\Resource\Collection] instance - * when you pass in an Eloquent query builder. - * - * @test - */ - public function makeMethodShouldReturnCollectionResourceWhenGivenQueryBuilder() - { - // Arrange... - $data = $this->mockBuilder([$this->makeModel()]); - - // Act... - $resource = (new ResourceFactory())->make($data); - - // Assert... - $this->assertInstanceOf(Collection::class, $resource); - $this->assertEquals($data->get()->all(), $resource->getData()); - } - - /** - * Test that the [make] method returns a [\League\Fractal\Resource\NullResouce] instance - * when you pass in an Eloquent query builder which gives no results. - * - * @test - */ - public function makeMethodShouldReturnNullResourceWhenGivenEmptyQueryBuilder() - { - // Arrange... - $data = $this->mockBuilder([]); - - // Act... - $resource = (new ResourceFactory())->make($data); - - // Assert... - $this->assertInstanceOf(NullResource::class, $resource); - } - - /** - * Test that the [make] method returns a [\League\Fractal\Resource\Collection] instance - * when you pass in an instance of [\Illuminate\Pagination\LengthAwarePaginator]. - * - * @test - */ - public function makeMethodShouldReturnCollectionResourceWhenGivenPaginator() - { - // Arrange... - $builder = $this->mockBuilderWithPaginator([$this->makeModel()]); - $data = $builder->paginate(); - - // Act... - $resource = (new ResourceFactory())->make($data); - - // Assert... - $this->assertInstanceOf(Collection::class, $resource); - $this->assertEquals($data->getCollection()->all(), $resource->getData()); - $this->assertEquals(new IlluminatePaginatorAdapter($data), $resource->getPaginator()); - } - - /** - * Test that the [make] method returns a [\League\Fractal\Resource\NullResource] instance - * when you pass in an instance of [\Illuminate\Pagination\LengthAwarePaginator] with - * no data. - * - * @test - */ - public function makeMethodShouldReturnNullResourceWhenGivenEmptyPaginator() - { - // Arrange... - $builder = $this->mockBuilderWithPaginator([]); - $data = $builder->paginate(); - - // Act... - $resource = (new ResourceFactory())->make($data); - - // Assert... - $this->assertInstanceOf(NullResource::class, $resource); - } - - /** - * Test that the [make] method returns a [\League\Fractal\Resource\Collection] instance - * when you pass in an instance of [\Illuminate\Database\Eloquent\Relations\Relation]. - * - * @test - */ - public function makeMethodShouldReturnCollectionResourceWhenGivenRelation() - { - // Arrange... - $data = $this->mockRelation([$this->makeModel()]); - - // Act... - $resource = (new ResourceFactory())->make($data); - - // Assert... - $this->assertInstanceOf(Collection::class, $resource); - $this->assertEquals($data->get()->all(), $resource->getData()); - } - - /** - * Test that the [make] method returns a [\League\Fractal\Resource\NullResource] instance - * when you pass in an instance of [\Illuminate\Database\Eloquent\Relations\Relation] - * which contains no data. - * - * @test - */ - public function makeMethodShouldReturnNullResourceWhenGivenEmptyRelation() - { - // Arrange... - $data = $this->mockRelation(null); - - // Act... - $resource = (new ResourceFactory())->make($data); - - // Assert... - $this->assertInstanceOf(NullResource::class, $resource); - } - - /** - * Test that the [make] method returns a [\League\Fractal\Resource\Item] instance when - * you pass in an instance of [\Illuminate\Database\Eloquent\Relations\Pivot]. - * - * @test - */ - public function makeMethodShouldReturnCollectionResourceWhenGivenPivot() - { - // Arrange... - $data = $this->mockPivot(); - - // Act... - $resource = (new ResourceFactory())->make($data); - - // Assert... - $this->assertInstanceOf(Item::class, $resource); - $this->assertEquals($data, $resource->getData()); - } - - /** - * Test that the [make] method throws an [\InvalidArgumentException] when passing in - * data of unsupported data type. - * - * @test - */ - public function makeMethodShouldThrowExceptionWhenGivenInvalidData() - { - // Arrange... - $this->expectException(InvalidArgumentException::class); - - // Act... - (new ResourceFactory())->make('foo'); - } -} \ No newline at end of file diff --git a/tests/Unit/Resources/DataNormalizerTest.php b/tests/Unit/Resources/DataNormalizerTest.php new file mode 100644 index 0000000..f94c429 --- /dev/null +++ b/tests/Unit/Resources/DataNormalizerTest.php @@ -0,0 +1,119 @@ + + * @license The MIT License + */ +class DataNormalizerTest extends TestCase +{ + /** + * The [DataNormalizer] class being tested. + * + * @var \Flugg\Responder\Resources\ResourceFactory + */ + protected $normalizer; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->normalizer = new DataNormalizer; + } + + /** + * Assert the the [normalize] method converts query builder instances to collections. + */ + public function testNormalizeMethodShouldConvertQueryBuildersToCollections() + { + $builder = Mockery::mock(Builder::class); + $builder->shouldReceive('get')->andReturn($collection = new Collection); + + $data = $this->normalizer->normalize($builder); + + $this->assertSame($collection, $data); + } + + /** + * Assert the the [normalize] method converts paginator instances to collections. + */ + public function testNormalizeMethodShouldConvertPaginatorsToCollections() + { + $paginator = Mockery::mock(Paginator::class); + $paginator->shouldReceive('getCollection')->andReturn($collection = new Collection); + + $data = $this->normalizer->normalize($paginator); + + $this->assertSame($collection, $data); + } + + /** + * Assert the the [normalize] method converts cursor paginator instances to collections. + */ + public function testNormalizeMethodShouldConvertCursorPaginatorsToCollections() + { + $paginator = Mockery::mock(CursorPaginator::class); + $paginator->shouldReceive('get')->andReturn($collection = new Collection); + + $data = $this->normalizer->normalize($paginator); + + $this->assertSame($collection, $data); + } + + /** + * Assert the the [normalize] method converts relationship instances to collections. + */ + public function testNormalizeMethodShouldConvertRelationsToCollections() + { + $relation = Mockery::mock(HasMany::class); + $relation->shouldReceive('get')->andReturn($collection = new Collection); + + $data = $this->normalizer->normalize($relation); + + $this->assertSame($collection, $data); + } + + /** + * Assert the the [normalize] method converts singular relationship instances to models. + */ + public function testNormalizeMethodShouldConvertSingularRelationsToModels() + { + $relation = Mockery::mock(HasOne::class); + $relation->shouldReceive('first')->andReturn($model = Mockery::mock(Model::class)); + + $data = $this->normalizer->normalize($relation); + + $this->assertSame($model, $data); + } + + /** + * Assert that the [normalize] methods leaves other data types untouched. + */ + public function testNormalizeMethodShouldReturnDataDirectlyIfUnknownType() + { + $data = $this->normalizer->normalize($array = ['foo' => 123]); + + $this->assertEquals($array, $data); + } +} \ No newline at end of file diff --git a/tests/Unit/Resources/ResourceFactoryTest.php b/tests/Unit/Resources/ResourceFactoryTest.php new file mode 100644 index 0000000..0a1a321 --- /dev/null +++ b/tests/Unit/Resources/ResourceFactoryTest.php @@ -0,0 +1,154 @@ + + * @license The MIT License + */ +class ResourceFactoryTest extends TestCase +{ + /** + * A mock of a [DataNormalizer] class. + * + * @var \Mockery\MockInterface + */ + protected $normalizer; + + /** + * A mock of a [TransformerResolver] class. + * + * @var \Mockery\MockInterface + */ + protected $transformerResolver; + + /** + * The [ResourceFactory] class being tested. + * + * @var \Flugg\Responder\Resources\ResourceFactory + */ + protected $factory; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->normalizer = Mockery::mock(DataNormalizer::class); + $this->transformerResolver = Mockery::mock(TransformerResolver::class); + $this->factory = new ResourceFactory($this->normalizer, $this->transformerResolver); + } + + /** + * Assert that the [make] method makes a [NullResource] resource when given no arguments. + */ + public function testMakeMethodShouldMakeNullResourcesWhenGivenNoArguments() + { + $this->normalizer->shouldReceive('normalize')->andReturn(null); + + $resource = $this->factory->make(); + + $this->assertInstanceOf(NullResource::class, $resource); + $this->normalizer->shouldHaveReceived('normalize')->with(null); + } + + /** + * Assert that the [make] method makes an [Item] resource when given a model. + */ + public function testMakeMethodShouldMakeItemResourcesWhenGivenModels() + { + $this->transformerResolver->shouldReceive('resolve')->andReturn($transformer = $this->mockTransformer()); + $this->normalizer->shouldReceive('normalize')->andReturn($data = Mockery::mock(Model::class)); + + $resource = $this->factory->make($data, $transformer, $resourceKey = 'bar'); + + $this->assertInstanceOf(Item::class, $resource); + $this->assertEquals($data, $resource->getData()); + $this->assertSame($transformer, $resource->getTransformer()); + $this->assertEquals($resourceKey, $resource->getResourceKey()); + $this->normalizer->shouldHaveReceived('normalize')->with($data)->once(); + $this->transformerResolver->shouldHaveReceived('resolve')->with($transformer)->once(); + } + + /** + * Assert that the [make] method makes a [Collection] resource when given arrays + * containing arrays or objects. + */ + public function testMakeMethodShouldMakeCollectionResourcesWhenGivenArraysWithNonScalars() + { + $this->transformerResolver->shouldReceive('resolve')->andReturn($transformer = $this->mockTransformer()); + $this->normalizer->shouldReceive('normalize')->andReturn($data = [ + 'foo' => ['foo' => 1], + 'bar' => ['bar' => 2], + ]); + + $resource = $this->factory->make($data, $transformer, $resourceKey = 'bar'); + + $this->assertInstanceOf(Collection::class, $resource); + $this->assertEquals($data, $resource->getData()); + $this->assertSame($transformer, $resource->getTransformer()); + $this->assertEquals($resourceKey, $resource->getResourceKey()); + $this->normalizer->shouldHaveReceived('normalize')->with($data)->once(); + $this->transformerResolver->shouldHaveReceived('resolve')->with($transformer)->once(); + } + + /** + * Assert that the [make] method makes a [Item] resource when given an array. + */ + public function testMakeMethodShouldMakeItemResourcesWhenGivenArraysWithScalars() + { + $this->transformerResolver->shouldReceive('resolve')->andReturn($transformer = $this->mockTransformer()); + $this->normalizer->shouldReceive('normalize')->andReturn($data = ['foo' => 1, 'bar' => 2]); + + $resource = $this->factory->make($data, $transformer, $resourceKey = 'bar'); + + $this->assertInstanceOf(Item::class, $resource); + } + + /** + * Assert that the [make] method resolves a transformer using the [TransformerResolver] + * if no transformer is given. + */ + public function testMakeMethodResolvesTransformerWhenNotGivenOne() + { + $this->transformerResolver->shouldReceive('resolveFromData')->andReturn($transformer = $this->mockTransformer()); + $this->normalizer->shouldReceive('normalize')->andReturn($data = Mockery::mock(Model::class)); + + $this->factory->make($data); + + $this->transformerResolver->shouldHaveReceived('resolveFromData')->with($data)->once(); + } + + /** + * Assert that the [make] method allows instances of [ResourceInterface] as data. + */ + public function testMakeMethodShouldAllowResources() + { + $this->transformerResolver->shouldReceive('resolveFromData')->andReturn($transformer = $this->mockTransformer()); + + $resource = $this->factory->make(new Item($data = Mockery::mock(Model::class))); + + $this->assertInstanceOf(Item::class, $resource); + $this->assertSame($transformer, $resource->getTransformer()); + $this->transformerResolver->shouldHaveReceived('resolveFromData')->with($data)->once(); + } +} \ No newline at end of file diff --git a/tests/Unit/ResponderTest.php b/tests/Unit/ResponderTest.php index e52449b..f80d9c9 100644 --- a/tests/Unit/ResponderTest.php +++ b/tests/Unit/ResponderTest.php @@ -2,13 +2,11 @@ namespace Flugg\Responder\Tests\Unit; -use Flugg\Responder\Http\SuccessResponseBuilder; use Flugg\Responder\Responder; use Flugg\Responder\Tests\TestCase; -use Illuminate\Http\JsonResponse; /** - * Collection of unit tests testing [\Flugg\Responder\Responder]. + * Unit tests for the [Flugg\Responder\Responder] class. * * @package flugger/laravel-responder * @author Alexander Tømmerås @@ -17,134 +15,63 @@ class ResponderTest extends TestCase { /** - * Test that you can resolve an instance of [\Flugg\Responder\Responder] from the service - * container. + * A mock of a [SuccessResponseBuilder] class. * - * @test + * @var \Mockery\MockInterface */ - public function youCanResolveResponderFromTheContainer() - { - // Act... - $manager = $this->app->make('responder'); - - // Assert... - $this->assertInstanceOf(Responder::class, $manager); - } + protected $successResponseBuilder; /** + * A mock of an [ErrorResponseBuilder] class. * - * - * @test + * @var \Mockery\MockInterface */ - public function successMethodShouldReturnAJsonResponse() - { - // Arrange... - $responder = $this->app->make('responder'); - - // Act... - $response = $responder->success(); - - // Assert... - $this->assertInstanceOf(JsonResponse::class, $response); - } - - /** - * - * - * @test - */ - public function successMethodShouldCallOnTheSuccessResponseBuilder() - { - // Arrange... - $responseBuilder = $this->mockSuccessBuilder(); - $responder = $this->app->make('responder'); - $data = $this->makeModel(); - $meta = ['foo' => true]; - - // Act... - $responder->success($data, 201, $meta); - - // Assert... - $responseBuilder->shouldHaveReceived('transform')->with($data)->once(); - $responseBuilder->shouldHaveReceived('addMeta')->with($meta)->once(); - $responseBuilder->shouldHaveReceived('respond')->with(201)->once(); - } + protected $errorResponseBuilder; /** + * The [Responder] service class being tested. * - * - * @test + * @var \Flugg\Responder\Responder */ - public function successMethodShouldAllowSkippingStatusCodeParameter() - { - // Arrange... - $responseBuilder = $this->mockSuccessBuilder(); - $responder = $this->app->make('responder'); - $data = $this->makeModel(); - $meta = ['foo' => true]; - - // Act... - $responder->success($data, $meta); - - // Assert... - $responseBuilder->shouldHaveReceived('transform')->with($data)->once(); - $responseBuilder->shouldHaveReceived('addMeta')->with($meta)->once(); - } + protected $responder; /** + * Setup the test environment. * - * - * @test + * @return void */ - public function successMethodShouldAllowSkippingDataParameter() + public function setUp() { - // Arrange... - $responseBuilder = $this->mockSuccessBuilder(); - $responder = $this->app->make('responder'); - $meta = ['foo' => true]; + parent::setUp(); - // Act... - $responder->success(201, $meta); - - // Assert... - $responseBuilder->shouldHaveReceived('addMeta')->with($meta)->once(); - $responseBuilder->shouldHaveReceived('respond')->with(201)->once(); + $this->successResponseBuilder = $this->mockSuccessResponseBuilder(); + $this->errorResponseBuilder = $this->mockErrorResponseBuilder(); + $this->responder = new Responder($this->successResponseBuilder, $this->errorResponseBuilder); } /** - * - * - * @test + * Assert that the parameters sent to the [success] method is forwarded to the success + * response builder. */ - public function transformMethodShouldReturnASuccessResponseBuilder() + public function testSuccessMethodShouldCallOnSuccessResponseBuilder() { - // Arrange... - $responder = $this->app->make('responder'); - - // Act... - $response = $responder->transform(); + $result = $this->responder->success($data = ['foo' => 1], $transformer = $this->mockTransformer(), $resourceKey = 'foo'); - // Assert... - $this->assertInstanceOf(SuccessResponseBuilder::class, $response); + $this->assertSame($this->successResponseBuilder, $result); + $this->successResponseBuilder->shouldHaveReceived('transform')->with($data, $transformer, $resourceKey)->once(); } /** - * - * - * @test + * Assert that the parameters sent to the [error] method is forwarded to the error + * response builder. */ - public function transformMethodShouldCallOnTheSuccessResponseBuilder() + public function testErrorMethodShouldCallOnErrorResponseBuilder() { - // Arrange... - $responseBuilder = $this->mockSuccessBuilder(); - $responder = $this->app->make('responder'); - $data = $this->makeModel(); - $transformer = function () { }; - - // Act... - $responder->transform($data, $transformer); + $error = 'error_occured'; + $message = 'An error has occured.'; + $result = $this->responder->error($error, $message); - // Assert... - $responseBuilder->shouldHaveReceived('transform')->with($data, $transformer)->once(); + $this->assertSame($this->errorResponseBuilder, $result); + $this->errorResponseBuilder->shouldHaveReceived('error')->with($error, $message)->once(); } -} \ No newline at end of file +} diff --git a/tests/Unit/Serializers/ErrorSerializerTest.php b/tests/Unit/Serializers/ErrorSerializerTest.php new file mode 100644 index 0000000..98608d6 --- /dev/null +++ b/tests/Unit/Serializers/ErrorSerializerTest.php @@ -0,0 +1,51 @@ + + * @license The MIT License + */ +class ErrorSerializerTest extends TestCase +{ + /** + * The [ErrorSerializer] class being tested. + * + * @var \Flugg\Responder\Serializers\ErrorSerializer + */ + protected $serializer; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->serializer = new ErrorSerializer(); + } + + /** + * Assert that the [format] method serializes the error data. + */ + public function testFormatMethodSerializesErrorData() + { + $formattedData = $this->serializer->format($code = 'test_error', $message = 'A test error has occured.', $data = ['foo' => 1]); + + $this->assertEquals([ + 'error' => [ + 'code' => $code, + 'message' => $message, + 'data' => $data, + ], + ], $formattedData); + } +} \ No newline at end of file diff --git a/tests/Unit/Serializers/NullSerializerTest.php b/tests/Unit/Serializers/NullSerializerTest.php new file mode 100644 index 0000000..16274e2 --- /dev/null +++ b/tests/Unit/Serializers/NullSerializerTest.php @@ -0,0 +1,108 @@ + + * @license The MIT License + */ +class NullSerializerTest extends TestCase +{ + /** + * The [NullSerializer] class being tested. + * + * @var \Flugg\Responder\Serializers\NullSerializer + */ + protected $serializer; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->serializer = new NullSerializer; + } + + /** + * Assert that the [collection] method returns the given data untouched. + */ + public function testCollectionMethodShouldReturnDataDirectly() + { + $result = $this->serializer->collection(null, $data = ['foo' => 1]); + + $this->assertEquals($data, $result); + } + + /** + * Assert that the [item] method returns the given data untouched. + */ + public function testItemMethodShouldReturnDataDirectly() + { + $result = $this->serializer->item(null, $data = ['foo' => 1]); + + $this->assertEquals($data, $result); + } + + /** + * Assert that the [null] method returns an empty array. + */ + public function testNullMethodShouldReturnAnEmptyArray() + { + $result = $this->serializer->null(); + + $this->assertEquals([], $result); + } + + /** + * Assert that the [meta] method returns an empty array. + */ + public function testMetaMethodShouldReturnAnEmptyArray() + { + $result = $this->serializer->meta($meta = ['foo' => 1]); + + $this->assertEquals([], $result); + } + + /** + * Assert that the [paginator] method returns an empty array. + */ + public function testPaginatorMethodShouldReturnAnEmptyArray() + { + $result = $this->serializer->paginator($paginator = Mockery::mock(PaginatorInterface::class)); + + $this->assertEquals([], $result); + } + + /** + * Assert that the [cursor] method returns an empty array. + */ + public function testCursorMethodShouldReturnAnEmptyArray() + { + $result = $this->serializer->cursor($cursor = Mockery::mock(CursorInterface::class)); + + $this->assertEquals([], $result); + } + + /** + * Assert that the [mergeIncludes] method merges relations. + */ + public function testMergeIncludesMethodShouldMergeRelations() + { + $result = $this->serializer->mergeIncludes($data = ['foo' => 1], $relations = ['bar' => 2]); + + $this->assertEquals(['foo' => 1, 'bar' => 2], $result); + } +} \ No newline at end of file diff --git a/tests/Unit/Serializers/SuccessSerializerTest.php b/tests/Unit/Serializers/SuccessSerializerTest.php new file mode 100644 index 0000000..d7332f0 --- /dev/null +++ b/tests/Unit/Serializers/SuccessSerializerTest.php @@ -0,0 +1,161 @@ + + * @license The MIT License + */ +class SuccessSerializerTest extends TestCase +{ + /** + * The [SuccessSerializer] class being tested. + * + * @var \Flugg\Responder\Serializers\SuccessSerializer + */ + protected $serializer; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->serializer = new SuccessSerializer(); + } + + /** + * Assert that the [collection] method wraps the data in a [data] field. + */ + public function testCollectionMethodShouldWrapDataInADataField() + { + $result = $this->serializer->collection(null, $data = ['foo' => 1]); + + $this->assertEquals(['data' => $data], $result); + } + + /** + * Assert that the [item] method wraps the data in a [data] field. + */ + public function testItemMethodShouldWrapDataInADataField() + { + $result = $this->serializer->item(null, $data = ['foo' => 1]); + + $this->assertEquals(['data' => $data], $result); + } + + /** + * Assert that the [null] method wraps [null] in a [data] field. + */ + public function testNullMethodShouldWrapNullInADataField() + { + $result = $this->serializer->null(); + + $this->assertEquals(['data' => null], $result); + } + + /** + * Assert that the [meta] method returns the given data untouched. + */ + public function testMetaMethodShouldReturnDataDirectly() + { + $result = $this->serializer->meta($meta = ['foo' => 1]); + + $this->assertEquals($meta, $result); + } + + /** + * Assert that the [paginator] method returns a formatted pagination meta data. + */ + public function testPaginatorMethodShouldReturnAFormattedArray() + { + $paginator = Mockery::mock(PaginatorInterface::class); + $paginator->shouldReceive('getTotal')->andReturn($total = 15); + $paginator->shouldReceive('getCount')->andReturn($count = 10); + $paginator->shouldReceive('getPerPage')->andReturn($perPage = 5); + $paginator->shouldReceive('getCurrentPage')->andReturn($currentPage = 2); + $paginator->shouldReceive('getLastPage')->andReturn($lastPage = 3); + $paginator->shouldReceive('getUrl')->with(1)->andReturn($previousUrl = 'foo.com/1'); + $paginator->shouldReceive('getUrl')->with(3)->andReturn($nextUrl = 'foo.com/3'); + $result = $this->serializer->paginator($paginator); + + $this->assertEquals([ + 'pagination' => [ + 'total' => $total, + 'count' => $count, + 'perPage' => $perPage, + 'currentPage' => $currentPage, + 'totalPages' => $lastPage, + 'links' => [ + 'previous' => $previousUrl, + 'next' => $nextUrl, + ], + ], + ], $result); + } + + /** + * Assert that the [paginator] method returns a formatted cursor meta data. + */ + public function testCursorMethodShouldReturnAFormattedArray() + { + $cursor = Mockery::mock(CursorInterface::class); + $cursor->shouldReceive('getPrev')->andReturn($previous = 1); + $cursor->shouldReceive('getCurrent')->andReturn($current = 2); + $cursor->shouldReceive('getNext')->andReturn($next = 3); + $cursor->shouldReceive('getCount')->andReturn($count = 2); + $result = $this->serializer->cursor($cursor); + + $this->assertEquals([ + 'cursor' => [ + 'current' => $current, + 'previous' => $previous, + 'next' => $next, + 'count' => $count, + ], + ], $result); + } + + /** + * Assert that the [sideloadIncludes] method returns true. + */ + public function testSideloadIncludesMethodShouldReturnTrue() + { + $result = $this->serializer->sideloadIncludes(); + + $this->assertTrue($result); + } + + /** + * Assert that the [mergeIncludes] method merges relations and strips away extra data fields. + */ + public function testMergeIncludesMethodShouldMergeRelationsAndStripDataFields() + { + $result = $this->serializer->mergeIncludes($data = ['foo' => 1], $relations = ['bar' => ['data' => 2]]); + + $this->assertEquals(['foo' => 1, 'bar' => 2], $result); + } + + /** + * Assert that the [includedData] method returns an empty array. + */ + public function testIncludedDataMethodShouldReturnAnEmptyArray() + { + $result = $this->serializer->includedData($resource = new Item, ['foo' => 1]); + + $this->assertEquals([], $result); + } +} \ No newline at end of file diff --git a/tests/Unit/SuccessResponseBuilderTest.php b/tests/Unit/SuccessResponseBuilderTest.php deleted file mode 100644 index d281d6a..0000000 --- a/tests/Unit/SuccessResponseBuilderTest.php +++ /dev/null @@ -1,767 +0,0 @@ - - * @license The MIT License - */ -class SuccessResponseBuilderTest extends TestCase -{ - /** - * Test that you can resolve an instance of [\League\Fractal\Manager] from the service - * container. - * - * @test - */ - public function youCanResolveAManagerFromTheContainer() - { - // Act... - $manager = $this->app->make('responder.manager'); - - // Assert... - $this->assertInstanceOf(Manager::class, $manager); - } - - /** - * Test that you can resolve an instance of [\Flugg\Responder\SuccessResponseBuilder] - * from the service container. - * - * @test - */ - public function youCanResolveASuccessResponseBuilderFromTheContainer() - { - // Act... - $responseBuilder = $this->app->make('responder.success'); - - // Assert... - $this->assertInstanceOf(SuccessResponseBuilder::class, $responseBuilder); - } - - /** - * Test that you can get an instance of [\League\Fractal\Manager] from the response - * builder. - * - * @test - */ - public function youCanGetTheManagerInstance() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $manager = $responseBuilder->getManager(); - - // Assert... - $this->assertInstanceOf(Manager::class, $manager); - } - - /** - * Test that a serializer is set to [\Flugg\Responder\Serializers\ApiSerializer] when - * you leave the configuration to the defaults. - * - * @test - */ - public function itShouldUsePackageSerializerByDefault() - { - // Act... - $responseBuilder = $this->app->make('responder.success'); - - // Assert... - $this->assertInstanceOf(ApiSerializer::class, $responseBuilder->getManager()->getSerializer()); - } - - /** - * Test that you can change serializer by changing the [serializer] key in the package - * configuration file. - * - * @test - */ - public function youCanChangeSerializerInTheConfig() - { - // Arrange... - $this->app['config']->set('responder.serializer', JsonApiSerializer::class); - - // Act... - $responseBuilder = $this->app->make('responder.success'); - - // Assert... - $this->assertInstanceOf(JsonApiSerializer::class, $responseBuilder->getManager()->getSerializer()); - } - - /** - * Test that you can get an instance of [\League\Fractal\Resource\ResourceAbstract] - * from the response builder. - * - * @test - */ - public function youCanGetTheResourceInstance() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $resource = $responseBuilder->getResource(); - - // Assert... - $this->assertInstanceOf(ResourceAbstract::class, $resource); - } - - /** - * Test that a new instance of [\League\Fractal\Resource\NullResource] is created when - * the response builder is instantiated. - * - * @test - */ - public function itShouldCreateANullResourceByDefault() - { - // Arrange... - $resourceFactory = $this->mockResourceFactory(new NullResource); - - // Act... - $responseBuilder = $this->app->make('responder.success'); - - // Assert... - $this->assertInstanceOf(NullResource::class, $responseBuilder->getResource()); - $resourceFactory->shouldHaveReceived('make')->withNoArgs()->once(); - } - - /** - * Test that the resource instance is set to [\League\Fractal\Resource\NullResource] - * when given no data to the [transform] method. - * - * @test - */ - public function transformMethodShouldSetResourceToNullResourceWhenGivenNoData() - { - // Arrange... - $resourceFactory = $this->mockResourceFactory(new NullResource); - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $responseBuilder->transform(); - - // Assert... - $this->assertInstanceOf(NullResource::class, $responseBuilder->getResource()); - $resourceFactory->shouldHaveReceived('make')->with(null)->once(); - } - - /** - * Test that the Fractal resource instance on the response is updated when calling the - * [transform] method using the resource factory. - * - * @test - */ - public function transformMethodShouldResolveResourceFromData() - { - // Arrange... - $resourceFactory = $this->mockResourceFactory(new Item); - $responseBuilder = $this->app->make('responder.success'); - $model = $this->makeModel(); - - // Act... - $responseBuilder->transform($model); - - // Assert... - $this->assertInstanceOf(Item::class, $responseBuilder->getResource()); - $resourceFactory->shouldHaveReceived('make')->with($model)->once(); - } - - /** - * Test that the [transform] method throws an [\InvalidArgumentException] when the given - * data doesn't contain, or is itself, an Eloquent model. - * - * @test - */ - public function transformMethodShouldFailIfNoModelIsFound() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $this->expectException(InvalidArgumentException::class); - - // Act... - $responseBuilder->transform('foo'); - } - - /** - * Test that the [transform] method resolves a transformer from the model resolved from - * the given data. - * - * @test - */ - public function transformMethodShouldResolveTransformerFromModel() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $model = $this->makeModelWithTransformer($this->makeTransformer()); - - // Act... - $responseBuilder->transform($model); - - // Assert... - $this->assertInstanceOf(Transformer::class, $responseBuilder->getResource()->getTransformer()); - } - - /** - * Test that the [transform] method can resolve a transformer from the model, when the - * [transformer] method returns a class name string. - * - * @test - */ - public function transformMethodShouldResolveTransformerUsingClassName() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $model = $this->makeModelWithTransformer(get_class($this->makeTransformer())); - - // Act... - $responseBuilder->transform($model); - - // Assert... - $this->assertInstanceOf(Transformer::class, $responseBuilder->getResource()->getTransformer()); - } - - /** - * Test that the [transform] method creates a closure transformer substitute from the - * model's fillable array. - * - * @test - */ - public function transformMethodShouldCreateClosureIfNoTransformerIsFound() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $model = $this->makeModel(); - - // Act... - $responseBuilder->transform($model); - - // Assert... - $this->assertInstanceOf(Closure::class, $responseBuilder->getResource()->getTransformer()); - } - - /** - * Test that a transformer can be set explicitly on the response by passing a second - * argument to the [transform] method. - * - * @test - */ - public function transformMethodShouldAllowSettingATransformer() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $model = $this->makeModel(); - $transformer = $this->makeTransformer(); - - // Act... - $responseBuilder->transform($model, $transformer); - - // Assert... - $this->assertSame($transformer, $responseBuilder->getResource()->getTransformer()); - } - - /** - * Test that you can use a class name string to set the transformer on the [transform] - * method. - * - * @test - */ - public function transformMethodShouldAllowSettingATransformerUsingClassName() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $model = $this->makeModel(); - $transformer = get_class($this->makeTransformer()); - - // Act... - $responseBuilder->transform($model, $transformer); - - // Assert... - $this->assertInstanceOf($transformer, $responseBuilder->getResource()->getTransformer()); - } - - /** - * Test that you can also use an anonymous function as a transformer instead of a full - * blown transformer class when using the [transform] method. - * - * @test - */ - public function transformMethodShouldAllowSettingATransformerUsingClosure() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $model = $this->makeModel(); - $transformer = function () { }; - - // Act... - $responseBuilder->transform($model, $transformer); - - // Assert... - $this->assertSame($transformer, $responseBuilder->getResource()->getTransformer()); - } - - /** - * Test that the [transform] method sets the eager loaded relations from the model on - * the transformer and manager. - * - * @test - */ - public function transformMethodShouldSetRelations() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $model = $this->makeModel()->setRelation('foo', null); - $transformer = $this->makeTransformer(); - - // Act... - $responseBuilder->transform($model, $transformer); - - // Assert... - $this->assertEquals(['foo'], $responseBuilder->getResource()->getTransformer()->getRelations()); - $this->assertEquals(['foo'], $responseBuilder->getManager()->getRequestedIncludes()); - } - - /** - * Test that the [transform] method merges the eager loaded relations with relations - * set directly on the transformer. - * - * @test - */ - public function transformMethodShouldMergeRelationsWithExisting() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $model = $this->makeModel()->setRelation('foo', null); - $transformer = $this->makeTransformer()->setRelations('bar'); - - // Act... - $responseBuilder->transform($model, $transformer); - - // Assert... - $this->assertEquals(['bar', 'foo'], $responseBuilder->getResource()->getTransformer()->getRelations()); - } - - /** - * Test that the [transform] method resolves resource key from the model's table name - * if not set explicitly. - * - * @test - */ - public function transformMethodShouldResolveResourceKeyFromTableNameByDefault() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $model = $this->makeModel()->setTable('foo'); - - // Act... - $responseBuilder->transform($model); - - // Assert... - $this->assertEquals($responseBuilder->getResource()->getResourceKey(), 'foo'); - } - - /** - * Test that you can set a resource key directly on your models by taking use of the - * [getResourceKey] method. - * - * @test - */ - public function transformMethodShouldGetResourceKeyFromModelMethod() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $model = $this->makeModelWithResourceKey('foo'); - - // Act... - $responseBuilder->transform($model); - - // Assert... - $this->assertEquals($responseBuilder->getResource()->getResourceKey(), 'foo'); - } - - /** - * Test that a resource key can be set explicitly on the response by passing a third - * argument to the [transform] method. - * - * @test - */ - public function transformMethodShouldAllowSettingAResourceKey() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $model = $this->makeModel(); - $transformer = function () { }; - - // Act... - $responseBuilder->transform($model, $transformer, 'foo'); - - // Assert... - $this->assertEquals($responseBuilder->getResource()->getResourceKey(), 'foo'); - } - - /** - * Test that the [transform] method returns the response builder, allowing for fluent - * method chaining. - * - * @test - */ - public function transformMethodShouldReturnItself() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $result = $responseBuilder->transform(); - - // Assert... - $this->assertSame($responseBuilder, $result); - } - - /** - * Test that the [addMeta] method adds the meta data to the resource instance. - * - * @test - */ - public function addMetaMethodShouldAddMetaDataToResource() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $meta = ['foo' => 1]; - - // Act... - $responseBuilder->addMeta($meta); - - // Assert... - $this->assertEquals($responseBuilder->getResource()->getMeta(), $meta); - } - - /** - * Test that the [addMeta] method merges new meta data with existing meta data - * set on the resource. - * - * @test - */ - public function addMetaMethodShouldMergeMetaDataWithExisting() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $meta = ['foo' => 1]; - $moreMeta = ['bar' => 2]; - - // Act... - $responseBuilder->addMeta($meta)->addMeta($moreMeta); - - // Assert... - $this->assertEquals($responseBuilder->getResource()->getMeta(), array_merge($meta, $moreMeta)); - } - - /** - * Test that the [addMeta] method returns the response builder, allowing for fluent - * method chaining. - * - * @test - */ - public function addMetaMethodShouldReturnItself() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $result = $responseBuilder->addMeta([]); - - // Assert... - $this->assertSame($responseBuilder, $result); - } - - /** - * Test that the [serializer] method sets the serializer given on the Fractal manager. - * - * @test - */ - public function serializerMethodShouldSetSerializerOnTheManager() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $responseBuilder->serializer(new JsonApiSerializer); - - // Assert... - $this->assertInstanceOf(JsonApiSerializer::class, $responseBuilder->getManager()->getSerializer()); - } - - /** - * Test that the [serializer] method allows for setting serializer using a string. - * - * @test - */ - public function serializerMethodShouldResolveSerializerFromClassName() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $responseBuilder->serializer(JsonApiSerializer::class); - - // Assert... - $this->assertInstanceOf(JsonApiSerializer::class, $responseBuilder->getManager()->getSerializer()); - } - - /** - * Test that the [serializer] method throws an [\InvalidArgumentException] when the given - * serializer is not a valid value. - * - * @test - */ - public function serializerMethodShouldFailIfSerializerIsInvalid() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $this->expectException(InvalidSerializerException::class); - - // Act... - $responseBuilder->serializer(123); - } - - /** - * Test that the [serializer] method returns the response builder, allowing for fluent - * method chaining. - * - * @test - */ - public function serializerMethodShouldReturnItself() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $result = $responseBuilder->serializer(new JsonApiSerializer); - - // Assert... - $this->assertSame($responseBuilder, $result); - } - - /** - * Test that the [respond] method converts the success response into an instance of - * [\Illuminate\Http\JsonResponse] with a default status code of 200. - * - * @test - */ - public function respondMethodShouldReturnAJsonResponse() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $response = $responseBuilder->respond(); - $responseArray = json_decode($response->content(), true); - - // Assert... - $this->assertInstanceOf(JsonResponse::class, $response); - $this->assertEquals($response->status(), 200); - $this->assertArrayHasKey('success', $responseArray); - $this->assertEquals(true, $responseArray['success']); - } - - /** - * Test that the [respond] method does not respond with success flag - * - * @test - */ - public function respondMethodShouldNotOutputSuccessFlagWhenDisabled() - { - $this->app['config']->set('responder.include_success_flag', false); - $responseBuilder = $this->app->make('responder.success'); - - $response = $responseBuilder->respond(); - $responseArray = json_decode($response->content(), true); - - $this->assertArrayNotHasKey('success', $responseArray); - } - - /** - * Test that the [respond] method allows passing a status code as the first parameter. - * - * @test - */ - public function respondMethodShouldAllowSettingStatusCode() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $response = $responseBuilder->respond(201); - - // Assert... - $this->assertEquals($response->status(), 201); - } - - /** - * Test that you can set any headers to the JSON response by passing a second argument - * to the [respond] method. - * - * @test - */ - public function respondMethodShouldAllowSettingHeaders() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $response = $responseBuilder->respond(201, [ - 'x-foo' => true - ]); - - // Assert... - $this->assertArrayHasKey('x-foo', $response->headers->all()); - } - - /** - * Test that the [setStatus] method sets the HTTP status code on the response, providing - * an alternative, more explicit way of setting the status code. - * - * @test - */ - public function setStatusMethodShouldSetStatusCode() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $responseBuilder->setStatus(201); - - // Assert... - $this->assertEquals($responseBuilder->respond()->status(), 201); - } - - /** - * Test that the [setStatus] method throws an [\InvalidArgumentException] when the status - * code given is not a valid successful HTTP status code. - * - * @test - */ - public function setStatusMethodShouldFailIfStatusCodeIsInvalid() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $this->expectException(InvalidArgumentException::class); - - // Act... - $responseBuilder->setStatus(400); - } - - /** - * Test that the [setStatus] method returns the response builder, allowing for fluent - * method chaining. - * - * @test - */ - public function setStatusMethodShouldReturnItself() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $result = $responseBuilder->setStatus(201); - - // Assert... - $this->assertSame($responseBuilder, $result); - } - - /** - * Test that the [toArray] method serializes the data given, using the default serializer - * and no data. - * - * @test - */ - public function toArrayMethodShouldSerializeData() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $array = $responseBuilder->toArray(); - - // Assert... - $this->assertEquals([ - 'data' => null - ], $array); - } - - /** - * Test that the [toArray] method also transforms the data using the set transformer. - * - * @test - */ - public function toArrayMethodShouldTransformData() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - $model = $this->makeModel(['foo' => 123]); - $responseBuilder->transform($model, function ($model) { - return ['foo' => (string) $model->foo]; - }); - - // Act... - $array = $responseBuilder->toArray(); - - // Assert... - $this->assertContains(['foo' => '123'], $array); - } - - /** - * Test that the [toCollection] serializes the data into a collection. - * - * @test - */ - public function toCollectionMethodShouldReturnACollection() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $collection = $responseBuilder->toCollection(); - - // Assert... - $this->assertEquals(collect([ - 'data' => null - ]), $collection); - } - - /** - * Test that the [toJson] serializes the data into a JSON string. - * - * @test - */ - public function toJsonMethodShouldReturnJson() - { - // Arrange... - $responseBuilder = $this->app->make('responder.success'); - - // Act... - $json = $responseBuilder->toCollection(); - - // Assert... - $this->assertEquals(json_encode([ - 'data' => null - ]), $json); - } -} \ No newline at end of file diff --git a/tests/Unit/TransformBuilderTest.php b/tests/Unit/TransformBuilderTest.php new file mode 100644 index 0000000..d6d3948 --- /dev/null +++ b/tests/Unit/TransformBuilderTest.php @@ -0,0 +1,351 @@ + + * @license The MIT License + */ +class TransformBuilderTest extends TestCase +{ + /** + * A mock of a [ResourceFactory] class. + * + * @var \Mockery\MockInterface + */ + protected $resourceFactory; + + /** + * A mock of a [TransformFactory] class. + * + * @var \Mockery\MockInterface + */ + protected $transformFactory; + + /** + * A mock of a [PaginatorFactory] class. + * + * @var \Mockery\MockInterface + */ + protected $paginatorFactory; + + /** + * A mock of a [ResourceInterface] class. + * + * @var \Mockery\MockInterface + */ + protected $resource; + + /** + * A mock of a [SerializerAbstract] class. + * + * @var \Mockery\MockInterface + */ + protected $serializer; + + /** + * The [TransformBuilder] class being tested. + * + * @var \Flugg\Responder\TransformBuilder + */ + protected $builder; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->resourceFactory = Mockery::mock(ResourceFactory::class); + $this->transformFactory = Mockery::mock(TransformFactory::class); + $this->paginatorFactory = Mockery::mock(PaginatorFactory::class); + $this->resourceFactory->shouldReceive('make')->andReturn($this->resource = $this->mockResource()); + $this->builder = new TransformBuilder($this->resourceFactory, $this->transformFactory, $this->paginatorFactory); + $this->builder->serializer($this->serializer = Mockery::mock(SuccessSerializer::class)); + } + + /** + * Assert that the [resource] method uses the [ResourceFactory] to create resources. + */ + public function testResourceMethodUsesResourceFactory() + { + $result = $this->builder->resource($data = ['foo' => 1], $transformer = $this->mockTransformer(), $resourceKey = 'foo'); + + $this->assertSame($this->builder, $result); + $this->resourceFactory->shouldHaveReceived('make')->with($data, $transformer, $resourceKey)->once(); + } + + /** + * Assert that the [resource] method sets cursor on the resource if data is an instance + * of [CursorPaginator]. + */ + public function testResourceMethodSetsCursorOnResource() + { + $cursor = Mockery::mock(Cursor::class); + $this->paginatorFactory->shouldReceive('makeCursor')->andReturn($cursor); + + $this->builder->resource($data = Mockery::mock(CursorPaginator::class)); + + $this->resource->shouldHaveReceived('setCursor')->with($cursor)->once(); + } + + /** + * Assert that the [resource] method sets paginator on the resource if data is an instance + * of [LengthAwarePaginator]. + */ + public function testResourceMethodSetsPagintorOnResource() + { + $paginator = Mockery::mock(IlluminatePaginatorAdapter::class); + $this->paginatorFactory->shouldReceive('make')->andReturn($paginator); + + $this->builder->resource($data = Mockery::mock(LengthAwarePaginator::class)); + + $this->resource->shouldHaveReceived('setPaginator')->with($paginator)->once(); + } + + /** + * Assert that the [cursor] method allows manually setting cursor on resource. + */ + public function testCursorMethodSetsCursorsOnResource() + { + $cursor = Mockery::mock(Cursor::class); + $this->paginatorFactory->shouldReceive('makeCursor')->andReturn($cursor); + + $this->builder->resource()->cursor($cursor); + + $this->resource->shouldHaveReceived('setCursor')->with($cursor)->once(); + } + + /** + * Assert that the [paginator] method allows manually setting paginator on resource. + */ + public function testPaginatorMethodSetsPaginatorsOnResource() + { + $paginator = Mockery::mock(IlluminatePaginatorAdapter::class); + $this->paginatorFactory->shouldReceive('make')->andReturn($paginator); + + $this->builder->resource()->paginator($paginator); + + $this->resource->shouldHaveReceived('setPaginator')->with($paginator)->once(); + } + + /** + * Assert that the [meta] method adds meta data to the resource. + */ + public function testMetaMethodAddsMetaDataToResource() + { + $result = $this->builder->resource()->meta($meta = ['foo' => 1]); + + $this->assertSame($this->builder, $result); + $this->resource->shouldHaveReceived('setMeta')->with($meta)->once(); + } + + /** + * Assert that the [transform] method transforms data using [TransformFactory]. + */ + public function testTransformMethodUsesTransformFactoryToTransformData() + { + $this->transformFactory->shouldReceive('make')->andReturn($data = ['foo' => 123]); + + $result = $this->builder->resource()->transform(); + + $this->assertEquals($data, $result); + $this->transformFactory->shouldHaveReceived('make')->with($this->resource, $this->serializer, [ + 'includes' => [], + 'excludes' => [], + 'fieldsets' => [], + ])->once(); + } + + /** + * Assert that the [serializer] method sets the serializer that is sent to the + * [TransformFactory]. + */ + public function testSerializerMethodSetsSerializerSentToTransformFactory() + { + $this->transformFactory->shouldReceive('make')->andReturn([]); + + $this->builder->resource()->serializer($serializer = new JsonApiSerializer)->transform(); + + $this->transformFactory->shouldHaveReceived('make')->with($this->resource, $serializer, [ + 'includes' => [], + 'excludes' => [], + 'fieldsets' => [], + ])->once(); + } + + /** + * Assert that the [serializer] method allows class name strings. + */ + public function testSerializerMethodAllowsClassNameStrings() + { + $this->transformFactory->shouldReceive('make')->andReturn([]); + + $this->builder->resource()->serializer($serializer = JsonApiSerializer::class)->transform(); + + $this->transformFactory->shouldHaveReceived('make')->with($this->resource, $serializer, [ + 'includes' => [], + 'excludes' => [], + 'fieldsets' => [], + ])->once(); + } + + /** + * Assert that the [serializer] method throws [InvalidSuccessSerializerException] exception when + * given an invalid serializer. + */ + public function testSerializerMethodThrowsExceptionWhenGivenInvalidSerializer() + { + $this->expectException(InvalidSuccessSerializerException::class); + + $this->builder->serializer($serializer = stdClass::class)->transform(); + } + + /** + * Assert that the [with] method sets the included relationships that are sent to the + * [TransformFactory]. + */ + public function testWithMethodSetsIncludedRelationsSentToFactory() + { + $this->transformFactory->shouldReceive('make')->andReturn([]); + + $this->builder->resource()->with($relations = ['foo', 'bar'])->transform(); + + $this->transformFactory->shouldHaveReceived('make')->with($this->resource, $this->serializer, [ + 'includes' => $relations, + 'excludes' => [], + 'fieldsets' => [], + ])->once(); + } + + /** + * Assert that the [with] method allows to be called multiple times and accepts strings + * as parameters. + */ + public function testWithMethodAllowsMultipleCallsAndStrings() + { + $this->transformFactory->shouldReceive('make')->andReturn([]); + + $this->builder->resource()->with('foo')->with('bar', 'baz')->transform(); + + $this->transformFactory->shouldHaveReceived('make')->with($this->resource, $this->serializer, [ + 'includes' => ['foo', 'bar', 'baz'], + 'excludes' => [], + 'fieldsets' => [], + ])->once(); + } + + /** + * Assert that the [without] method sets the excluded relationships that are sent to the + * [TransformFactory]. + */ + public function testWithoutMethodSetsExcludedRelationsSentToFactory() + { + $this->transformFactory->shouldReceive('make')->andReturn([]); + + $this->builder->resource()->without($relations = ['foo', 'bar'])->transform(); + + $this->transformFactory->shouldHaveReceived('make')->with($this->resource, $this->serializer, [ + 'includes' => [], + 'excludes' => $relations, + 'fieldsets' => [], + ])->once(); + } + + /** + * Assert that the [with] method allows to be called multiple times and accepts strings + * as parameters. + */ + public function testWithoutMethodAllowsMultipleCallsAndStrings() + { + $this->transformFactory->shouldReceive('make')->andReturn([]); + + $this->builder->resource()->without('foo')->without('bar', 'baz')->transform(); + + $this->transformFactory->shouldHaveReceived('make')->with($this->resource, $this->serializer, [ + 'includes' => [], + 'excludes' => ['foo', 'bar', 'baz'], + 'fieldsets' => [], + ])->once(); + } + + /** + * Assert that the [transform] method extracts default relationships from transformer and + * automatically eager loads all relationships. + */ + public function testTransformMethodExtractsAndEagerLoadsRelations() + { + $this->transformFactory->shouldReceive('make')->andReturn([]); + $this->resource->shouldReceive('getData')->andReturn($model = Mockery::mock(Model::class)); + $model->shouldReceive('load')->andReturnSelf(); + $this->resource->shouldReceive('getTransformer')->andReturn($transformer = Mockery::mock(Transformer::class)); + $transformer->shouldReceive('defaultRelations')->andReturn($default = ['baz']); + + $this->builder->resource()->with($relations = ['foo' => function () { }, 'bar'])->transform(); + + $model->shouldHaveReceived('load')->with(array_merge($relations, $default))->once(); + $this->transformFactory->shouldHaveReceived('make')->with($this->resource, $this->serializer, [ + 'includes' => ['foo', 'bar', 'baz'], + 'excludes' => [], + 'fieldsets' => [], + ])->once(); + } + + /** + * Assert that the [only] method sets the filtered fields that are sent to the + * [TransformFactory]. + */ + public function testOnlyMethodSetsFilteredFieldsSentToFactory() + { + $this->transformFactory->shouldReceive('make')->andReturn([]); + + $this->builder->resource()->only($fields = ['foo', 'bar'])->transform(); + + $this->transformFactory->shouldHaveReceived('make')->with($this->resource, $this->serializer, [ + 'includes' => [], + 'excludes' => [], + 'fieldsets' => $fields, + ])->once(); + } + + /** + * Assert that the [only] method allows to be called multiple times and accepts strings + * as parameters. + */ + public function testOnlyMethodAllowsMultipleCallsAndStrings() + { + $this->transformFactory->shouldReceive('make')->andReturn([]); + + $this->builder->resource()->only('foo')->only('bar', 'baz')->transform(); + + $this->transformFactory->shouldHaveReceived('make')->with($this->resource, $this->serializer, [ + 'includes' => [], + 'excludes' => [], + 'fieldsets' => ['foo', 'bar', 'baz'], + ])->once(); + } +} \ No newline at end of file diff --git a/tests/Unit/Transformer.php b/tests/Unit/Transformer.php deleted file mode 100644 index 421b91e..0000000 --- a/tests/Unit/Transformer.php +++ /dev/null @@ -1,86 +0,0 @@ - - * @license The MIT License - */ -class TransformerTest extends TestCase -{ - /** - * - * - * @test - */ - public function setRelationsMethodShouldSetRelationsOnTransformer() - { - // Arrange... - $transformer = $this->makeTransformer(); - - // Act... - $transformer->setRelations(['foo', 'bar']); - - // Assert... - $this->assertEquals(['foo', 'bar'], $transformer->getAvailableIncludes()); - } - - /** - * - * - * @test - */ - public function setRelationsMethodAllowsASingleValue() - { - // Arrange... - $transformer = $this->makeTransformer(); - - // Act... - $transformer->setRelations('foo'); - - // Assert... - $this->assertEquals(['foo'], $transformer->getAvailableIncludes()); - } - - /** - * - * - * @test - */ - public function setRelationsMethodShouldMergeRelationsWithExisting() - { - // Arrange... - $transformer = $this->makeTransformer(); - $transformer->setRelations('foo'); - - // Act... - $transformer->setRelations('bar'); - - // Assert... - $this->assertEquals(['foo', 'bar'], $transformer->getAvailableIncludes()); - } - - /** - * - * - * @test - */ - public function getRelationsMethodShouldReturnAllSetIncludes() - { - // Arrange... - $transformer = $this->makeTransformer(); - $transformer->setAvailableIncludes(['foo']); - $transformer->setDefaultIncludes(['bar']); - - // Act... - $relations = $transformer->getRelations(); - - // Assert... - $this->assertEquals(['foo', 'bar'], $relations); - } -} \ No newline at end of file diff --git a/tests/Unit/TransformerTest.php b/tests/Unit/TransformerTest.php new file mode 100644 index 0000000..6647fa9 --- /dev/null +++ b/tests/Unit/TransformerTest.php @@ -0,0 +1,61 @@ + + * @license The MIT License + */ +class TransformerTest extends TestCase +{ + /** + * A mock of a [TransformBuilder] class. + * + * @var \Mockery\MockInterface + */ + protected $transformBuilder; + + /** + * The [Transformer] service class being tested. + * + * @var \Flugg\Responder\Transformer + */ + protected $transformer; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->transformBuilder = $this->mockTransformBuilder(); + $this->transformer = new Transformer($this->transformBuilder); + } + + /** + * Assert that the parameters sent to the [transform] method is forwarded to the + * transform builder. + */ + public function testTransformMethodShouldCallOnTransformBuilder() + { + $transformer = $transformer = $this->mockTransformer(); + $this->transformBuilder->shouldReceive('transform')->andReturn($data = ['foo' => 1]); + + $transformation = $this->transformer->transform($data, $transformer, $relations = ['foo', 'bar']); + + $this->assertEquals($data, $transformation); + $this->transformBuilder->shouldHaveReceived('resource')->with($data, $transformer)->once(); + $this->transformBuilder->shouldHaveReceived('serializer')->with(NullSerializer::class)->once(); + $this->transformBuilder->shouldHaveReceived('with')->with($relations)->once(); + } +} \ No newline at end of file diff --git a/tests/Unit/Transformers/TransformerTest.php b/tests/Unit/Transformers/TransformerTest.php new file mode 100644 index 0000000..445b942 --- /dev/null +++ b/tests/Unit/Transformers/TransformerTest.php @@ -0,0 +1,36 @@ + + * @license The MIT License + */ +class TransformerTest +{ + /** + * The [Transformer] class being tested. + * + * @var \Flugg\Responder\Transformers\Transformer + */ + protected $transformer; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->transformer = Mockery::mock(Transformer::class); + } +} \ No newline at end of file