├── src ├── Exceptions │ ├── BadRequestException.php │ ├── ContentTypeNotSupportedException.php │ ├── NotFoundException.php │ ├── ForbiddenException.php │ └── JsonException.php ├── Models │ └── Responses │ │ ├── RespondHttpOk.php │ │ ├── RespondHttpCreated.php │ │ ├── RespondHttpNoContent.php │ │ ├── RespondHttpPartialContent.php │ │ ├── RespondHttpForbidden.php │ │ ├── RespondHttpNotFound.php │ │ ├── RespondHttpUnauthorized.php │ │ ├── RespondSuccess.php │ │ └── RespondError.php ├── Http │ ├── Resources │ │ ├── AnonymousResourceCollection.php │ │ ├── IdentifierResource.php │ │ ├── BaseApiCollectionResource.php │ │ ├── JsonApiResource.php │ │ └── BaseApiResource.php │ ├── Middleware │ │ ├── ConfigureLocale.php │ │ └── InspectContentType.php │ └── Controllers │ │ └── Api │ │ └── BaseApiController.php ├── Constants │ └── HttpCodes.php ├── Repositories │ ├── RepositoryInterface.php │ └── BaseApiRepository.php ├── Paginators │ └── EmptyPaginator.php ├── Console │ └── Commands │ │ ├── BaseGenerateCommand.php │ │ ├── GenerateModelCommand.php │ │ ├── GeneratePolicyCommand.php │ │ ├── GenerateApiControllerCommand.php │ │ ├── GenerateRepositoryCommand.php │ │ ├── GenerateModelTranslationCommand.php │ │ ├── GenerateRoutesCommand.php │ │ ├── GenerateAuthenticationTestCommand.php │ │ ├── GenerateModelPermissionsCommand.php │ │ └── GenerateAllCommand.php ├── Services │ ├── ResponseService.php │ └── CustomFileGenerator.php ├── Providers │ └── LaravelApiServiceProvider.php └── Traits │ ├── HandleResponses.php │ └── HandlesRelationships.php ├── resources └── templates │ ├── translation.stub │ ├── repository.stub │ ├── route.stub │ ├── model_permissions.stub │ ├── controller.stub │ ├── model.stub │ ├── policy.stub │ └── authentication_test.stub ├── ISSUE_TEMPLATE.md ├── .travis.yml ├── config └── laravel_api.php ├── PULL_REQUEST_TEMPLATE.md └── composer.json /src/Exceptions/BadRequestException.php: -------------------------------------------------------------------------------- 1 | statusCode; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /resources/templates/repository.stub: -------------------------------------------------------------------------------- 1 | respondWithNotFound($this->message); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Exceptions/ForbiddenException.php: -------------------------------------------------------------------------------- 1 | respondWithForbidden($this->message); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Models/Responses/RespondError.php: -------------------------------------------------------------------------------- 1 | statusCode; 15 | } 16 | 17 | public function getMessage() 18 | { 19 | return $this->message; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /resources/templates/route.stub: -------------------------------------------------------------------------------- 1 | middleware('auth:api')->group(function () { 6 | Route::get('', Controller::class . '@index'); 7 | Route::get('/{id}', Controller::class . '@show'); 8 | Route::delete('/{id}', Controller::class . '@delete'); 9 | Route::post('', Controller::class . '@create'); 10 | Route::patch('/{id}', Controller::class . '@update'); 11 | }); 12 | -------------------------------------------------------------------------------- /src/Http/Resources/AnonymousResourceCollection.php: -------------------------------------------------------------------------------- 1 | collection->map->toArray($request, true)->all(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /resources/templates/model_permissions.stub: -------------------------------------------------------------------------------- 1 | respondWithBadRequest($this->message); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Repositories/RepositoryInterface.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 18 | parent::__construct($this->repository, $request); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Http/Middleware/ConfigureLocale.php: -------------------------------------------------------------------------------- 1 | user(); 20 | if ($user && property_exists($user, 'locale')) { 21 | $defaultLocale = $user->locale; 22 | } 23 | app()->setLocale($request->get('lang', $defaultLocale)); 24 | 25 | return $next($request); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /resources/templates/model.stub: -------------------------------------------------------------------------------- 1 | header('Content-Type') && 'application/vnd.api+json' !== $request->header('Accept')) { 19 | throw new ContentTypeNotSupportedException('Your request should be in json api format. (Content-Type: application/vnd.api+json)'); 20 | } 21 | 22 | return $next($request); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Detailed description 4 | 5 | Provide a detailed description of the change or addition you are proposing. 6 | 7 | Make it clear if the issue is a bug, an enhancement or just a question. 8 | 9 | ## Context 10 | 11 | Why is this change important to you? How would you use it? 12 | 13 | How can it benefit other users? 14 | 15 | ## Possible implementation 16 | 17 | Not obligatory, but suggest an idea for implementing addition or change. 18 | 19 | ## Your environment 20 | 21 | Include as many relevant details about the environment you experienced the bug in and how to reproduce it. 22 | 23 | * Version used (e.g. PHP 5.6, HHVM 3): 24 | * Operating system and version (e.g. Ubuntu 16.04, Windows 7): 25 | * Link to your project: 26 | * ... 27 | * ... 28 | -------------------------------------------------------------------------------- /src/Paginators/EmptyPaginator.php: -------------------------------------------------------------------------------- 1 | items = $items instanceof Collection ? $items : Collection::make($items); 15 | $this->total = 0; 16 | } 17 | 18 | public function toArray() 19 | { 20 | return [ 21 | 'data' => $this->items->toArray(), 22 | 'from' => $this->firstItem(), 23 | 'path' => $this->path, 24 | 'to' => $this->lastItem(), 25 | 'total' => $this->total(), 26 | ]; 27 | } 28 | 29 | public function total() 30 | { 31 | return $this->total; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Http/Resources/IdentifierResource.php: -------------------------------------------------------------------------------- 1 | $this->getResourceType(), 21 | $this->resource->getKeyName() => (string) $this->resource->getKey(), 22 | ]; 23 | } 24 | 25 | protected function getResourceType() 26 | { 27 | $resourceClass = class_basename($this->resource); 28 | $resourcePlural = Str::plural($resourceClass); 29 | // Converts camelcase to dash 30 | $lowerCaseResourceType = strtolower(preg_replace('/([a-zA-Z])(?=[A-Z])/', '$1-', $resourcePlural)); 31 | 32 | return $lowerCaseResourceType; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | matrix: 4 | include: 5 | - php: "7.2" 6 | env: LARAVEL_VERSION="^6.0" 7 | - php: "7.2" 8 | env: LARAVEL_VERSION="^7.0" 9 | - php: "7.3" 10 | env: LARAVEL_VERSION="^7.0" 11 | - php: "7.4" 12 | env: LARAVEL_VERSION="^7.0" 13 | - php: "7.3" 14 | env: LARAVEL_VERSION="^8.0" 15 | - php: "7.4" 16 | env: LARAVEL_VERSION="^8.0" 17 | - php: "8.0" 18 | env: LARAVEL_VERSION="^8.0" 19 | - php: "8.0" 20 | env: LARAVEL_VERSION="^9.0" 21 | - php: "8.1" 22 | env: LARAVEL_VERSION="^8.0" 23 | - php: "8.1" 24 | env: LARAVEL_VERSION="^9.0" $RUN_CS_FIXER=1 25 | 26 | sudo: false 27 | 28 | install: 29 | - composer require "laravel/framework:${LARAVEL_VERSION}" --no-update --no-interaction 30 | - travis_retry composer install --no-interaction --prefer-dist 31 | 32 | script: 33 | - if [ "$RUN_CS_FIXER" ] ; then vendor/bin/php-cs-fixer fix -v --dry-run --using-cache=no ; fi 34 | - vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml 35 | 36 | 37 | branches: 38 | only: 39 | - master 40 | 41 | -------------------------------------------------------------------------------- /src/Console/Commands/BaseGenerateCommand.php: -------------------------------------------------------------------------------- 1 | generator = new CustomFileGenerator(); 16 | } 17 | 18 | protected function generateClass($classType, $stubName) 19 | { 20 | $configPath = config($this->getConfigPath()); 21 | $this->generator->setModelName($this->getModelName())->generate($classType, $stubName, $configPath, $this); 22 | } 23 | 24 | protected function overridePath() 25 | { 26 | $overridePath = $this->getOverridePath(); 27 | $configPath = $this->getConfigPath(); 28 | 29 | if (!isset($overridePath) || !isset($configPath)) { 30 | return; 31 | } 32 | 33 | config([$configPath => $overridePath]); 34 | } 35 | 36 | abstract public function getModelName(); 37 | 38 | abstract public function getOverridePath(); 39 | 40 | abstract public function getConfigPath(); 41 | } 42 | -------------------------------------------------------------------------------- /resources/templates/policy.stub: -------------------------------------------------------------------------------- 1 | id === $requestedItem->owner->id 14 | 15 | public function index(User $user) 16 | { 17 | return $user->tokenCan($MODEL_NAME$Permissions::RETRIEVE_ALL_$PLURAL_UPPER_CASED_MODEL_NAME$); 18 | } 19 | 20 | public function show(User $user, $requestedItem) 21 | { 22 | return $user->tokenCan($MODEL_NAME$Permissions::RETRIEVE_$UPPER_CASED_MODEL_NAME$); 23 | } 24 | 25 | public function create(User $user) 26 | { 27 | return $user->tokenCan($MODEL_NAME$Permissions::CREATE_$UPPER_CASED_MODEL_NAME$); 28 | } 29 | 30 | public function update(User $user, $requestedItem) 31 | { 32 | return $user->tokenCan($MODEL_NAME$Permissions::UPDATE_$UPPER_CASED_MODEL_NAME$); 33 | } 34 | 35 | public function delete(User $user, $requestedItem) 36 | { 37 | return $user->tokenCan($MODEL_NAME$Permissions::DELETE_$UPPER_CASED_MODEL_NAME$); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Console/Commands/GenerateModelCommand.php: -------------------------------------------------------------------------------- 1 | name = $this->argument('name'); 31 | $this->overridePath = $this->option('path'); 32 | $this->overridePath(); 33 | $this->generateClass('', 'model'); 34 | } 35 | 36 | public function getModelName() 37 | { 38 | return $this->name; 39 | } 40 | 41 | public function getOverridePath() 42 | { 43 | return $this->overridePath; 44 | } 45 | 46 | public function getConfigPath() 47 | { 48 | return 'laravel_api.path.model'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Console/Commands/GeneratePolicyCommand.php: -------------------------------------------------------------------------------- 1 | name = $this->argument('name'); 31 | $this->overridePath = $this->option('path'); 32 | $this->overridePath(); 33 | $this->generateClass('Policy', 'policy'); 34 | } 35 | 36 | public function getModelName() 37 | { 38 | return $this->name; 39 | } 40 | 41 | public function getOverridePath() 42 | { 43 | return $this->overridePath; 44 | } 45 | 46 | public function getConfigPath() 47 | { 48 | return 'laravel_api.path.policy'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Console/Commands/GenerateApiControllerCommand.php: -------------------------------------------------------------------------------- 1 | name = $this->argument('name'); 31 | $this->overridePath = $this->option('path'); 32 | $this->overridePath(); 33 | $this->generateClass('Controller', 'controller'); 34 | } 35 | 36 | public function getModelName() 37 | { 38 | return $this->name; 39 | } 40 | 41 | public function getOverridePath() 42 | { 43 | return $this->overridePath; 44 | } 45 | 46 | public function getConfigPath() 47 | { 48 | return 'laravel_api.path.controller'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Console/Commands/GenerateRepositoryCommand.php: -------------------------------------------------------------------------------- 1 | name = $this->argument('name'); 31 | $this->overridePath = $this->option('path'); 32 | $this->overridePath(); 33 | $this->generateClass('Repository', 'repository'); 34 | } 35 | 36 | public function getModelName() 37 | { 38 | return $this->name; 39 | } 40 | 41 | public function getOverridePath() 42 | { 43 | return $this->overridePath; 44 | } 45 | 46 | public function getConfigPath() 47 | { 48 | return 'laravel_api.path.repository'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Console/Commands/GenerateModelTranslationCommand.php: -------------------------------------------------------------------------------- 1 | name = $this->argument('name'); 31 | $this->overridePath = $this->option('path'); 32 | $this->overridePath(); 33 | $this->generateClass('Translation', 'translation'); 34 | } 35 | 36 | public function getModelName() 37 | { 38 | return $this->name; 39 | } 40 | 41 | public function getOverridePath() 42 | { 43 | return $this->overridePath; 44 | } 45 | 46 | public function getConfigPath() 47 | { 48 | return 'laravel_api.path.translation'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Console/Commands/GenerateRoutesCommand.php: -------------------------------------------------------------------------------- 1 | name = $this->argument('name'); 34 | $this->overridePath = $this->option('path'); 35 | $this->overridePath(); 36 | $this->generateClass('Routes', 'route'); 37 | } 38 | 39 | public function getModelName() 40 | { 41 | return $this->name; 42 | } 43 | 44 | public function getOverridePath() 45 | { 46 | return $this->overridePath; 47 | } 48 | 49 | public function getConfigPath() 50 | { 51 | return 'laravel_api.path.routes'; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Console/Commands/GenerateAuthenticationTestCommand.php: -------------------------------------------------------------------------------- 1 | name = $this->argument('name'); 31 | $this->overridePath = $this->option('path'); 32 | $this->overridePath(); 33 | $this->generateClass('AuthenticationTest', 'authentication_test'); 34 | } 35 | 36 | public function getModelName() 37 | { 38 | return $this->name; 39 | } 40 | 41 | public function getOverridePath() 42 | { 43 | return $this->overridePath; 44 | } 45 | 46 | public function getConfigPath() 47 | { 48 | return 'laravel_api.path.auth_test'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Http/Resources/BaseApiCollectionResource.php: -------------------------------------------------------------------------------- 1 | collection); 23 | $includedRelationships = $this->getIncludedRelationships($items, $request); 24 | $response = []; 25 | 26 | $response['data'] = $items; 27 | empty($includedRelationships) ?: $response['included'] = $includedRelationships; 28 | 29 | return $response; 30 | } 31 | 32 | protected function getIncludedRelationships($items, Request $request) 33 | { 34 | $includes = array_filter(explode(',', $request->get('include', ''))); 35 | if ([] === $includes) { 36 | return []; 37 | } 38 | $relations = $this->includeCollectionRelationships($items, $includes); 39 | 40 | return $relations; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Console/Commands/GenerateModelPermissionsCommand.php: -------------------------------------------------------------------------------- 1 | name = $this->argument('name'); 31 | $this->overridePath = $this->option('path'); 32 | $this->overridePath(); 33 | $this->generateClass('Permissions', 'model_permissions'); 34 | } 35 | 36 | public function getModelName() 37 | { 38 | return $this->name; 39 | } 40 | 41 | public function getOverridePath() 42 | { 43 | return $this->overridePath; 44 | } 45 | 46 | public function getConfigPath() 47 | { 48 | return 'laravel_api.path.model_permissions'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /config/laravel_api.php: -------------------------------------------------------------------------------- 1 | [ 6 | 'model' => app_path('/'), 7 | 8 | 'model_permissions' => app_path('Permissions/'), 9 | 10 | 'translation' => app_path('Translations/'), 11 | 12 | 'controller' => app_path('Http/Controllers/Api/'), 13 | 14 | 'repository' => app_path('Repositories/'), 15 | 16 | 'policy' => app_path('Policies/'), 17 | 18 | 'auth_test' => base_path('tests/Authentication/'), 19 | 20 | 'templates' => 'vendor/swisnl/json-api-server/resources/templates/', 21 | 22 | 'routes' => app_path('Http/Routes/') 23 | ], 24 | 25 | 'namespace' => [ 26 | 'model' => 'App', 27 | 28 | 'model_permissions' => 'App\Permissions', 29 | 30 | 'controller' => 'App\Http\Controllers\Api', 31 | 32 | 'repository' => 'App\Repositories', 33 | 34 | 'translation' => 'App\Translations', 35 | 36 | 'policy' => 'App\Policies', 37 | 38 | 'auth_test' => 'App\Tests\Authentication' 39 | ], 40 | 41 | // Permissions configuration 42 | 'permissions' => [ 43 | 'checkDefaultIndexPermission' => false, 44 | 45 | 'checkDefaultShowPermission' => false, 46 | 47 | 'checkDefaultCreatePermission' => false, 48 | 49 | 'checkDefaultUpdatePermission' => false, 50 | 51 | 'checkDefaultDeletePermission' => false, 52 | ], 53 | 54 | 55 | // Load all relationships to have response exactly like json api. This slows down the API immensely. 56 | 'loadAllJsonApiRelationships' => true, 57 | ]; -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | Describe your changes in detail. 6 | 7 | ## Motivation and context 8 | 9 | Why is this change required? What problem does it solve? 10 | 11 | If it fixes an open issue, please link to the issue here (if you write `fixes #num` 12 | or `closes #num`, the issue will be automatically closed when the pull is accepted.) 13 | 14 | ## How has this been tested? 15 | 16 | Please describe in detail how you tested your changes. 17 | 18 | Include details of your testing environment, and the tests you ran to 19 | see how your change affects other areas of the code, etc. 20 | 21 | ## Screenshots (if appropriate) 22 | 23 | ## Types of changes 24 | 25 | What types of changes does your code introduce? Put an `x` in all the boxes that apply: 26 | - [ ] Bug fix (non-breaking change which fixes an issue) 27 | - [ ] New feature (non-breaking change which adds functionality) 28 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 29 | 30 | ## Checklist: 31 | 32 | Go over all the following points, and put an `x` in all the boxes that apply. 33 | 34 | Please, please, please, don't send your pull request until all of the boxes are ticked. Once your pull request is created, it will trigger a build on our [continuous integration](http://www.phptherightway.com/#continuous-integration) server to make sure your [tests and code style pass](https://help.github.com/articles/about-required-status-checks/). 35 | 36 | - [ ] I have read the **[CONTRIBUTING](CONTRIBUTING.md)** document. 37 | - [ ] My pull request addresses exactly one patch/feature. 38 | - [ ] I have created a branch for this patch/feature. 39 | - [ ] Each individual commit in the pull request is meaningful. 40 | - [ ] I have added tests to cover my changes. 41 | - [ ] If my change requires a change to the documentation, I have updated it accordingly. 42 | 43 | If you're unsure about any of these, don't hesitate to ask. We're here to help! 44 | -------------------------------------------------------------------------------- /src/Console/Commands/GenerateAllCommand.php: -------------------------------------------------------------------------------- 1 | modelName = $this->argument('name'); 36 | $this->overridePath = $this->option('path'); 37 | $this->callsToSkip = explode(',', $this->option('skip')); 38 | 39 | $this->makeGeneratorCalls(); 40 | } 41 | 42 | public function makeGeneratorCalls() 43 | { 44 | $generatorCalls = [ 45 | 'controller' => 'json-api-server:generate-controller', 46 | 'model' => 'json-api-server:generate-model', 47 | 'model-permissions' => 'json-api-server:generate-model-permissions', 48 | 'repository' => 'json-api-server:generate-repository', 49 | 'translation' => 'json-api-server:generate-translation', 50 | 'policy' => 'json-api-server:generate-policy', 51 | 'test' => 'json-api-server:generate-test', 52 | 'routes' => 'json-api-server:generate-routes', 53 | ]; 54 | 55 | foreach ($generatorCalls as $type => $generatorCall) { 56 | if (in_array($type, $this->callsToSkip)) { 57 | continue; 58 | } 59 | $this->call($generatorCall, ['name' => $this->modelName, '--path' => $this->overridePath]); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Http/Resources/JsonApiResource.php: -------------------------------------------------------------------------------- 1 | type; 17 | } 18 | 19 | public function setType($type) 20 | { 21 | $this->type = $type; 22 | 23 | return $this; 24 | } 25 | 26 | public function getId() 27 | { 28 | return $this->id; 29 | } 30 | 31 | public function setId($id) 32 | { 33 | $this->id = $id; 34 | 35 | return $this; 36 | } 37 | 38 | public function getAttributes() 39 | { 40 | return $this->attributes; 41 | } 42 | 43 | public function setAttributes($attributes) 44 | { 45 | $this->attributes = $attributes; 46 | 47 | return $this; 48 | } 49 | 50 | public function getRelationships() 51 | { 52 | return $this->relationships; 53 | } 54 | 55 | public function setRelationships($relationships) 56 | { 57 | $this->relationships = $relationships; 58 | 59 | return $this; 60 | } 61 | 62 | public function getLinks() 63 | { 64 | return $this->links; 65 | } 66 | 67 | public function setLinks($links) 68 | { 69 | $this->links = $links; 70 | 71 | return $this; 72 | } 73 | 74 | public function getIncluded() 75 | { 76 | return $this->included; 77 | } 78 | 79 | public function setIncluded($included) 80 | { 81 | $this->included = $included; 82 | 83 | return $this; 84 | } 85 | 86 | public function changeTypeInAttributes($typeAlias) 87 | { 88 | if (!isset($this->attributes['type'])) { 89 | return $this; 90 | } 91 | 92 | $this->attributes[$typeAlias] = $this->attributes['type']; 93 | unset($this->attributes['type']); 94 | 95 | return $this; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swisnl/json-api-server", 3 | "type": "library", 4 | "description": "Set up a JSON API in Laravel in just a few minutes.", 5 | "keywords": [ 6 | "swisnl", 7 | "laravel", 8 | "laravel-api", 9 | "json-api", 10 | "json-api-server" 11 | ], 12 | "homepage": "http://www.swis.nl", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Arnaud van Zandwijk", 17 | "email": "arnaud@swis.nl", 18 | "homepage": "https://www.swis.nl", 19 | "role": "Developer" 20 | }, 21 | { 22 | "name": "Björn Brala", 23 | "email": "bjorn@swis.nl", 24 | "homepage": "https://www.swis.nl", 25 | "role": "Developer" 26 | }, 27 | { 28 | "name": "Dylan de Wit", 29 | "email": "ddewit@swis.nl", 30 | "homepage": "https://www.swis.nl", 31 | "role": "Developer" 32 | }, 33 | { 34 | "name": "Dani Tulp", 35 | "email": "dtulp@swis.nl", 36 | "homepage": "https://www.swis.nl", 37 | "role": "Developer" 38 | } 39 | ], 40 | "require": { 41 | "php": ">=7.2|^8.0", 42 | "ext-json": "*", 43 | "astrotomic/laravel-translatable": "^11.6", 44 | "laravel/framework": "^6.0|^7.0|^8.0|^9.0" 45 | }, 46 | "require-dev": { 47 | "filp/whoops": "~2.0", 48 | "friendsofphp/php-cs-fixer": "^2.16", 49 | "mockery/mockery": "^1.0", 50 | "orchestra/testbench": "^4.0|^5.0|^6.0", 51 | "phpunit/php-code-coverage": "^7.0", 52 | "phpunit/phpunit": "^8.0", 53 | "spatie/phpunit-watcher": "^1.3", 54 | "squizlabs/php_codesniffer": "^3.5" 55 | }, 56 | "autoload": { 57 | "psr-4": { 58 | "Swis\\JsonApi\\Server\\": "src/" 59 | } 60 | }, 61 | "autoload-dev": { 62 | "psr-4": { 63 | "Tests\\": "tests" 64 | } 65 | }, 66 | "scripts": { 67 | "test": "vendor/bin/phpunit", 68 | "test-watch": "vendor/bin/phpunit-watcher watch", 69 | "check-style": "vendor/bin/php-cs-fixer fix --dry-run -vvv", 70 | "fix-style": "vendor/bin/php-cs-fixer fix -vvv" 71 | }, 72 | "extra": { 73 | "branch-alias": { 74 | "dev-master": "1.0-dev" 75 | }, 76 | "laravel": { 77 | "providers": [ 78 | "Swis\\JsonApi\\Server\\Providers\\LaravelApiServiceProvider" 79 | ] 80 | } 81 | }, 82 | "config": { 83 | "sort-packages": true 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Services/ResponseService.php: -------------------------------------------------------------------------------- 1 | createResponse($responseModel, $content); 17 | } 18 | 19 | protected function createResponse($responseModel, $content) 20 | { 21 | if ($responseModel instanceof RespondError) { 22 | $errors = $this->formatErrors($responseModel, $content); 23 | 24 | return response()->json($errors, $responseModel->getStatusCode(), ['Content-Type' => 'application/vnd.api+json'], JSON_UNESCAPED_SLASHES); 25 | } 26 | 27 | return response()->json($content, $responseModel->getStatusCode(), ['Content-Type' => 'application/vnd.api+json'], JSON_UNESCAPED_SLASHES); 28 | } 29 | 30 | public function respondWithResourceCollection($strResponseModel, $content) 31 | { 32 | $responseModel = new $strResponseModel(); 33 | 34 | return (new BaseApiCollectionResource($content)) 35 | ->response() 36 | ->setEncodingOptions(JSON_UNESCAPED_SLASHES) 37 | ->header('Content-Type', 'application/vnd.api+json') 38 | ->setStatusCode($responseModel->getStatusCode()); 39 | } 40 | 41 | public function responseWithResource($strResponseModel, $content) 42 | { 43 | if (!$content) { 44 | throw new NotFoundException('Not found'); 45 | } 46 | $responseModel = new $strResponseModel(); 47 | 48 | return (new BaseApiResource($content)) 49 | ->response() 50 | ->setEncodingOptions(JSON_UNESCAPED_SLASHES) 51 | ->header('Content-Type', 'application/vnd.api+json') 52 | ->setStatusCode($responseModel->getStatusCode()); 53 | } 54 | 55 | /** 56 | * @param $responseModel 57 | * @param $content 58 | * 59 | * @return mixed 60 | */ 61 | protected function formatErrors($responseModel, $content) 62 | { 63 | $errors['errors'] = [ 64 | 0 => [ 65 | 'status' => (string) $responseModel->getStatusCode(), 66 | 'title' => (string) $responseModel->getMessage(), 67 | 'detail' => (string) $content, 68 | ], 69 | ]; 70 | 71 | return $errors; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Providers/LaravelApiServiceProvider.php: -------------------------------------------------------------------------------- 1 | aliasMiddleware('configure_locale', ConfigureLocale::class); 28 | $router->aliasMiddleware('inspect_content_type', InspectContentType::class); 29 | 30 | $this->publishes([ 31 | __DIR__.'/../../config/laravel_api.php' => base_path('config/laravel_api.php'), 32 | ], 'laravel-api'); 33 | 34 | $this->publishes([ 35 | __DIR__.'/../../resources/templates' => base_path('resources/templates'), 36 | ], 'laravel-api-templates'); 37 | $this->mapJsonApiRoutes(); 38 | } 39 | 40 | public function register() 41 | { 42 | $this->app->register(TranslatableServiceProvider::class); 43 | 44 | $this->commands([ 45 | GenerateAllCommand::class, 46 | GenerateApiControllerCommand::class, 47 | GenerateModelCommand::class, 48 | GenerateModelTranslationCommand::class, 49 | GeneratePolicyCommand::class, 50 | GenerateRepositoryCommand::class, 51 | GenerateModelPermissionsCommand::class, 52 | GenerateAuthenticationTestCommand::class, 53 | GenerateRoutesCommand::class, 54 | ]); 55 | 56 | $this->mergeConfigFrom( 57 | __DIR__.'/../../config/laravel_api.php', 58 | 'laravel_api' 59 | ); 60 | } 61 | 62 | protected function mapJsonApiRoutes() 63 | { 64 | if (!File::exists(config('laravel_api.path.routes'))) { 65 | return; 66 | } 67 | $files = Finder::create()->files()->in(config('laravel_api.path.routes')); 68 | foreach ($files as $file) { 69 | Route::middleware('inspect_content_type')->group($file->getRealPath()); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Traits/HandleResponses.php: -------------------------------------------------------------------------------- 1 | response($respondModel, $content); 23 | } 24 | 25 | protected function respondWithResource($respondModel, $content) 26 | { 27 | $service = new ResponseService(); 28 | 29 | return $service->responseWithResource($respondModel, $content); 30 | } 31 | 32 | protected function respondWithResourceCollection($respondModel, $content) 33 | { 34 | $service = new ResponseService(); 35 | 36 | return $service->respondWithResourceCollection($respondModel, $content); 37 | } 38 | 39 | public function respondWithOK($content) 40 | { 41 | return $this->respondWithResource(RespondHttpOk::class, $content); 42 | } 43 | 44 | public function respondWithPartialContent($content) 45 | { 46 | return $this->respondWithResourceCollection(RespondHttpPartialContent::class, $content); 47 | } 48 | 49 | protected function respondWithCollectionOK($content) 50 | { 51 | return $this->respondWithResourceCollection(RespondHttpOk::class, $content); 52 | } 53 | 54 | public function respondWithCreated($content) 55 | { 56 | return $this->respondWithResource(RespondHttpCreated::class, $content); 57 | } 58 | 59 | public function respondWithNoContent() 60 | { 61 | return $this->respond(RespondHttpNoContent::class, ''); 62 | } 63 | 64 | public function respondWithCollection($content) 65 | { 66 | if ($content instanceof Paginator) { 67 | return $this->respondWithPartialContent($content); 68 | } 69 | 70 | if ($content instanceof Model) { 71 | return $this->respondWithOK($content); 72 | } 73 | 74 | return $this->respondWithCollectionOK($content); 75 | } 76 | 77 | public function respondWithForbidden($content) 78 | { 79 | return $this->respond(RespondHttpForbidden::class, $content); 80 | } 81 | 82 | public function respondWithBadRequest($content) 83 | { 84 | return $this->respond(RespondError::class, $content); 85 | } 86 | 87 | public function respondWithNotFound($content) 88 | { 89 | return $this->respond(RespondHttpNotFound::class, $content); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Services/CustomFileGenerator.php: -------------------------------------------------------------------------------- 1 | stubVariables = [ 15 | '$MODEL_NAME$' => $this->modelName, 16 | '$CAMEL_CASE_MODEL_NAME$' => Str::camel($this->modelName), 17 | '$SNAKE_CASED_MODEL_NAME$' => strtolower(Str::snake($this->modelName)), 18 | '$PLURAL_SNAKE_CASED_MODEL_NAME$' => strtolower(Str::plural(Str::snake($this->modelName))), 19 | '$PLURAL_UPPER_CASED_MODEL_NAME$' => strtoupper(Str::plural($this->modelName)), 20 | '$PLURAL_LOWER_CASED_MODEL_NAME$' => strtolower(Str::plural($this->modelName)), 21 | '$UPPER_CASED_MODEL_NAME$' => strtoupper($this->modelName), 22 | '$NAMESPACE_MODEL$' => config('laravel_api.namespace.model'), 23 | '$NAME_SPACE_REPOSITORY$' => config('laravel_api.namespace.repository'), 24 | '$NAME_SPACE_MODEL_PERMISSIONS$' => config('laravel_api.namespace.model_permissions'), 25 | '$NAME_SPACE_POLICY$' => config('laravel_api.namespace.policy'), 26 | '$NAMESPACE_REPOSITORY$' => config('laravel_api.namespace.repository'), 27 | '$NAMESPACE_CONTROLLER$' => config('laravel_api.namespace.controller'), 28 | '$NAMESPACE_AUTH_TEST$' => config('laravel_api.namespace.auth_test'), 29 | ]; 30 | 31 | return $this; 32 | } 33 | 34 | public function generate($classExtensionName, $stubName, $path, $command) 35 | { 36 | if (file_exists($path.$this->modelName.$classExtensionName.'.php')) { 37 | $command->error($path.$this->modelName.$classExtensionName.' already exists.'); 38 | 39 | return; 40 | } 41 | 42 | $this->setStubVars(); 43 | 44 | $filledStub = $this->getStub($stubName); 45 | $filledStub = $this->fillStub($this->stubVariables, $filledStub); 46 | $this->createFile($path, $this->modelName.$classExtensionName.'.php', $filledStub); 47 | $command->info('generated '.$path.$this->modelName.$classExtensionName); 48 | } 49 | 50 | public function setModelName($modelName) 51 | { 52 | $this->modelName = $modelName; 53 | 54 | return $this; 55 | } 56 | 57 | public static function createFile($path, $classExtensionName, $filledStub) 58 | { 59 | if (!is_dir($path)) { 60 | mkdir($path); 61 | } 62 | 63 | $path = $path.$classExtensionName; 64 | 65 | file_put_contents($path, $filledStub); 66 | } 67 | 68 | protected function getStubDir($stub) 69 | { 70 | $stubName = str_replace('.', '/', $stub); 71 | 72 | return config('laravel_api.path.templates').$stubName.'.stub'; 73 | } 74 | 75 | protected function getStub($templateName) 76 | { 77 | $path = $this->getStubDir($templateName); 78 | 79 | return file_get_contents($path); 80 | } 81 | 82 | protected function fillStub($variables, $stub) 83 | { 84 | foreach ($variables as $variable => $value) { 85 | $stub = str_replace($variable, $value, $stub); 86 | } 87 | 88 | return $stub; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /resources/templates/authentication_test.stub: -------------------------------------------------------------------------------- 1 | user = factory(User::class)->create(); 23 | 24 | $this->user->givePermissionTo($MODEL_NAME$Permissions::RETRIEVE_$UPPER_CASED_MODEL_NAME$); 25 | $this->user->givePermissionTo($MODEL_NAME$Permissions::RETRIEVE_ALL_$PLURAL_UPPER_CASED_MODEL_NAME$); 26 | $this->user->givePermissionTo($MODEL_NAME$Permissions::CREATE_$UPPER_CASED_MODEL_NAME$); 27 | $this->user->givePermissionTo($MODEL_NAME$Permissions::UPDATE_$UPPER_CASED_MODEL_NAME$); 28 | $this->user->givePermissionTo($MODEL_NAME$Permissions::DELETE_$UPPER_CASED_MODEL_NAME$); 29 | 30 | $this->baseUrl = env('API_URL').'/$PLURAL_SNAKE_CASED_MODEL_NAME$/'; 31 | $this->withHeaders(['Accept'=>'application/vnd.api+json']); 32 | } 33 | 34 | /** @test */ 35 | public function it_creates_an_$CAMEL_CASE_MODEL_NAME$_unauthenticated() 36 | { 37 | $response = $this->post($this->baseUrl); 38 | $response->assertStatus(401); 39 | } 40 | 41 | /** @test */ 42 | public function it_creates_an_$CAMEL_CASE_MODEL_NAME$_authenticated() 43 | { 44 | Passport::actingAs($this->user); 45 | 46 | $response = $this->post($this->baseUrl); 47 | $response->assertStatus(201); 48 | } 49 | 50 | /** @test */ 51 | public function it_updates_an_$CAMEL_CASE_MODEL_NAME$_unauthenticated() 52 | { 53 | $$CAMEL_CASE_MODEL_NAME$ = factory($MODEL_NAME$::class)->create(); 54 | 55 | $response = $this->patch($this->baseUrl.$$CAMEL_CASE_MODEL_NAME$->id, $$CAMEL_CASE_MODEL_NAME$->toArray()); 56 | $response->assertStatus(401); 57 | } 58 | 59 | /** @test */ 60 | public function it_updates_an_$CAMEL_CASE_MODEL_NAME$_authenticated() 61 | { 62 | Passport::actingAs($this->user); 63 | 64 | $$CAMEL_CASE_MODEL_NAME$ = factory($MODEL_NAME$::class)->create(); 65 | 66 | $response = $this->patch($this->baseUrl.$$CAMEL_CASE_MODEL_NAME$->id, $$CAMEL_CASE_MODEL_NAME$->toArray()); 67 | 68 | $response->assertStatus(200); 69 | } 70 | 71 | /** @test */ 72 | public function it_retrieves_an_$CAMEL_CASE_MODEL_NAME$_unauthenticated() 73 | { 74 | $$CAMEL_CASE_MODEL_NAME$ = factory($MODEL_NAME$::class)->create(); 75 | 76 | $response = $this->get($this->baseUrl.$$CAMEL_CASE_MODEL_NAME$->id); 77 | $response->assertStatus(401); 78 | } 79 | 80 | /** @test */ 81 | public function it_retrieves_an_$CAMEL_CASE_MODEL_NAME$_authenticated() 82 | { 83 | Passport::actingAs($this->user); 84 | 85 | $$CAMEL_CASE_MODEL_NAME$ = factory($MODEL_NAME$::class)->create(); 86 | 87 | $response = $this->get($this->baseUrl.$$CAMEL_CASE_MODEL_NAME$->id); 88 | $response->assertStatus(200); 89 | } 90 | 91 | /** @test */ 92 | public function it_retrieves_all_$PLURAL_LOWER_CASED_MODEL_NAME$_unauthenticated() 93 | { 94 | factory($MODEL_NAME$::class, 3)->create(); 95 | 96 | $response = $this->get($this->baseUrl); 97 | $response->assertStatus(401); 98 | } 99 | 100 | /** @test */ 101 | public function it_retrieves_all_$PLURAL_LOWER_CASED_MODEL_NAME$_authenticated() 102 | { 103 | Passport::actingAs($this->user); 104 | 105 | factory($MODEL_NAME$::class, 3)->create(); 106 | 107 | $response = $this->get($this->baseUrl); 108 | $response->assertStatus(206); 109 | } 110 | 111 | /** @test */ 112 | public function it_deletes_an_$CAMEL_CASE_MODEL_NAME$_unauthenticated() 113 | { 114 | $$CAMEL_CASE_MODEL_NAME$ = factory($MODEL_NAME$::class)->create(); 115 | 116 | $response = $this->delete($this->baseUrl.$$CAMEL_CASE_MODEL_NAME$->id); 117 | $response->assertStatus(401); 118 | } 119 | 120 | /** @test */ 121 | public function it_deletes_an_$CAMEL_CASE_MODEL_NAME$_authenticated() 122 | { 123 | Passport::actingAs($this->user); 124 | 125 | $$CAMEL_CASE_MODEL_NAME$ = factory($MODEL_NAME$::class)->create(); 126 | 127 | $response = $this->delete($this->baseUrl.$$CAMEL_CASE_MODEL_NAME$->id); 128 | $response->assertStatus(204); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Http/Controllers/Api/BaseApiController.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 31 | $this->request = $request; 32 | 33 | $this->repository->setParameters($request->query()); 34 | } 35 | 36 | /** 37 | * @throws ForbiddenException 38 | * 39 | * @return $this 40 | */ 41 | public function index() 42 | { 43 | $items = $this->repository 44 | ->paginate($this->request->query()); 45 | 46 | if (config('laravel_api.permissions.checkDefaultIndexPermission')) { 47 | $this->authorizeAction('index', $this->repository->getModelName()); 48 | } 49 | 50 | return $this->respondWithCollection($items); 51 | } 52 | 53 | /** 54 | * This method returns an object by requested id if you have the permissions. 55 | * 56 | * @param $id 57 | * 58 | * @throws ForbiddenException 59 | * 60 | * @return string 61 | */ 62 | public function show($id) 63 | { 64 | $item = $this->repository->findById($id, $this->request->query()); 65 | if (config('laravel_api.permissions.checkDefaultShowPermission')) { 66 | $this->authorizeAction('show', $item); 67 | } 68 | 69 | return $this->respondWithOK($item); 70 | } 71 | 72 | /** 73 | * Creates a new row in the db. 74 | * 75 | * @throws ForbiddenException 76 | * @throws JsonException 77 | * 78 | * @return $this|\Illuminate\Database\Eloquent\Model 79 | */ 80 | public function create() 81 | { 82 | if (config('laravel_api.permissions.checkDefaultCreatePermission')) { 83 | $this->authorizeAction('create', $this->repository->getModelName()); 84 | } 85 | $createdResource = $this->repository->create($this->validateObject()); 86 | 87 | return $this->respondWithCreated($createdResource); 88 | } 89 | 90 | /** 91 | * Updates an item in the db. 92 | * 93 | * @param $id 94 | * 95 | * @throws ForbiddenException 96 | * @throws JsonException 97 | * 98 | * @return $this 99 | */ 100 | public function update($id) 101 | { 102 | if (config('laravel_api.permissions.checkDefaultUpdatePermission')) { 103 | $this->authorizeAction('update', $this->repository->findById($id)); 104 | } 105 | 106 | return $this->respondWithOK($this->repository->update($this->validateObject($id), $id)); 107 | } 108 | 109 | /** 110 | * Deletes an item in the db. Will probably not be implemented. 111 | * 112 | * @param $id 113 | * 114 | * @throws ForbiddenException 115 | * 116 | * @return \Illuminate\Contracts\Routing\ResponseFactory|\Symfony\Component\HttpFoundation\Response 117 | */ 118 | public function delete($id) 119 | { 120 | if (config('laravel_api.permissions.checkDefaultDeletePermission')) { 121 | $this->authorizeAction('delete', $this->repository->findById($id)); 122 | } 123 | 124 | $this->repository->destroy($id); 125 | 126 | return $this->respondWithNoContent(); 127 | } 128 | 129 | /** 130 | * @param $policyMethod 131 | * @param $item 132 | * @param mixed|null $requestedObject 133 | * 134 | * @throws ForbiddenException 135 | * @throws AuthorizationException 136 | */ 137 | protected function authorizeAction($policyMethod, $requestedObject = null) 138 | { 139 | try { 140 | if (null !== $requestedObject) { 141 | if ($requestedObject instanceof Paginator) { 142 | foreach ($requestedObject->items() as $item) { 143 | $this->authorize($policyMethod, $item); 144 | } 145 | 146 | return; 147 | } 148 | 149 | $this->authorize($policyMethod, $requestedObject); 150 | 151 | return; 152 | } 153 | 154 | $this->authorize($policyMethod, $this->repository->getModelName()); 155 | } catch (AuthorizationException $exception) { 156 | throw new ForbiddenException('This action is forbidden'); 157 | } 158 | } 159 | 160 | /** 161 | * @param null $id 162 | * 163 | * @throws JsonException 164 | * 165 | * @return array|string 166 | */ 167 | public function validateObject($id = null) 168 | { 169 | $input = $this->request->input(); 170 | if (isset($input['data']) and isset($input['data']['attributes'])) { 171 | $input = $input['data']['attributes']; 172 | } 173 | 174 | $model = $this->repository->makeModel(); 175 | 176 | return $this->getValidationFactory()->make( 177 | $input, 178 | $model->getRules($id) 179 | )->validate(); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/Traits/HandlesRelationships.php: -------------------------------------------------------------------------------- 1 | getMethods() as $method) { 42 | $returnType = $method->getReturnType(); 43 | //TODO: niet alleen op ! checken maar beter afvangen 44 | if (!$returnType) { 45 | continue; 46 | } 47 | if ($this->isRelationshipReturntype($returnType)) { 48 | $relations[] = $method->getName(); 49 | } 50 | } 51 | 52 | return $relations; 53 | } 54 | 55 | public function includeRelationships($item, $includes) 56 | { 57 | $relationshipResources = $this->handleIncludes($item, $includes); 58 | 59 | return $this->mergeInnerArrays($relationshipResources); 60 | } 61 | 62 | public function includeCollectionRelationships($items, $includes) 63 | { 64 | $relationshipResources = []; 65 | foreach ($items as $item) { 66 | $relationshipResources = array_merge($relationshipResources, $this->handleIncludes($item, $includes)); 67 | } 68 | 69 | return $this->mergeInnerArrays($relationshipResources); 70 | } 71 | 72 | /** 73 | * Loops through all included tags. It checks for each include if there is a nested include. 74 | * And also runs that recursively through includeCollectionRelationships. 75 | * 76 | * @param mixed $item 77 | * @param mixed $includes 78 | * 79 | * @return array 80 | */ 81 | protected function handleIncludes($item, $includes) 82 | { 83 | $relationshipResources = []; 84 | foreach ($includes as $include) { 85 | list($nestedInclude, $include) = $this->getNestedRelation($include); 86 | $included = null; 87 | if ($item->$include instanceof Collection) { 88 | $included = BaseApiResource::collection($item->$include); 89 | if ($nestedInclude) { 90 | $relationshipResources[] = $this->includeCollectionRelationships($included, [$nestedInclude]); 91 | } 92 | } else { 93 | $included = BaseApiResource::make($item->$include); 94 | $object = Str::before($nestedInclude ?? '', '.'); 95 | if (isset($included->$object)) { 96 | $relationshipResources[] = $this->handleIncludes($included, [$nestedInclude]); 97 | } 98 | } 99 | 100 | $relationshipResources[] = $included; 101 | } 102 | 103 | return $relationshipResources; 104 | } 105 | 106 | /** 107 | * Merges all arrays to be single level. 108 | * 109 | * @param $array 110 | * 111 | * @return array 112 | */ 113 | protected function mergeInnerArrays($array) 114 | { 115 | $mergedArray = []; 116 | 117 | foreach ($array as $items) { 118 | if ($items instanceof ResourceCollection || is_array($items)) { 119 | foreach ($items as $item) { 120 | $mergedArray[] = $item; 121 | } 122 | continue; 123 | } 124 | 125 | $mergedArray[] = $items; 126 | } 127 | $mergedArray = $this->removeDuplicates($mergedArray); 128 | 129 | return $mergedArray; 130 | } 131 | 132 | protected function removeDuplicates($items) 133 | { 134 | $tempArray = []; 135 | $relations = []; 136 | 137 | foreach ($items as $item) { 138 | if (!isset($item->resource)) { 139 | continue; 140 | } 141 | 142 | if (in_array($item->attributesToArray(), $tempArray, true)) { 143 | continue; 144 | } 145 | 146 | $tempArray[] = $item->attributesToArray(); 147 | $relations[] = $item; 148 | } 149 | 150 | return $relations; 151 | } 152 | 153 | /** 154 | * @param $include 155 | */ 156 | protected function getNestedRelation($include): array 157 | { 158 | $nestedInclude = null; 159 | if (Str::contains($include, '.')) { 160 | $nestedInclude = Str::after($include, '.'); 161 | $include = Str::before($include, '.'); 162 | } 163 | 164 | return [$nestedInclude, $include]; 165 | } 166 | 167 | protected function isRelationshipReturntype(\ReflectionType $returnType): bool 168 | { 169 | /** 170 | * compatibility php7.0 & php7.1+. 171 | */ 172 | $returnTypeClassname = null; 173 | if (is_callable([$returnType, 'getName'])) { 174 | $returnTypeClassname = $returnType->getName(); 175 | } else { 176 | $returnTypeClassname = (string) $returnType; 177 | } 178 | 179 | return in_array($returnTypeClassname, $this->relationshipTypes); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/Repositories/BaseApiRepository.php: -------------------------------------------------------------------------------- 1 | model = $this->makeModel(); 38 | $this->initQuery(); 39 | } 40 | 41 | public function paginate($parameters = []) 42 | { 43 | $this->initQuery(); 44 | 45 | $this->parameters = $parameters; 46 | $this->setFilters(); 47 | 48 | if (array_key_exists('all', $this->parameters)) { 49 | $collection = $this->query->get(); 50 | $total = count($collection); 51 | if (0 == $total) { 52 | return new EmptyPaginator(); 53 | } 54 | 55 | return new LengthAwarePaginator($collection, $total, $total); 56 | } 57 | 58 | return $this->query->paginate($this->perPage, $this->columns, 'page', $this->page); 59 | } 60 | 61 | /** 62 | * @param $value 63 | * @param array $parameters 64 | * 65 | * @throws NotFoundException 66 | * 67 | * @return \Illuminate\Database\Eloquent\Collection|Model|static|static[]|null 68 | */ 69 | public function findById($value, $parameters = []) 70 | { 71 | $this->initQuery(); 72 | $this->parameters = $parameters; 73 | $this->setFilters(); 74 | $this->eagerLoadRelationships(); 75 | $this->model = $this->query->find($value, $this->columns); 76 | if (!$this->model) { 77 | throw new NotFoundException("{$this->getModelName()} {$value} not found"); 78 | } 79 | 80 | return $this->model; 81 | } 82 | 83 | public function create(array $data) 84 | { 85 | $data = array_map([$this, 'nullToEmptyString'], $data); 86 | 87 | return $this->model->create($data); 88 | } 89 | 90 | /** 91 | * @param $objectKey 92 | * 93 | * @throws NotFoundException 94 | * 95 | * @return \Illuminate\Database\Eloquent\Collection|Model|static|static[]|null 96 | */ 97 | public function update(array $data, $objectKey) 98 | { 99 | $this->model = $this->findById($objectKey); 100 | $this->model->update($data); 101 | 102 | return $this->model; 103 | } 104 | 105 | public function destroy($id) 106 | { 107 | return $this->model->destroy($id); 108 | } 109 | 110 | public function makeModel(): Model 111 | { 112 | $model = app()->make($this->getModelName()); 113 | 114 | if (!$model instanceof Model) { 115 | throw new ModelNotFoundException("Class: {$this->getModelName()} must be an instance of Illuminate\\Database\\Eloquent\\Model"); 116 | } 117 | 118 | return $model; 119 | } 120 | 121 | public function getModelRelationships(): array 122 | { 123 | return $this->getRelationships($this->model); 124 | } 125 | 126 | protected function setFilters() 127 | { 128 | $this->setIds(); 129 | $this->excludeIds(); 130 | $this->orderByAsc(); 131 | $this->orderByDesc(); 132 | $this->eagerLoadRelationships(); 133 | $this->setPagination(); 134 | $this->setColumns(); 135 | } 136 | 137 | public function setIds() 138 | { 139 | if (!isset($this->parameters['ids'])) { 140 | return; 141 | } 142 | 143 | $this->query->whereIn('id', explode(',', $this->parameters['ids'])); 144 | } 145 | 146 | public function excludeIds() 147 | { 148 | if (!isset($this->parameters['exclude_ids'])) { 149 | return; 150 | } 151 | 152 | $this->query->whereNotIn('id', explode(',', $this->parameters['exclude_ids'])); 153 | } 154 | 155 | public function orderByAsc() 156 | { 157 | if (!isset($this->parameters['order_by_asc'])) { 158 | return; 159 | } 160 | 161 | $this->query->getQuery()->orders = null; 162 | $this->query->orderBy($this->parameters['order_by_asc']); 163 | } 164 | 165 | public function orderByDesc() 166 | { 167 | if (!isset($this->parameters['order_by_desc'])) { 168 | return; 169 | } 170 | 171 | $this->query->getQuery()->orders = null; 172 | $this->query->orderByDesc($this->parameters['order_by_desc']); 173 | } 174 | 175 | public function initQuery() 176 | { 177 | if (!isset($this->query)) { 178 | $this->query = $this->model->newQuery(); 179 | } 180 | } 181 | 182 | protected function nullToEmptyString($value) 183 | { 184 | if (null !== $value) { 185 | return $value; 186 | } 187 | 188 | return ''; 189 | } 190 | 191 | protected function eagerLoadRelationships() 192 | { 193 | $relations = []; 194 | if (!empty($this->parameters) && array_key_exists('include', $this->parameters)) { 195 | $relations = explode(',', $this->parameters['include']); 196 | } 197 | //Todo if relation isn't found throw BadException, currently throws 500. 198 | $this->query->with(array_unique($relations)); 199 | } 200 | 201 | public function setParameters($parameters) 202 | { 203 | $this->parameters = $parameters; 204 | 205 | return $this; 206 | } 207 | 208 | public function setPagination() 209 | { 210 | if (isset($this->parameters['page'])) { 211 | $this->page = $this->parameters['page']; 212 | } 213 | 214 | if (isset($this->parameters['per_page'])) { 215 | $this->perPage = $this->parameters['per_page']; 216 | } 217 | } 218 | 219 | abstract public function getModelName(): string; 220 | 221 | public function setColumns() 222 | { 223 | if (isset($this->parameters['fields'])) { 224 | $this->columns = explode(',', $this->parameters['fields']); 225 | //Need to set id else pagination breaks 226 | $this->columns[] = 'id'; 227 | } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/Http/Resources/BaseApiResource.php: -------------------------------------------------------------------------------- 1 | jsonApiModel = new JsonApiResource(); 25 | $this->setValues(); 26 | } 27 | 28 | public function toArray($request, $isCollection = false) 29 | { 30 | $response = []; 31 | $wrap = false; 32 | $isMasterResource = $this->findMasterResource($request->getPathInfo()) == $this->getResourceType(); 33 | if (!$isCollection && !$wrap && $isMasterResource) { 34 | $this->jsonApiModel->setIncluded($this->getIncludedRelationships($request)); 35 | $wrap = true; 36 | $response['data'] = []; 37 | } 38 | 39 | return $this->mapToJsonApi($response, $wrap); 40 | } 41 | 42 | public function setValues() 43 | { 44 | if (!$this->resource) { 45 | return; 46 | } 47 | $this->resource->makeHidden($this->resource->getKeyName()); 48 | 49 | $this->jsonApiModel->setId((string) $this->resource->getKey()); 50 | $this->jsonApiModel->setType($this->getResourceType()); 51 | $this->jsonApiModel->setAttributes($this->resource->attributesToArray()); 52 | $this->jsonApiModel->setAttributes($this->jsonApiModel->getAttributes() + $this->getPivotAttributes()); 53 | $this->jsonApiModel->setRelationships($this->relationships()); 54 | $this->jsonApiModel->setLinks($this->getLinks()); 55 | $this->translateAttributes(); 56 | $this->setExtraValues(); 57 | $this->filterTypeFromAttributes(); 58 | } 59 | 60 | public function mapToJsonApi($response, bool $wrap) 61 | { 62 | $jsonApiArray = [ 63 | 'id' => $this->jsonApiModel->getId(), 64 | 'type' => $this->jsonApiModel->getType(), 65 | 'attributes' => $this->jsonApiModel->getAttributes(), 66 | 'links' => $this->jsonApiModel->getLinks(), 67 | 'relationships' => $this->jsonApiModel->getRelationships(), 68 | 'included' => $this->jsonApiModel->getIncluded(), 69 | ]; 70 | 71 | foreach ($jsonApiArray as $key => $value) { 72 | if ($wrap && 'included' != $key) { 73 | $response['data'] = $this->addToResponse($response['data'], $key, $value); 74 | continue; 75 | } 76 | 77 | $response = $this->addToResponse($response, $key, $value); 78 | } 79 | 80 | return $response; 81 | } 82 | 83 | protected function addToResponse($response, $key, $value) 84 | { 85 | if (!isset($value) || empty($value)) { 86 | return $response; 87 | } 88 | $response[$key] = $value; 89 | 90 | return $response; 91 | } 92 | 93 | protected function translateAttributes() 94 | { 95 | if (!$this->resource->translatedAttributes) { 96 | return; 97 | } 98 | 99 | $attributes = $this->jsonApiModel->getAttributes(); 100 | 101 | foreach ($this->resource->translatedAttributes as $key => $translation) { 102 | if ($this->resource->$translation == '') {// temp while there are still empty values in translations table 103 | continue; 104 | } 105 | 106 | $attributes[$translation] = $this->resource->$translation; 107 | } 108 | 109 | $this->jsonApiModel->setAttributes($attributes); 110 | } 111 | 112 | protected function setExtraValues() 113 | { 114 | if (method_exists($this->resource, 'getExtraValues')) { 115 | $this->jsonApiModel->setAttributes($this->jsonApiModel->getAttributes() + $this->getExtraValues()); 116 | } 117 | } 118 | 119 | protected function findMasterResource($str) 120 | { 121 | if (empty($str)) { 122 | return; 123 | } 124 | 125 | $masterResource = substr($str, strrpos($str, '/') + 1); 126 | 127 | if (is_numeric($masterResource)) { 128 | $str = str_replace('/'.$masterResource, '', $str); 129 | $masterResource = $this->findMasterResource($str); 130 | } 131 | 132 | return $masterResource; 133 | } 134 | 135 | protected function getPivotAttributes() 136 | { 137 | $attributes = []; 138 | 139 | if ($this->resource->pivot) { 140 | $attributes = $this->resource->pivot->attributesToArray(); 141 | if (array_key_exists('id', $attributes)) { 142 | $attributes['pivot_id'] = $attributes['id']; 143 | unset($attributes['id']); 144 | } 145 | } 146 | 147 | return $attributes; 148 | } 149 | 150 | protected function getLinks() 151 | { 152 | return [ 153 | 'self' => env('API_URL').'/'.$this->getResourceType().'/'.$this->resource->getKey(), 154 | ]; 155 | } 156 | 157 | protected function relationships() 158 | { 159 | $relationships = $this->getRelationships($this->resource); 160 | $this->jsonApiModel->setAttributes($this->jsonApiModel->getAttributes() + ['available_relationships' => $relationships]); 161 | $relationshipsIdentifiers = []; 162 | 163 | foreach ($relationships as $relationship) { 164 | if (!config('laravel_api.loadAllJsonApiRelationships') && !$this->resource->relationLoaded($relationship)) { 165 | continue; 166 | } 167 | 168 | $data = $this->resource->$relationship; 169 | if ((is_array($data) || $data instanceof \Countable) && 0 == count($data)) { 170 | continue; 171 | } 172 | 173 | $relationshipData = $this->getRelationshipData($data); 174 | 175 | if (empty($relationshipData) || !$relationshipData) { 176 | continue; 177 | } 178 | 179 | $relationshipsIdentifiers[$relationship] = ['data' => $relationshipData]; 180 | } 181 | 182 | return $relationshipsIdentifiers; 183 | } 184 | 185 | protected function getRelationshipData($data) 186 | { 187 | $relationshipData = []; 188 | 189 | if ($data instanceof Collection) { 190 | $relationshipData = IdentifierResource::collection($data); 191 | foreach ($relationshipData as $key => $relation) { 192 | if (!$this->checkIfDataIsSet($relation)) { 193 | unset($relationshipData[$key]); 194 | } 195 | } 196 | 197 | if ([] == $relationshipData->toArray(true)) { 198 | $relationshipData = []; 199 | } 200 | } elseif ($data instanceof Model) { 201 | $relationshipData = IdentifierResource::make($data); 202 | $this->checkIfDataIsSet($relationshipData) ?: $relationshipData = []; 203 | } 204 | 205 | return $relationshipData; 206 | } 207 | 208 | protected function filterTypeFromAttributes() 209 | { 210 | if (!array_key_exists('type', $this->jsonApiModel->getAttributes())) { 211 | return; 212 | } 213 | 214 | if (!method_exists($this->resource, 'getTypeAlias')) { 215 | throw new \Exception('Your model lacks the method getTypeAlias'); 216 | } 217 | 218 | $this->jsonApiModel->changeTypeInAttributes($this->getTypeAlias()); 219 | } 220 | 221 | protected function checkIfDataIsSet($relationshipData): bool 222 | { 223 | return isset($relationshipData->resource->id); 224 | } 225 | 226 | protected function getIncludedRelationships(Request $request) 227 | { 228 | $this->includes = explode(',', $request->get('include', null)); 229 | if (null == $this->includes) { 230 | return []; 231 | } 232 | $relations = $this->includeRelationships($this->resource, $this->includes); 233 | 234 | return $relations; 235 | } 236 | 237 | protected function getResourceType() 238 | { 239 | $resourceClass = class_basename($this->resource); 240 | $resourcePlural = Str::plural($resourceClass); 241 | 242 | // Converts camelcase to dash 243 | $lowerCaseResourceType = strtolower(preg_replace('/([a-zA-Z])(?=[A-Z])/', '$1-', $resourcePlural)); 244 | 245 | return $lowerCaseResourceType; 246 | } 247 | 248 | /** 249 | * Create new anonymous resource collection. 250 | * 251 | * @param mixed $resource 252 | * 253 | * @return mixed 254 | */ 255 | public static function collection($resource) 256 | { 257 | return new class($resource, get_called_class()) extends AnonymousResourceCollection { 258 | /** 259 | * @var string 260 | */ 261 | public $collects; 262 | 263 | /** 264 | * Create a new anonymous resource collection. 265 | * 266 | * @param mixed $resource 267 | * @param string $collects 268 | */ 269 | public function __construct($resource, $collects) 270 | { 271 | $this->collects = $collects; 272 | parent::__construct($resource); 273 | } 274 | }; 275 | } 276 | } 277 | --------------------------------------------------------------------------------