diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b3c0e42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/docs export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/phpunit.xml export-ignore +/.scrutinizer.yml export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.coverage.xml export-ignore +/coverage export-ignore \ No newline at end of file diff --git a/composer.json b/composer.json index 87464d7..602ad8a 100644 --- a/composer.json +++ b/composer.json @@ -35,11 +35,12 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.7", - "mockery/mockery": "^0.9.5", - "phpunit/phpunit" : "^6.1", - "phpunit/phpcov": "^4.0", + "mockery/mockery": "^1.0", + "phpunit/phpunit" : "^7.0", + "phpunit/phpcov": "^5.0", "squizlabs/php_codesniffer": "^3.1", - "orchestra/testbench": "~3.0", + "orchestra/testbench": "^3.6", + "orchestra/database": "^3.6", "fzaninotto/faker": "^1.7" }, "extra": { diff --git a/phpunit.xml b/phpunit.xml index 1140099..d55b1d2 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,7 +8,6 @@ convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" - syntaxCheck="true" verbose="true"> @@ -28,9 +27,8 @@ + - - \ No newline at end of file diff --git a/src/Transformers/ApiTransformer.php b/src/Transformers/ApiTransformer.php index ec438f4..1825035 100644 --- a/src/Transformers/ApiTransformer.php +++ b/src/Transformers/ApiTransformer.php @@ -7,13 +7,18 @@ use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection; +use Illuminate\Support\Str; +/** + * Class ApiTransformer + * @package Napp\Core\Api\Transformers + */ class ApiTransformer implements TransformerInterface { /** * @var array */ - protected $apiMapping = []; + public $apiMapping = []; /** * Strict mode removes keys that are @@ -67,16 +72,65 @@ public function transformOutput($data): array } if (true === $data instanceof Collection) { - foreach ($data as $item) { - $output[] = $this->transformOutput($item); - } + $output = $this->transformCollection($output, $data); + } else if (true === $data instanceof Model) { + $output = $this->transformAttributes($output, $data->getAttributes()); + $output = $this->transformRelationships($output, $data); } else { $data = (true === \is_array($data)) ? $data : $data->toArray(); - foreach ($data as $key => $value) { - if (true === $this->strict && false === array_key_exists($key, $this->apiMapping)) { + $output = $this->transformAttributes($output, $data); + } + + return $output; + } + + + /** + * @param array $output + * @param array $data + * @return array + */ + protected function transformAttributes(array $output, array $data): array + { + foreach ($data as $key => $value) { + if (true === $this->strict && false === array_key_exists($key, $this->apiMapping)) { + continue; + } + + $output[$this->findNewKey($key)] = $this->convertValueType($key, $value); + } + + return $output; + } + + /** + * @param array $output + * @param Model $data + * @return array + */ + protected function transformRelationships(array $output, Model $data): array + { + /** @var Model $data */ + $relationships = $data->getRelations(); + foreach ($relationships as $relationshipName => $relationship) { + if (true === $relationship instanceof Collection) { + // do not transform empty relationships + if($relationship->isEmpty()) { continue; } - $output[$this->findNewKey($key)] = $this->convertValueType($key, $value); + + if ($this->isTransformAware($relationship->first())) { + $output[$relationshipName] = $relationship->first()->getTransformer()->transformOutput($relationship); + } else { + $output[$relationshipName] = $relationship->toArray(); + } + } else { + // model + if ($this->isTransformAware($relationship)) { + $output[$relationshipName] = $relationship->getTransformer()->transformOutput($relationship); + } else { + $output[$relationshipName] = $relationship->getAttributes(); + } } } @@ -92,6 +146,20 @@ protected function transformPaginatedOutput($data): array return $result; } + /** + * @param array $output + * @param Collection $data + * @return array + */ + protected function transformCollection(array $output, Collection $data): array + { + foreach ($data as $item) { + $output[] = $this->transformOutput($item); + } + + return $output; + } + /** * @param string $newKey * @return string @@ -123,6 +191,7 @@ protected function findNewKey(string $originalKey): string /** * @param string $key * @param mixed $value + * @param string $newKey * @return mixed */ protected function convertValueType(string $key, $value) @@ -131,21 +200,140 @@ protected function convertValueType(string $key, $value) ? $this->apiMapping[$key]['dataType'] : 'string'; + foreach (static::normalizeType($type) as list($method, $parameters)) { + if (true === empty($method)) { + return $value; + } + + if ('Nullable' === $method) { + if (true === empty($value) && false === \is_numeric($value)) { + return null; + } + + continue; + } + + $method = "convert{$method}"; + + if (false === method_exists(TransformerMethods::class, $method)) { + return $value; + } + + return TransformerMethods::$method($value, $parameters); + } + } + + /** + * @param $type + * @return array + */ + protected static function parseStringDataType($type): array + { + $parameters = []; + + // The format for transforming data-types and parameters follows an + // easy {data-type}:{parameters} formatting convention. For instance the + // data-type "float:3" states that the value will be converted to a float with 3 decimals. + if (mb_strpos($type, ':') !== false) { + list($dataType, $parameter) = explode(':', $type, 2); + + $parameters = static::parseParameters($parameter); + } + + $dataType = static::normalizeDataType(trim($dataType ?? $type)); + + return [Str::studly($dataType), $parameters ?? []]; + } + + /** + * Parse a parameter list. + * + * @param string $parameter + * @return array + */ + protected static function parseParameters($parameter): array + { + return str_getcsv($parameter); + } + + /** + * @param $type + * @return array + */ + protected static function parseManyDataTypes($type): array + { + $parsed = []; + + $dataTypes = explode('|', $type); + + foreach ($dataTypes as $dataType) { + $parsed[] = static::parseStringDataType(trim($dataType)); + } + + return $parsed; + } + + /** + * @param $type + * @return array + */ + protected static function normalizeType($type): array + { + if (false !== mb_strpos($type, '|')) { + return self::normalizeNullable( + static::parseManyDataTypes($type) + ); + } + + return [static::parseStringDataType(trim($type))]; + } + + /** + * @param $type + * @return bool + */ + protected static function hasParameters($type): bool + { + return false !== mb_strpos($type, ':'); + } + + /** + * @param $dataTypes + * @return array + */ + protected static function normalizeNullable($dataTypes): array + { + if (isset($dataTypes[1][0]) && $dataTypes[1][0] === 'Nullable') { + return array_reverse($dataTypes); + } + + return $dataTypes; + } + + /** + * @param $type + * @return string + */ + protected static function normalizeDataType($type): string + { switch ($type) { - case 'datetime': - return strtotime($value) > 0 ? date("c", strtotime($value)) : ''; case 'int': - return (int) $value; + return 'integer'; case 'bool': - return (bool) $value; - case 'array': - return (array) $value; - case 'json': - return json_decode($value); - case 'float': - return (float) $value; + return 'boolean'; + case 'date': + return 'datetime'; default: - return $value; + return $type; } } + + /** + * @param $model + * @return bool + */ + protected function isTransformAware($model): bool + { + return array_key_exists(TransformerAware::class, class_uses($model)); + } } diff --git a/src/Transformers/TransformerAware.php b/src/Transformers/TransformerAware.php new file mode 100644 index 0000000..bca2145 --- /dev/null +++ b/src/Transformers/TransformerAware.php @@ -0,0 +1,8 @@ + 0 ? date('c', strtotime($value)) : ''; + } +} diff --git a/tests/ApiPaginatedTransformerTest.php b/tests/ApiPaginatedTransformerTest.php index 000ed83..d626b64 100644 --- a/tests/ApiPaginatedTransformerTest.php +++ b/tests/ApiPaginatedTransformerTest.php @@ -5,6 +5,7 @@ use Faker\Factory; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\Paginator; +use Napp\Core\Api\Tests\Models\Category; use Napp\Core\Api\Transformers\ApiTransformer; use Napp\Core\Api\Tests\TestCase; @@ -109,4 +110,40 @@ public function test_transform_length_aware_paginated_output_data() } } + public function test_transform_length_aware_paginated_with_relationships() + { + $category = Category::create(['title' => 'Electronics']); + $category->products()->create(['name' => 'iPhone', 'price'=> 100.0]); + $category->products()->create(['name' => 'Google Pixel', 'price'=> 80.0]); + $category->products()->create(['name' => 'Samsung Galaxy 9', 'price'=> 110.0]); + + $category2 = Category::create(['title' => 'Computers']); + $category2->products()->create(['name' => 'Mac', 'price'=> 28860.0]); + $category2->products()->create(['name' => 'Windows', 'price'=> 11000.0]); + + $input = Category::with('products')->get(); + + $paginatedInput = new LengthAwarePaginator($input, count($input) * 4, count($input)); + + $transformedOutput = $category->getTransformer()->transformOutput($paginatedInput); + + $this->assertArrayHasKey('current_page', $transformedOutput); + $this->assertArrayHasKey('data', $transformedOutput); + $this->assertArrayHasKey('first_page_url', $transformedOutput); + $this->assertArrayHasKey('from', $transformedOutput); + $this->assertArrayHasKey('last_page', $transformedOutput); + $this->assertArrayHasKey('last_page_url', $transformedOutput); + $this->assertArrayHasKey('next_page_url', $transformedOutput); + $this->assertArrayHasKey('path', $transformedOutput); + $this->assertArrayHasKey('per_page', $transformedOutput); + $this->assertArrayHasKey('prev_page_url', $transformedOutput); + $this->assertArrayHasKey('to', $transformedOutput); + $this->assertArrayHasKey('total', $transformedOutput); + + $this->assertEquals('iPhone', $transformedOutput['data'][0]['products'][0]['title']); + $this->assertEquals('Google Pixel', $transformedOutput['data'][0]['products'][1]['title']); + $this->assertEquals('Mac', $transformedOutput['data'][1]['products'][0]['title']); + $this->assertEquals('Windows', $transformedOutput['data'][1]['products'][1]['title']); + } + } \ No newline at end of file diff --git a/tests/ApiRequestTest.php b/tests/ApiRequestTest.php index 5e5599f..d6537ce 100644 --- a/tests/ApiRequestTest.php +++ b/tests/ApiRequestTest.php @@ -18,10 +18,9 @@ class ApiRequestTest extends TestCase public function setUp() { parent::setUp(); - $container = $this->app->make(\Illuminate\Contracts\Container\Container::class); $this->request = new ApiRequestStub(); - $this->request->setContainer($container); + $this->request->setContainer($this->app); } public function test_required_field() @@ -30,6 +29,13 @@ public function test_required_field() $this->request->setRules(['name' => 'required']); $this->request->setData(['name' => '']); + + // Laravel 5.6 changed the method validate method on the FormRequest to validateResolved. + if (true === version_compare($this->app->version(), '5.6', '>=')) { + $this->request->validateResolved(); + return; + } + $this->request->validate(); } @@ -39,6 +45,13 @@ public function test_field_with_wrong_format() $this->request->setRules(['number' => 'integer']); $this->request->setData(['number' => 'some integer']); + + // Laravel 5.6 changed the method validate method on the FormRequest to validateResolved. + if (true === version_compare($this->app->version(), '5.6', '>=')) { + $this->request->validateResolved(); + return; + } + $this->request->validate(); } @@ -48,6 +61,13 @@ public function test_invalid_field() $this->request->setRules(['name' => 'string']); $this->request->replace(['name' => 'some name', 'title' => 'some title']); + + // Laravel 5.6 changed the method validate method on the FormRequest to validateResolved. + if (true === version_compare($this->app->version(), '5.6', '>=')) { + $this->request->validateResolved(); + return; + } + $this->request->validate(); } diff --git a/tests/ApiTransformerTest.php b/tests/ApiTransformerTest.php index 3ac91b4..4c49fde 100644 --- a/tests/ApiTransformerTest.php +++ b/tests/ApiTransformerTest.php @@ -2,6 +2,9 @@ namespace Napp\Core\Api\Tests\Unit; +use Napp\Core\Api\Tests\Models\Category; +use Napp\Core\Api\Tests\Models\Product; +use Napp\Core\Api\Tests\Transformers\ProductTransformer; use Napp\Core\Api\Transformers\ApiTransformer; use Napp\Core\Api\Tests\TestCase; @@ -174,4 +177,123 @@ public function test_output_transforming_with_collection_strict_mode() $this->assertEquals($expectedOutput, $transformedOutput); } -} \ No newline at end of file + + public function test_the_datatype_is_nullable() + { + $this->transformer->setApiMapping([ + 'price' => ['newName' => 'price', 'dataType' => 'nullable|int'] + ]); + + $input = [ + 'price' => '100' + ]; + + $expectedOutput = [ + 'price' => 100 + ]; + + $this->assertSame($expectedOutput, $this->transformer->transformOutput($input)); + + $input = [ + 'price' => 0 + ]; + + $expectedOutput = [ + 'price' => 0 + ]; + + $this->assertSame($expectedOutput, $this->transformer->transformOutput($input)); + + $this->transformer->setApiMapping([ + 'description' => ['newName' => 'description', 'dataType' => 'array|nullable'] + ]); + + $input = [ + 'description' => [] + ]; + + $expectedOutput = [ + 'description' => null + ]; + + $this->assertSame($expectedOutput, $this->transformer->transformOutput($input)); + } + + public function test_arguments_can_be_passed_to_the_datatype() + { + $this->transformer->setApiMapping([ + 'price' => ['newName' => 'price', 'dataType' => 'float:2'] + ]); + + $input = [ + 'price' => '100.5542' + ]; + + $expectedOutput = [ + 'price' => 100.55 + ]; + + $this->assertSame($expectedOutput, $this->transformer->transformOutput($input)); + } + + public function test_transform_model_hasMany_relation_returns_transformed_relation_with_it() + { + /** @var Category $category */ + $category = Category::create(['title' => 'Electronics']); + $category->products()->create(['name' => 'iPhone', 'price'=> 100.0]); + $category->load('products'); + $result = $category->getTransformer()->transformOutput($category); + + $this->assertArrayHasKey('products', $result); + $this->assertEquals('iPhone', $result['products'][0]['title']); + } + + public function test_empty_relation_returns_only_transformed_base_model() + { + /** @var Category $category */ + $category = Category::create(['title' => 'Electronics']); + $category->load('products'); + $result = $category->getTransformer()->transformOutput($category); + + $this->assertArrayNotHasKey('products', $result); + } + + public function test_without_relation_loaded_returns_only_transformed_base_model() + { + /** @var Category $category */ + $category = Category::create(['title' => 'Electronics']); + $result = $category->getTransformer()->transformOutput($category); + + $this->assertArrayNotHasKey('products', $result); + } + + public function test_transform_collection_with_belongsTo_relation_transforms() + { + $category = Category::create(['title' => 'Electronics']); + $category->products()->create(['name' => 'iPhone', 'price'=> 100.0]); + $category->products()->create(['name' => 'Google Pixel', 'price'=> 80.0]); + $category->products()->create(['name' => 'Samsung Galaxy 9', 'price'=> 110.0]); + + $products = Product::with('category')->get(); + $result = app(ProductTransformer::class)->transformOutput($products); + + $this->assertEquals('iPhone', $result[0]['title']); + $this->assertEquals('Electronics', $result[0]['category']['name']); + $this->assertEquals('Electronics', $result[1]['category']['name']); + $this->assertEquals('Electronics', $result[2]['category']['name']); + + $category->load('products'); + $result = $category->getTransformer()->transformOutput($category); + $this->assertCount(3, $result['products']); + } + + public function test_transform_deeply_nested_relationships() + { + $category = Category::create(['title' => 'Electronics']); + $category->products()->create(['name' => 'iPhone', 'price'=> 100.0])->variants()->create(['name' => 'iPhone 8', 'sku_id' => 'IPHONE2233']); + $category->load(['products', 'products.variants']); + $result = $category->getTransformer()->transformOutput($category); + + $this->assertEquals('iPhone 8', $result['products'][0]['variants'][0]['title']); + } +} diff --git a/tests/Models/Category.php b/tests/Models/Category.php new file mode 100644 index 0000000..557ad75 --- /dev/null +++ b/tests/Models/Category.php @@ -0,0 +1,39 @@ + ['newName' => 'id', 'dataType' => 'int'], + 'title' => ['newName' => 'name', 'dataType' => 'string'], + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function products() + { + return $this->hasMany(Product::class); + } + + /** + * @return ApiTransformer + */ + public function getTransformer(): ApiTransformer + { + return app(CategoryTransformer::class); + } +} diff --git a/tests/Models/Product.php b/tests/Models/Product.php new file mode 100644 index 0000000..11c7d07 --- /dev/null +++ b/tests/Models/Product.php @@ -0,0 +1,48 @@ + ['newName' => 'id', 'dataType' => 'int'], + 'name' => ['newName' => 'title', 'dataType' => 'string'], + 'price' => ['newName' => 'price', 'dataType' => 'float'], + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\belongsTo + */ + public function category() + { + return $this->belongsTo(Category::class); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function variants() + { + return $this->hasMany(Variant::class); + } + + /** + * @return ApiTransformer + */ + public function getTransformer(): ApiTransformer + { + return app(ProductTransformer::class); + } +} diff --git a/tests/Models/Variant.php b/tests/Models/Variant.php new file mode 100644 index 0000000..685f6f3 --- /dev/null +++ b/tests/Models/Variant.php @@ -0,0 +1,40 @@ + ['newName' => 'id', 'dataType' => 'int'], + 'name' => ['newName' => 'title', 'dataType' => 'string'], + 'sku_id' => ['newName' => 'sku', 'dataType' => 'string'], + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\belongsTo + */ + public function product() + { + return $this->belongsTo(Product::class); + } + + /** + * @return ApiTransformer + */ + public function getTransformer(): ApiTransformer + { + return app(VariantTransformer::class); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 3abfb9d..d1643c0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,7 +2,133 @@ namespace Napp\Core\Api\Tests; -abstract class TestCase extends \Orchestra\Testbench\TestCase +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; +use Napp\Core\Api\Requests\Provider\RequestServiceProvider; +use Napp\Core\Api\Router\Provider\RouterServiceProvider; + +class TestCase extends \Orchestra\Testbench\TestCase { + public static $migrated = false; + + /** + * Setup the test environment. + * + * @return void + */ + public function setUp() + { + parent::setUp(); + + $this->migrateTables(); + } + + public function setUpTestDatabases() + { + if (false === static::$migrated) { + $this->dropAllTables(); + + $this->migrateTables(); + + static::$migrated = true; + } + + //$this->beginDatabaseTransaction(); + } + + /** + * Define environment setup. + * + * @param \Illuminate\Foundation\Application $app + * @return void + */ + protected function getEnvironmentSetUp($app) + { + $app['config']->set('cache.default', 'array'); + + // mysql + /*$app['config']->set('database.default', 'mysql'); + $app['config']->set('database.connections.mysql', [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'database' => 'apicore', + 'username' => 'username', + 'password' => 'password', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'strict' => true, + 'engine' => null, + ]);*/ + + // sqlite + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + $app['config']->set('database.default', 'testing'); + } + + /** + * Loading package service provider + * + * @param \Illuminate\Foundation\Application $app + * @return array + */ + protected function getPackageProviders($app) + { + return [ + RequestServiceProvider::class, + RouterServiceProvider::class + ]; + } + + public function migrateTables() + { + if ( ! Schema::hasTable('categories')) { + Schema::create('categories', function (Blueprint $table) { + $table->increments('id'); + $table->string('title'); + $table->timestamps(); + }); + } + + if ( ! Schema::hasTable('products')) { + Schema::create('products', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->float('price'); + $table->integer('category_id')->unsigned(); + $table->timestamps(); + }); + } + + if ( ! Schema::hasTable('variants')) { + Schema::create('variants', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->float('sku_id'); + $table->integer('product_id')->unsigned(); + $table->timestamps(); + }); + } + } -} + /** + * Drop all tables to start the test with fresh data + */ + public function dropAllTables() + { + Schema::disableForeignKeyConstraints(); + collect(DB::select('SHOW TABLES')) + ->map(function (\stdClass $tableProperties) { + return get_object_vars($tableProperties)[key($tableProperties)]; + }) + ->each(function (string $tableName) { + Schema::drop($tableName); + }); + Schema::enableForeignKeyConstraints(); + } +} \ No newline at end of file diff --git a/tests/Transformers/CategoryTransformer.php b/tests/Transformers/CategoryTransformer.php new file mode 100644 index 0000000..04ac58a --- /dev/null +++ b/tests/Transformers/CategoryTransformer.php @@ -0,0 +1,22 @@ +setApiMapping($category); + } +} \ No newline at end of file diff --git a/tests/Transformers/ProductTransformer.php b/tests/Transformers/ProductTransformer.php new file mode 100644 index 0000000..a0ccbab --- /dev/null +++ b/tests/Transformers/ProductTransformer.php @@ -0,0 +1,22 @@ +setApiMapping($product); + } +} \ No newline at end of file diff --git a/tests/Transformers/VariantTransformer.php b/tests/Transformers/VariantTransformer.php new file mode 100644 index 0000000..31e2753 --- /dev/null +++ b/tests/Transformers/VariantTransformer.php @@ -0,0 +1,22 @@ +setApiMapping($variant); + } +} \ No newline at end of file