├── .gitattributes ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── config └── apihandler.php ├── phpunit.xml ├── src ├── ApiHandler.php ├── ApiHandlerException.php ├── ApiHandlerServiceProvider.php ├── CountMetaProvider.php ├── Facades │ └── ApiHandler.php ├── MetaProvider.php ├── Parser.php └── Result.php └── tests ├── ApiHandlerTest.php ├── Comment.php ├── Post.php └── User.php /.gitattributes: -------------------------------------------------------------------------------- 1 | * text -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.phar 3 | composer.lock 4 | .DS_Store 5 | .phpintel 6 | .idea -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - "5.4" 5 | - "5.5" 6 | 7 | env: 8 | - LARAVEL_VERSION="4.2.*" 9 | - LARAVEL_VERSION="dev-master" 10 | 11 | before_script: 12 | - composer self-update 13 | - composer install --prefer-source --no-interaction --dev 14 | 15 | script: phpunit -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelgwerder/laravel-api-handler/0c864d0735e7e061bfef99b33d6ef964850ff722/LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Laravel API Handler 2 | [![Build Status](https://travis-ci.org/marcelgwerder/laravel-api-handler.png?branch=master)](https://travis-ci.org/marcelgwerder/laravel-api-handler) [![Latest Stable Version](https://poser.pugx.org/marcelgwerder/laravel-api-handler/v/stable.png)](https://packagist.org/packages/marcelgwerder/laravel-api-handler) [![Total Downloads](https://poser.pugx.org/marcelgwerder/laravel-api-handler/downloads.png)](https://packagist.org/packages/marcelgwerder/laravel-api-handler) [![License](https://poser.pugx.org/marcelgwerder/laravel-api-handler/license.png)](https://packagist.org/packages/marcelgwerder/laravel-api-handler) 3 | 4 | This helper package provides functionality for parsing the URL of a REST-API request. 5 | 6 | ### Installation 7 | 8 | ***Note:*** This version is for Laravel 5. When using Laravel 4 you need to use version 0.4.x. 9 | 10 | Install the package through composer by running 11 | ``` 12 | composer require marcelgwerder/laravel-api-handler 13 | ``` 14 | 15 | Once composer finished add the service provider to the `providers` array in `app/config/app.php`: 16 | ``` 17 | Marcelgwerder\ApiHandler\ApiHandlerServiceProvider::class, 18 | ``` 19 | Now import the `ApiHandler` facade into your classes: 20 | ```php 21 | use Marcelgwerder\ApiHandler\Facades\ApiHandler; 22 | ``` 23 | Or set an alias in `app.php`: 24 | ``` 25 | 'ApiHandler' => Marcelgwerder\ApiHandler\Facades\ApiHandler::class, 26 | ``` 27 | That's it! 28 | 29 | ### Migrate from 0.3.x to >= 0.4.x 30 | 31 | #### Relation annotations 32 | 33 | Relation methods now need an `@Relation` annotation to prove that they are relation methods and not any other methods (see issue #11). 34 | 35 | ```php 36 | /** 37 | * @Relation 38 | */ 39 | public function author() { 40 | return $this->belongsTo('Author'); 41 | } 42 | ``` 43 | #### Custom identification columns 44 | 45 | If you pass an array as the second parameter to `parseSingle`, there now have to be column/value pairs. 46 | This allows us to pass multiple conditions like: 47 | 48 | ```php 49 | ApiHandler::parseSingle($books, array('id_origin' => 'Random Bookstore Ltd', 'id' => 1337)); 50 | ``` 51 | 52 | ### Configuration 53 | 54 | To override the configuration, create a file called `apihandler.php` in the config folder of your app. 55 | Check out the config file in the package source to see what options are available. 56 | 57 | 58 | ### URL parsing 59 | 60 | Url parsing currently supports: 61 | * Limit the fields 62 | * Filtering 63 | * Full text search 64 | * Sorting 65 | * Define limit and offset 66 | * Append related models 67 | * Append meta information (counts) 68 | 69 | There are two kind of api resources supported, a single object and a collection of objects. 70 | 71 | #### Single object 72 | 73 | If you handle a GET request on a resource representing a single object like for example `/api/books/1`, use the `parseSingle` method. 74 | 75 | **parseSingle($queryBuilder, $identification, [$queryParams]):** 76 | * **$queryBuilder**: Query builder object, Eloquent model or Eloquent relation 77 | * **$identification**: An integer used in the `id` column or an array column/value pair(s) (`array('isbn' => '1234')`) used as a unique identifier of the object. 78 | * **$queryParams**: An array containing the query parameters. If not defined, the original GET parameters are used. 79 | 80 | ```php 81 | ApiHandler::parseSingle($book, 1); 82 | ``` 83 | 84 | #### Collection of objects 85 | 86 | If you handle a GET request on a resource representing multiple objects like for example `/api/books`, use the `parseMultiple` method. 87 | 88 | **parseMultiple($queryBuilder, $fullTextSearchColumns, [$queryParams]):** 89 | * **$queryBuilder**: Query builder object, Eloquent model or Eloquent relation 90 | * **$fullTextSearchColumns**: An array which defines the columns used for full text search. 91 | * **$queryParams**: An array containing the query parameters. If not defined, the original GET parameters are used. 92 | 93 | ```php 94 | ApiHandler::parseMultiple($book, array('title', 'isbn', 'description')); 95 | ``` 96 | 97 | #### Result 98 | 99 | Both `parseSingle` and `parseMultiple` return a `Result` object with the following methods available: 100 | 101 | **getBuilder():** 102 | Returns the original `$queryBuilder` with all the functions applied to it. 103 | 104 | **getResult():** 105 | Returns the result object returned by Laravel's `get()` or `first()` functions. 106 | 107 | **getResultOrFail():** 108 | Returns the result object returned by Laravel's `get()` function if you expect multiple objects or `firstOrFail()` if you expect a single object. 109 | 110 | **getResponse($resultOrFail = false):** 111 | Returns a Laravel `Response` object including body, headers and HTTP status code. 112 | If `$resultOrFail` is true, the `getResultOrFail()` method will be used internally instead of `getResult()`. 113 | 114 | **getHeaders():** 115 | Returns an array of prepared headers. 116 | 117 | **getMetaProviders():** 118 | Returns an array of meta provider objects. Each of these objects provide a specific type of meta data through its `get()` method. 119 | 120 | **cleanup($cleanup):** 121 | If true, the resulting array will get cleaned up from unintentionally added relations. Such relations can get automatically added if they are accessed as properties in model accessors. The global default for the cleanup can be defined using the config option `cleanup_relations` which defaults to `false`. 122 | ```php 123 | ApiHandler::parseSingle($books, 42)->cleanup(true)->getResponse(); 124 | ``` 125 | 126 | #### Filtering 127 | Every query parameter, except the predefined functions `_fields`, `_with`, `_sort`, `_limit`, `_offset`, `_config` and `_q`, is interpreted as a filter. Be sure to remove additional parameters not meant for filtering before passing them to `parseMultiple`. 128 | 129 | ``` 130 | /api/books?title=The Lord of the Rings 131 | ``` 132 | All the filters are combined with an `AND` operator. 133 | ``` 134 | /api/books?title-lk=The Lord*&created_at-min=2014-03-14 12:55:02 135 | ``` 136 | The above example would result in the following SQL where: 137 | ```sql 138 | WHERE `title` LIKE "The Lord%" AND `created_at` >= "2014-03-14 12:55:02" 139 | ``` 140 | Its also possible to use multiple values for one filter. Multiple values are separated by a pipe `|`. 141 | Multiple values are combined with `OR` except when there is a `-not` suffix, then they are combined with `AND`. 142 | For example all the books with the id 5 or 6: 143 | ``` 144 | /api/books?id=5|6 145 | ``` 146 | Or all the books except the ones with id 5 or 6: 147 | ``` 148 | /api/books?id-not=5|6 149 | ``` 150 | 151 | The same could be achieved using the `-in` suffix: 152 | ``` 153 | /api/books?id-in=5,6 154 | ``` 155 | Respectively the `not-in` suffix: 156 | ``` 157 | /api/books?id-not-in=5,6 158 | ``` 159 | 160 | 161 | ##### Suffixes 162 | Suffix | Operator | Meaning 163 | ------------- | ------------- | ------------- 164 | -lk | LIKE | Same as the SQL `LIKE` operator 165 | -not-lk | NOT LIKE | Same as the SQL `NOT LIKE` operator 166 | -in | IN | Same as the SQL `IN` operator 167 | -not-in | NOT IN | Same as the SQL `NOT IN` operator 168 | -min | >= | Greater than or equal to 169 | -max | <= | Smaller than or equal to 170 | -st | < | Smaller than 171 | -gt | > | Greater than 172 | -not | != | Not equal to 173 | 174 | #### Sorting 175 | Two ways of sorting, ascending and descending. Every column which should be sorted descending always starts with a `-`. 176 | ``` 177 | /api/books?_sort=-title,created_at 178 | ``` 179 | 180 | #### Fulltext search 181 | Two implementations of full text search are supported. 182 | You can choose which one to use by changing the `fulltext` option in the config file to either `default` or `native`. 183 | 184 | ***Note:*** When using an empty `_q` param the search will always return an empty result. 185 | 186 | **Limited custom implementation (default)** 187 | 188 | A given text is split into keywords which then are searched in the database. Whenever one of the keyword exists, the corresponding row is included in the result set. 189 | 190 | ``` 191 | /api/books?_q=The Lord of the Rings 192 | ``` 193 | The above example returns every row that contains one of the keywords `The`, `Lord`, `of`, `the`, `Rings` in one of its columns. The columns to consider in full text search are passed to `parseMultiple`. 194 | 195 | **Native MySQL implementation** 196 | 197 | If your MySQL version supports fulltext search for the engine you use you can use this advanced search in the api handler. 198 | Just change the `fulltext` config option to `native` and make sure that there is a proper fulltext index on the columns you pass to `parseMultiple`. 199 | 200 | Each result will also contain a `_score` column which allows you to sort the results according to how well they match with the search terms. E.g. 201 | 202 | ``` 203 | /api/books?_q=The Lord of the Rings&_sort=-_score 204 | ``` 205 | 206 | You can adjust the name of this column by modifying the `fulltext_score_column` setting in the config file. 207 | 208 | #### Limit the result set 209 | To define the maximum amount of datasets in the result, use `_limit`. 210 | ``` 211 | /api/books?_limit=50 212 | ``` 213 | To define the offset of the datasets in the result, use `_offset`. 214 | ``` 215 | /api/books?_offset=20&_limit=50 216 | ``` 217 | Be aware that in order to use `offset` you always have to specify a `limit` too. MySQL throws an error for offset definition without a limit. 218 | 219 | #### Include related models 220 | The api handler also supports Eloquent relationships. So if you want to get all the books with their authors, just add the authors to the `_with` parameter. 221 | ``` 222 | /api/books?_with=author 223 | ``` 224 | Relationships, can also be nested: 225 | ``` 226 | /api/books?_with=author.awards 227 | ``` 228 | 229 | To get this to work though you have to add the `@Relation` annotation to each of your relation methods like: 230 | 231 | ```php 232 | /** 233 | * @Relation 234 | */ 235 | public function author() { 236 | return $this->belongsTo('Author'); 237 | } 238 | ``` 239 | This is necessary for security reasons, so that only real relation methods can be invoked by using `_with`. 240 | 241 | ***Note:*** Whenever you limit the fields with `_fields` in combination with `_with`. Under the hood the fields are extended with the primary/foreign keys of the relation. Eloquent needs the linking keys to get related models. 242 | 243 | #### Include meta information 244 | It's possible to add additional information to a response. There are currently two types of counts which can be added to the response headers. 245 | 246 | The `total-count` which represents the count of all elements of a resource or to be more specific, the count on the originally passed query builder instance. 247 | The `filter-count` which additionally takes filters into account. They can for example be useful to implement pagination. 248 | 249 | ``` 250 | /api/books?id-gt=5&_config=meta-total-count,meta-filter-count 251 | ``` 252 | All meta fields are provided in the response header by default. 253 | The following custom headers are used: 254 | 255 | Config | Header 256 | ----------------- | ------------- 257 | meta-total-count | Meta-Total-Count 258 | meta-filter-count | Meta-Filter-Count 259 | 260 | #### Use an envelope for the response 261 | By default meta data is included in the response header. If you want to have everything together in the response body you can request a so called "envelope" 262 | either by including `response-envelope` in the `_config` parameter or by overriding the default `config.php` of the package. 263 | 264 | The envelope has the following structure: 265 | 266 | ```json 267 | { 268 | "meta": { 269 | 270 | }, 271 | "data": [ 272 | 273 | ] 274 | } 275 | ``` -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "marcelgwerder/laravel-api-handler", 3 | "description": "Package providing helper functions for a Laravel REST-API ", 4 | "keywords": ["api", "laravel", "rest", "url", "parse", "mysql"], 5 | "homepage": "http://github.com/marcelgwerder/laravel-api-handler", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Marcel Gwerder", 10 | "email": "info@marcelgwerder.ch" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=5.4.0", 15 | "illuminate/support": "~5.0", 16 | "illuminate/database": "~5.0", 17 | "illuminate/http": "~5.0" 18 | }, 19 | "require-dev": { 20 | "phpunit/phpunit": "~4.5", 21 | "mockery/mockery": "~0.9" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Marcelgwerder\\ApiHandler\\": "src" 26 | } 27 | }, 28 | "minimum-stability": "stable" 29 | } -------------------------------------------------------------------------------- /config/apihandler.php: -------------------------------------------------------------------------------- 1 | '_', 16 | 17 | /* 18 | |-------------------------------------------------------------------------- 19 | | Envelope 20 | |-------------------------------------------------------------------------- 21 | | 22 | | Define whether to use an envelope for meta data or not. By default the 23 | | meta data will be in the response header not in the body. 24 | | 25 | */ 26 | 27 | 'envelope' => false, 28 | 29 | /* 30 | |-------------------------------------------------------------------------- 31 | | Fulltext Search 32 | |-------------------------------------------------------------------------- 33 | | 34 | | The type of fulltext search, either "default" or "native". 35 | | Native fulltext search for InnoDB tables is only supported by MySQL versions >= 5.6. 36 | | 37 | */ 38 | 39 | 'fulltext' => 'default', 40 | 41 | /* 42 | |-------------------------------------------------------------------------- 43 | | Fulltext Search Score Column 44 | |-------------------------------------------------------------------------- 45 | | 46 | | The name of the column containing the fulltext search score in native 47 | | fulltext search mode. 48 | | 49 | */ 50 | 51 | 'fulltext_score_column' => '_score', 52 | 53 | /* 54 | |-------------------------------------------------------------------------- 55 | | Cleanup Relations in the Response 56 | |-------------------------------------------------------------------------- 57 | | 58 | | By default, the API Handler returns all loaded relations of the models, 59 | | even if they are not in the '_with' param or explicitly loaded with 60 | | $builder->with(). These unexpected relations can come from custom 61 | | accessor methods or when you access a relation in your code in general. 62 | | 63 | | If you want these to get removed from the response, set this value 64 | | to true so only the ones in the '_with' param or explicitly added with 65 | | $builder->with('relation') get returned. 66 | | 67 | */ 68 | 69 | 'cleanup_relations' => false, 70 | 71 | /* 72 | |-------------------------------------------------------------------------- 73 | | Errors 74 | |-------------------------------------------------------------------------- 75 | | 76 | | These arrays define the default error messages and the corresponding http 77 | | status codes. 78 | | 79 | */ 80 | 81 | 'errors' => [ 82 | 'ResourceNotFound' => ['http_code' => 404, 'message' => 'The requested resource could not be found but may be available again in the future.'], 83 | 'InternalError' => ['http_code' => 500, 'message' => 'Internal server error'], 84 | 'Unauthorized' => ['http_code' => 401, 'message' => 'Authentication is required and has failed or has not yet been provided'], 85 | 'Forbidden' => ['http_code' => 403, 'message' => 'You don\'t have enough permissions to access this resource'], 86 | 'ToManyRequests' => ['http_code' => 429, 'message' => 'You have sent too many requests in a specific timespan'], 87 | 'InvalidInput' => ['http_code' => 400, 'message' => 'The submited data is not valid'], 88 | 'InvalidQueryParameter' => ['http_code' => 400, 'message' => 'Invalid parameter'], 89 | 'UnknownResourceField' => ['http_code' => 400, 'message' => 'Unknown field ":field"'], 90 | 'UnknownResourceRelation' => ['http_code' => 400, 'message' => 'Unknown relation ":relation"'], 91 | ], 92 | 93 | /* 94 | |-------------------------------------------------------------------------- 95 | | Predefined Errors 96 | |-------------------------------------------------------------------------- 97 | | 98 | | Link the errors the api handler uses internaly with the the respective 99 | | error above. 100 | | 101 | */ 102 | 103 | 'internal_errors' => [ 104 | 'UnknownResourceField' => 'UnknownResourceField', 105 | 'UnknownResourceRelation' => 'UnknownResourceRelation', 106 | 'UnsupportedQueryParameter' => 'UnsupportedQueryParameter', 107 | 'InvalidQueryParameter' => 'InvalidQueryParameter', 108 | ], 109 | ]; 110 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./tests/ 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/ApiHandler.php: -------------------------------------------------------------------------------- 1 | parse($identification); 24 | 25 | return new Result($parser); 26 | } 27 | 28 | /** 29 | * Return a new Result object for multiple datasets 30 | * 31 | * @param mixed $queryBuilder Some kind of query builder instance 32 | * @param array $fullTextSearchColumns Columns to search in fulltext search 33 | * @param array|boolean $queryParams A list of query parameter 34 | * @return Result 35 | */ 36 | public function parseMultiple($queryBuilder, $fullTextSearchColumns = array(), $queryParams = false) 37 | { 38 | if ($queryParams === false) { 39 | $queryParams = Input::get(); 40 | } 41 | 42 | $parser = new Parser($queryBuilder, $queryParams); 43 | $parser->parse($fullTextSearchColumns, true); 44 | 45 | return new Result($parser); 46 | } 47 | 48 | /** 49 | * Return a new "created" response object 50 | * 51 | * @param array|object $object 52 | * @return Response 53 | */ 54 | public function created($object) 55 | { 56 | return Response::json($object, 201); 57 | } 58 | 59 | /** 60 | * Return a new "updated" response object 61 | * 62 | * @param array|object $object 63 | * @return Response 64 | */ 65 | public function updated($object = null) 66 | { 67 | if ($object != null) { 68 | return Response::json($object, 200); 69 | } else { 70 | return Response::make(null, 204); 71 | } 72 | } 73 | 74 | /** 75 | * Return a new "deleted" response object 76 | * 77 | * @param array|object $object 78 | * @return Response 79 | */ 80 | public function deleted($object = null) 81 | { 82 | if ($object != null) { 83 | return Response::json($object, 200); 84 | } else { 85 | return Response::make(null, 204); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/ApiHandlerException.php: -------------------------------------------------------------------------------- 1 | make('config'); 22 | $errors = $config->get('apihandler.errors'); 23 | $internalErrors = $config->get('apihandler.internal_errors'); 24 | 25 | //Check if error is internal or not 26 | if (isset($internalErrors[$code])) { 27 | $code = $internalErrors[$code]; 28 | } 29 | 30 | $error = $errors[$code]; 31 | 32 | if ($message == null) { 33 | $message = $error['message']; 34 | } 35 | 36 | $this->httpCode = $error['http_code']; 37 | $this->code = $code; 38 | 39 | //Replace replacement values 40 | foreach ($replace as $key => $value) { 41 | $message = str_replace(':' . $key, $value, $message); 42 | } 43 | 44 | parent::__construct($message); 45 | } 46 | 47 | /** 48 | * Get the http code of the exception 49 | * 50 | * @return int|string 51 | */ 52 | public function getHttpCode() 53 | { 54 | return $this->httpCode; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ApiHandlerServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 22 | __DIR__ . '/../config/apihandler.php', 'apihandler' 23 | ); 24 | } 25 | 26 | /** 27 | * Register the service provider. 28 | * 29 | * @return void 30 | */ 31 | public function register() 32 | { 33 | $this->app->bind('ApiHandler', 'Marcelgwerder\ApiHandler\ApiHandler'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/CountMetaProvider.php: -------------------------------------------------------------------------------- 1 | builder = $builder; 22 | } else if(method_exists($builder, 'toBase')) { 23 | // Laravel > 5.2 as global scopes, toBase makes sure they are included. 24 | $this->builder = $builder->toBase(); 25 | } else { 26 | // Laravel < 5.2 did not have global scopes and thus the query builder is enough. 27 | $this->builder = $builder->getQuery(); 28 | } 29 | 30 | $this->title = $title; 31 | 32 | //Remove offset from builder because a count doesn't work in combination with an offset 33 | $this->builder->offset = null; 34 | 35 | //Remove orders from builder because they are irrelevant for counts and can cause errors with renamed columns 36 | $this->builder->orders = null; 37 | } 38 | 39 | /** 40 | * Get the meta information 41 | * 42 | * @return string 43 | */ 44 | public function get() 45 | { 46 | if (!empty($this->builder->groups)) { 47 | //Only a count column is required 48 | $this->builder->columns = []; 49 | $this->builder->selectRaw('count(*) as aggregate'); 50 | $this->builder->limit = null; 51 | 52 | //Use the original builder as a subquery and count over it because counts over groups return the number of rows for each group, not for the total results 53 | $query = $this->builder->newQuery()->selectRaw('count(*) as aggregate from (' . $this->builder->toSql() . ') as count_table', $this->builder->getBindings()); 54 | 55 | return intval($query->first()->aggregate); 56 | } 57 | 58 | return intval($this->builder->count()); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Facades/ApiHandler.php: -------------------------------------------------------------------------------- 1 | title; 20 | } 21 | 22 | /** 23 | * Get the meta information 24 | * 25 | * @return string 26 | */ 27 | abstract public function get(); 28 | } 29 | -------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | builder = $builder; 131 | $this->params = $params; 132 | 133 | $this->prefix = Config::get('apihandler.prefix'); 134 | $this->envelope = Config::get('apihandler.envelope'); 135 | 136 | $isEloquentModel = is_subclass_of($builder, '\Illuminate\Database\Eloquent\Model'); 137 | $isEloquentRelation = is_subclass_of($builder, '\Illuminate\Database\Eloquent\Relations\Relation'); 138 | 139 | $this->isEloquentBuilder = $builder instanceof EloquentBuilder; 140 | $this->isQueryBuilder = $builder instanceof QueryBuilder; 141 | 142 | if ($this->isEloquentBuilder) { 143 | $this->query = $builder->getQuery(); 144 | } else if ($isEloquentRelation) { 145 | $this->builder = $builder->getQuery(); 146 | $this->query = $builder->getBaseQuery(); 147 | $this->isEloquentBuilder = true; 148 | } else if ($isEloquentModel) { 149 | //Convert the model to a builder object 150 | $this->builder = $builder->newQuery(); 151 | 152 | $this->query = $this->builder->getQuery(); 153 | 154 | $this->isEloquentBuilder = true; 155 | } else if ($this->isQueryBuilder) { 156 | $this->query = $builder; 157 | } else { 158 | throw new InvalidArgumentException('The builder argument has to the wrong type.'); 159 | } 160 | 161 | $this->originalBuilder = clone $this->builder; 162 | $this->originalQuery = clone $this->query; 163 | } 164 | 165 | /** 166 | * Parse the query parameters with the given options. 167 | * Either for a single dataset or multiple. 168 | * 169 | * @param mixed $options 170 | * @param boolean $multiple 171 | * @return void 172 | */ 173 | public function parse($options, $multiple = false) 174 | { 175 | $this->multiple = $multiple; 176 | 177 | if ($multiple) { 178 | $fullTextSearchColumns = array_map([$this, 'getQualifiedColumnName'], $options); 179 | 180 | //Parse and apply offset using the laravel "offset" function 181 | if ($offset = $this->getParam('offset')) { 182 | $offset = intval($offset); 183 | $this->query->offset($offset); 184 | } 185 | 186 | //Parse and apply limit using the laravel "limit" function 187 | if ($limit = $this->getParam('limit')) { 188 | $limit = intval($limit); 189 | $this->query->limit($limit); 190 | } 191 | 192 | //Parse and apply the filters using the different laravel "where" functions 193 | //Every parameter that has not a predefined functionality gets parsed as a filter 194 | if ($filterParams = $this->getFilterParams()) { 195 | $this->parseFilter($filterParams); 196 | } 197 | 198 | //Parse an apply the fulltext search using the different laravel "where" functions 199 | //The fulltext search is only applied to the columns passed by $fullTextSearchColumns. 200 | if ($this->getParam('q') !== false) { 201 | $this->parseFulltextSearch($this->getParam('q'), $fullTextSearchColumns); 202 | } 203 | } else { 204 | $identification = $options; 205 | 206 | if (is_array($identification)) { 207 | foreach ($identification as $column => $value) { 208 | $this->query->where($column, $value); 209 | } 210 | } else { 211 | if($this->isEloquentBuilder) { 212 | $primaryKey = $this->builder->getModel()->getQualifiedKeyName(); 213 | } else { 214 | $primaryKey = $this->getQualifiedColumnName('id'); 215 | } 216 | 217 | $this->query->where($primaryKey, $identification); 218 | } 219 | } 220 | 221 | //Parse and apply field elements using the laravel "select" function 222 | //The needed fields for the with function (Primary and foreign keys) have to be added accordingly 223 | if ($fields = $this->getParam('fields')) { 224 | $this->parseFields($fields); 225 | } 226 | 227 | //Parse and apply sort elements using the laravel "orderBy" function 228 | if ($sort = $this->getParam('sort')) { 229 | $this->parseSort($sort); 230 | } 231 | 232 | //Parse and apply with elements using the Laravel "with" function 233 | if (($with = $this->getParam('with')) && $this->isEloquentBuilder) { 234 | $this->parseWith($with); 235 | } 236 | 237 | //Parse the config params 238 | if ($config = $this->getParam('config')) { 239 | $this->parseConfig($config); 240 | } 241 | 242 | if ($this->isEloquentBuilder) { 243 | //Attach the query builder object back to the eloquent builder object 244 | $this->builder->setQuery($this->query); 245 | } 246 | } 247 | 248 | /** 249 | * Set the config object 250 | * 251 | * @param mixed $config 252 | */ 253 | public function setConfigHandler($config) 254 | { 255 | $this->config = $config; 256 | } 257 | 258 | /** 259 | * Get a parameter 260 | * 261 | * @param string $param 262 | * @return string|boolean 263 | */ 264 | protected function getParam($param) 265 | { 266 | if (isset($this->params[$this->prefix.$param])) { 267 | return $this->params[$this->prefix.$param]; 268 | } 269 | 270 | return false; 271 | } 272 | 273 | /** 274 | * Get the relevant filter parameters 275 | * 276 | * @return array|boolean 277 | */ 278 | protected function getFilterParams() 279 | { 280 | $reserved = array_fill_keys($this->functions, true); 281 | $prefix = $this->prefix; 282 | 283 | $filterParams = array_diff_ukey($this->params, $reserved, function ($a, $b) use ($prefix) { 284 | return $a != $prefix.$b; 285 | }); 286 | 287 | if (count($filterParams) > 0) { 288 | return $filterParams; 289 | } 290 | 291 | return false; 292 | } 293 | 294 | /** 295 | * Parse the fields parameter and return an array of fields 296 | * 297 | * @param string $fieldsParam 298 | * @return void 299 | */ 300 | protected function parseFields($fieldsParam) 301 | { 302 | $fields = []; 303 | 304 | foreach (explode(',', $fieldsParam) as $field) { 305 | //Only add the fields that are on the base resource 306 | if (strpos($field, '.') === false) { 307 | $fields[] = trim($field); 308 | } else { 309 | $this->additionalFields[] = trim($field); 310 | } 311 | } 312 | 313 | if (count($fields) > 0) { 314 | $this->query->addSelect($fields); 315 | } 316 | 317 | if (is_array($this->query->columns)) { 318 | $this->query->columns = array_diff($this->query->columns, ['*']); 319 | } 320 | } 321 | 322 | /** 323 | * Parse the with parameter 324 | * 325 | * @param string $withParam 326 | * @return void 327 | */ 328 | protected function parseWith($withParam) 329 | { 330 | $fields = $this->query->columns; 331 | $fieldsCount = count($fields ?: []); 332 | $baseModel = $this->builder->getModel(); 333 | 334 | $withHistory = []; 335 | 336 | foreach (explode(',', $withParam) as $with) { 337 | //Use ArrayObject to be able to copy the array (for array_splice) 338 | $parts = new ArrayObject(explode('.', $with)); 339 | $lastKey = count($parts) - 1; 340 | 341 | for ($i = 0; $i <= $lastKey; $i++) { 342 | $part = $parts[$i]; 343 | $partsCopy = $parts->getArrayCopy(); 344 | 345 | //Get the previous history path (e.g. if current is a.b.c the previous is a.b) 346 | $previousHistoryPath = implode('.', array_splice($partsCopy, 0, $i)); 347 | 348 | //Get the current history part based on the previous one 349 | $currentHistoryPath = $previousHistoryPath ? $previousHistoryPath.'.'.$part : $part; 350 | 351 | //Create new history element 352 | if (!isset($withHistory[$currentHistoryPath])) { 353 | $withHistory[$currentHistoryPath] = ['fields' => []]; 354 | } 355 | 356 | //Get all given fields related to the current part 357 | $withHistory[$currentHistoryPath]['fields'] = array_filter($this->additionalFields, function ($field) use ($part) { 358 | return preg_match('/'.$part.'\..+$/', $field); 359 | }); 360 | 361 | //Get all given sorts related to the current part 362 | $withHistory[$currentHistoryPath]['sorts'] = array_filter($this->additionalSorts, function ($pair) use ($part) { 363 | return preg_match('/'.$part.'\..+$/', $pair[0]); 364 | }); 365 | 366 | if (!isset($previousModel)) { 367 | $previousModel = $baseModel; 368 | } 369 | 370 | //Throw a new ApiHandlerException if the relation doesn't exist 371 | //or is not properly marked as a relation 372 | if (!$this->isRelation($previousModel, $part)) { 373 | throw new ApiHandlerException('UnknownResourceRelation', ['relation' => $part]); 374 | } 375 | 376 | $relation = call_user_func([$previousModel, $part]); 377 | $relationType = $this->getRelationType($relation); 378 | 379 | if ($relationType === 'BelongsTo') { 380 | $firstKey = $relation->getQualifiedForeignKey(); 381 | $secondKey = $relation->getQualifiedParentKeyName(); 382 | } else if ($relationType === 'HasMany' || $relationType === 'HasOne') { 383 | $firstKey = $relation->getQualifiedParentKeyName(); 384 | if (method_exists($relation, 'getQualifiedForeignKeyName')) { 385 | $secondKey = $relation->getQualifiedForeignKeyName(); 386 | } else { 387 | // compatibility for laravel < 5.4 388 | $secondKey = $relation->getForeignKey(); 389 | } 390 | } else if ($relationType === 'BelongsToMany') { 391 | $firstKey = $relation->getQualifiedParentKeyName(); 392 | $secondKey = $relation->getRelated()->getQualifiedKeyName(); 393 | } else if ($relationType === 'HasManyThrough') { 394 | if (method_exists($relation, 'getQualifiedLocalKeyName')) { 395 | $firstKey = $relation->getQualifiedLocalKeyName(); 396 | } else if (method_exists($relation, 'getExistenceCompareKey')) { 397 | // compatibility for laravel 5.4 398 | $firstKey = $relation->getExistenceCompareKey(); 399 | } else { 400 | // compatibility for laravel < 5.4 401 | $firstKey = $relation->getHasCompareKey(); 402 | } 403 | $secondKey = null; 404 | } else { 405 | die('Relation type not supported!'); 406 | } 407 | 408 | //Check if we're on level 1 (e.g. a and not a.b) 409 | if ($firstKey !== null && $previousHistoryPath == '') { 410 | if ($fieldsCount > 0 && !in_array($firstKey, $fields)) { 411 | $fields[] = $firstKey; 412 | } 413 | } else if ($firstKey !== null) { 414 | if (count($withHistory[$previousHistoryPath]['fields']) > 0 && !in_array($firstKey, $withHistory[$previousHistoryPath]['fields'])) { 415 | $withHistory[$previousHistoryPath]['fields'][] = $firstKey; 416 | } 417 | } 418 | 419 | if ($secondKey !== null && count($withHistory[$currentHistoryPath]['fields']) > 0 && !in_array($secondKey, $withHistory[$currentHistoryPath]['fields'])) { 420 | $withHistory[$currentHistoryPath]['fields'][] = $secondKey; 421 | } 422 | 423 | $previousModel = $relation->getModel(); 424 | } 425 | 426 | unset($previousModel); 427 | } 428 | 429 | //Apply the withHistory to using the laravel "with" function 430 | $withsArr = []; 431 | 432 | foreach ($withHistory as $withHistoryKey => $withHistoryValue) { 433 | $withsArr[$withHistoryKey] = function ($query) use ($withHistory, $withHistoryKey) { 434 | 435 | //Reduce field values to fieldname 436 | $fields = array_map(function ($field) { 437 | $pos = strpos($field, '.'); 438 | return $pos !== false ? substr($field, $pos + 1) : $field; 439 | }, $withHistory[$withHistoryKey]['fields']); 440 | 441 | if (count($fields) > 0 && is_array($fields)) { 442 | $query->select($fields); 443 | } 444 | 445 | //Attach sorts 446 | foreach ($withHistory[$withHistoryKey]['sorts'] as $pair) { 447 | call_user_func_array([$query, 'orderBy'], $pair); 448 | } 449 | }; 450 | } 451 | 452 | $this->builder->with($withsArr); 453 | 454 | //Merge the base fields 455 | if (count($fields ?: []) > 0) { 456 | if (!is_array($this->query->columns)) { 457 | $this->query->columns = []; 458 | } 459 | 460 | $this->query->columns = array_merge($this->query->columns, $fields); 461 | } 462 | } 463 | 464 | /** 465 | * Parse the sort param and determine whether the sorting is ascending or descending. 466 | * A descending sort has a leading "-". Apply it to the query. 467 | * 468 | * @param string $sortParam 469 | * @return void 470 | */ 471 | protected function parseSort($sortParam) 472 | { 473 | foreach (explode(',', $sortParam) as $sortElem) { 474 | //Check if ascending or derscenting(-) sort 475 | if (preg_match('/^-.+/', $sortElem)) { 476 | $direction = 'desc'; 477 | } else { 478 | $direction = 'asc'; 479 | } 480 | 481 | $column = $this->getQualifiedColumnName(preg_replace('/^-/', '', $sortElem)); 482 | $pair = [$column, $direction]; 483 | 484 | //Only add the sorts that are on the base resource 485 | if (strpos($sortElem, '.') === false) { 486 | call_user_func_array([$this->query, 'orderBy'], $pair); 487 | } else { 488 | $this->additionalSorts[] = $pair; 489 | } 490 | } 491 | } 492 | 493 | /** 494 | * Parse the remaining filter params 495 | * 496 | * @param array $filterParams 497 | * @return void 498 | */ 499 | protected function parseFilter($filterParams) 500 | { 501 | $supportedPostfixes = [ 502 | 'st' => '<', 503 | 'gt' => '>', 504 | 'min' => '>=', 505 | 'max' => '<=', 506 | 'lk' => 'LIKE', 507 | 'not-lk' => 'NOT LIKE', 508 | 'in' => 'IN', 509 | 'not-in' => 'NOT IN', 510 | 'not' => '!=', 511 | ]; 512 | 513 | $supportedPrefixesStr = implode('|', $supportedPostfixes); 514 | $supportedPostfixesStr = implode('|', array_keys($supportedPostfixes)); 515 | 516 | foreach ($filterParams as $filterParamKey => $filterParamValue) { 517 | $keyMatches = []; 518 | 519 | //Matches every parameter with an optional prefix and/or postfix 520 | //e.g. not-title-lk, title-lk, not-title, title 521 | $keyRegex = '/^(?:('.$supportedPrefixesStr.')-)?(.*?)(?:-('.$supportedPostfixesStr.')|$)/'; 522 | 523 | preg_match($keyRegex, $filterParamKey, $keyMatches); 524 | 525 | if (!isset($keyMatches[3])) { 526 | if (strtolower(trim($filterParamValue)) == 'null') { 527 | $comparator = 'NULL'; 528 | } else { 529 | $comparator = '='; 530 | } 531 | } else { 532 | if (strtolower(trim($filterParamValue)) == 'null') { 533 | $comparator = 'NOT NULL'; 534 | } else { 535 | $comparator = $supportedPostfixes[$keyMatches[3]]; 536 | } 537 | } 538 | 539 | $column = $this->getQualifiedColumnName($keyMatches[2]); 540 | 541 | if ($comparator == 'IN') { 542 | $values = explode(',', $filterParamValue); 543 | 544 | $this->query->whereIn($column, $values); 545 | } else if ($comparator == 'NOT IN') { 546 | $values = explode(',', $filterParamValue); 547 | 548 | $this->query->whereNotIn($column, $values); 549 | } else { 550 | $values = explode('|', $filterParamValue); 551 | 552 | if (count($values) > 1) { 553 | $this->query->where(function ($query) use ($column, $comparator, $values) { 554 | foreach ($values as $value) { 555 | if ($comparator == 'LIKE' || $comparator == 'NOT LIKE') { 556 | $value = preg_replace('/(^\*|\*$)/', '%', $value); 557 | } 558 | 559 | //Link the filters with AND of there is a "not" and with OR if there's none 560 | if ($comparator == '!=' || $comparator == 'NOT LIKE') { 561 | $query->where($column, $comparator, $value); 562 | } else { 563 | $query->orWhere($column, $comparator, $value); 564 | } 565 | } 566 | }); 567 | } else { 568 | $value = $values[0]; 569 | 570 | if ($comparator == 'LIKE' || $comparator == 'NOT LIKE') { 571 | $value = preg_replace('/(^\*|\*$)/', '%', $value); 572 | } 573 | 574 | if ($comparator == 'NULL' || $comparator == 'NOT NULL') { 575 | $this->query->whereNull($column, 'and', $comparator == 'NOT NULL'); 576 | } else { 577 | $this->query->where($column, $comparator, $value); 578 | } 579 | } 580 | } 581 | } 582 | } 583 | 584 | /** 585 | * Parse the fulltext search parameter q 586 | * 587 | * @param string $qParam 588 | * @param array $fullTextSearchColumns 589 | * @return void 590 | */ 591 | protected function parseFullTextSearch($qParam, $fullTextSearchColumns) 592 | { 593 | if ($qParam == '') { 594 | //Add where that will never be true 595 | $this->query->whereRaw('0 = 1'); 596 | 597 | return; 598 | } 599 | 600 | $fulltextType = Config::get('apihandler.fulltext'); 601 | 602 | if ($fulltextType == 'native') { 603 | //Use pdo's quote method to be protected against sql-injections. 604 | //The usual placeholders unfortunately don't seem to work using AGAINST(). 605 | $qParam = $this->query->getConnection()->getPdo()->quote($qParam); 606 | 607 | //Use native fulltext search 608 | $this->query->whereRaw('MATCH('.implode(',', $fullTextSearchColumns).') AGAINST("'.$qParam.'" IN BOOLEAN MODE)'); 609 | 610 | //Add the * to the selects because of the score column 611 | if (count($this->query->columns) == 0) { 612 | $this->query->addSelect('*'); 613 | } 614 | 615 | //Add the score column 616 | $scoreColumn = Config::get('apihandler.fulltext_score_column'); 617 | $this->query->addSelect($this->query->raw('MATCH('.implode(',', $fullTextSearchColumns).') AGAINST("'.$qParam.'" IN BOOLEAN MODE) as `'.$scoreColumn.'`')); 618 | } else { 619 | $keywords = explode(' ', $qParam); 620 | 621 | //Use default php implementation 622 | $this->query->where(function ($query) use ($fullTextSearchColumns, $keywords) { 623 | foreach ($fullTextSearchColumns as $column) { 624 | foreach ($keywords as $keyword) { 625 | $query->orWhere($column, 'LIKE', '%'.$keyword.'%'); 626 | } 627 | } 628 | }); 629 | } 630 | } 631 | 632 | /** 633 | * Parse the meta parameter and prepare an array of meta provider objects. 634 | * 635 | * @param array $metaParam 636 | * @return void 637 | */ 638 | protected function parseConfig($configParam) 639 | { 640 | $configItems = explode(',', $configParam); 641 | 642 | foreach ($configItems as $configItem) { 643 | $configItem = trim($configItem); 644 | 645 | $pos = strpos($configItem, '-'); 646 | $cat = substr($configItem, 0, $pos); 647 | $option = substr($configItem, $pos + 1); 648 | 649 | if ($cat == 'mode') { 650 | if ($option == 'count') { 651 | $this->mode = 'count'; 652 | } 653 | } else if ($cat == 'meta') { 654 | if ($option == 'total-count') { 655 | $this->meta[] = new CountMetaProvider('Meta-Total-Count', $this->originalBuilder); 656 | } else if ($option == 'filter-count') { 657 | $this->meta[] = new CountMetaProvider('Meta-Filter-Count', $this->builder); 658 | } 659 | } else if ($cat == 'response') { 660 | if ($option == 'envelope') { 661 | $this->envelope = true; 662 | } else if ($option == 'default') { 663 | $this->envelope = false; 664 | } 665 | } 666 | } 667 | } 668 | 669 | /** 670 | * Determine the type of the Eloquent relation 671 | * 672 | * @param Illuminate\Database\Eloquent\Relations\Relation $relation 673 | * @return string 674 | */ 675 | protected function getRelationType($relation) 676 | { 677 | if ($relation instanceof HasOne) { 678 | return 'HasOne'; 679 | } 680 | 681 | if ($relation instanceof HasMany) { 682 | return 'HasMany'; 683 | } 684 | 685 | if ($relation instanceof BelongsTo) { 686 | return 'BelongsTo'; 687 | } 688 | 689 | if ($relation instanceof BelongsToMany) { 690 | return 'BelongsToMany'; 691 | } 692 | 693 | if ($relation instanceof HasManyThrough) { 694 | return 'HasManyThrough'; 695 | } 696 | 697 | if ($relation instanceof MorphOne) { 698 | return 'MorphOne'; 699 | } 700 | 701 | if ($relation instanceof MorphMany) { 702 | return 'MorphMany'; 703 | } 704 | } 705 | 706 | /** 707 | * Check if there exists a method marked with the "@Relation" 708 | * annotation on the given model. 709 | * 710 | * @param Illuminate\Database\Eloquent\Model $model 711 | * @param string $relationName 712 | * @return boolean 713 | */ 714 | protected function isRelation($model, $relationName) 715 | { 716 | if (!method_exists($model, $relationName)) { 717 | return false; 718 | } 719 | 720 | $reflextionObject = new ReflectionObject($model); 721 | $doc = $reflextionObject->getMethod($relationName)->getDocComment(); 722 | 723 | if ($doc && strpos($doc, '@Relation') !== false) { 724 | return true; 725 | } else { 726 | return false; 727 | } 728 | } 729 | 730 | /** 731 | * Get the qualified column name 732 | * 733 | * @param string $column 734 | * @param null $table 735 | * @return string 736 | */ 737 | protected function getQualifiedColumnName($column, $table = null) 738 | { 739 | //Check whether there is a matching column expression that contains an 740 | //alias and should therefore not be turned into a qualified column name. 741 | $isAlias = count(array_filter($this->query->columns ?: [], function($queryColumn) use ($column) { 742 | return preg_match('/.*[\s\'"`]as\s*[\s\'"`]' . $column . '[\'"`]?$/', trim($queryColumn)); 743 | })) > 0; 744 | 745 | if (strpos($column, '.') === false && !$isAlias) { 746 | return $table ?: $this->query->from.'.'.$column; 747 | } else { 748 | return $column; 749 | } 750 | } 751 | } 752 | -------------------------------------------------------------------------------- /src/Result.php: -------------------------------------------------------------------------------- 1 | parser = $parser; 33 | } 34 | 35 | /** 36 | * Return a laravel response object including the correct status code and headers 37 | * 38 | * @param bool $resultOrFail 39 | * @return Illuminate\Support\Facades\Response 40 | */ 41 | public function getResponse($resultOrFail = false) 42 | { 43 | $headers = $this->getHeaders(); 44 | 45 | // if the cleanup flag is not explicitly set, get the default from the config 46 | if ($this->cleanup === null) { 47 | $this->cleanup(Config::get('apihandler.cleanup_relations', false)); 48 | } 49 | 50 | if ($resultOrFail) { 51 | $result = $this->getResultOrFail(); 52 | } else { 53 | $result = $this->getResult(); 54 | } 55 | 56 | if ($this->parser->mode == 'count') { 57 | return Response::json($headers, 200, $headers); 58 | } else { 59 | if ($this->parser->envelope) { 60 | return Response::json([ 61 | 'meta' => $headers, 62 | 'data' => $result, 63 | ], 200); 64 | } else { 65 | return Response::json($result, 200, $headers); 66 | } 67 | 68 | } 69 | } 70 | 71 | /** 72 | * Return the query builder including the results 73 | * 74 | * @return Illuminate\Database\Query\Builder $result 75 | */ 76 | public function getResult() 77 | { 78 | if ($this->parser->multiple) { 79 | $result = $this->parser->builder->get(); 80 | 81 | if ($this->cleanup) { 82 | $result = $this->cleanupRelationsOnModels($result); 83 | } 84 | } else { 85 | $result = $this->parser->builder->first(); 86 | 87 | if ($this->cleanup) { 88 | $result = $this->cleanupRelations($result); 89 | } 90 | } 91 | 92 | return $result; 93 | } 94 | 95 | /** 96 | * Return the query builder including the result or fail if it could not be found 97 | * 98 | * @return Illuminate\Database\Query\Builder $result 99 | */ 100 | public function getResultOrFail() 101 | { 102 | if ($this->parser->multiple) { 103 | return $this->getResult(); 104 | } 105 | 106 | $result = $this->parser->builder->firstOrFail(); 107 | 108 | if ($this->cleanup) { 109 | $result = $this->cleanupRelations($result); 110 | } 111 | 112 | return $result; 113 | } 114 | 115 | /** 116 | * Get the query bulder object 117 | * 118 | * @return Illuminate\Database\Query\Builder 119 | */ 120 | public function getBuilder() 121 | { 122 | return $this->parser->builder; 123 | } 124 | 125 | /** 126 | * Get the headers 127 | * 128 | * @return array 129 | */ 130 | public function getHeaders() 131 | { 132 | $meta = $this->parser->meta; 133 | $headers = []; 134 | 135 | foreach ($meta as $provider) { 136 | if ($this->parser->envelope) { 137 | $headers[strtolower(str_replace('-', '_', preg_replace('/^Meta-/', '', $provider->getTitle())))] = $provider->get(); 138 | } else { 139 | $headers[$provider->getTitle()] = $provider->get(); 140 | } 141 | } 142 | 143 | return $headers; 144 | } 145 | 146 | /** 147 | * Get an array of meta providers 148 | * 149 | * @return array 150 | */ 151 | public function getMetaProviders() 152 | { 153 | return $this->parser->meta; 154 | } 155 | 156 | /** 157 | * Get the mode of the parser 158 | * 159 | * @return string 160 | */ 161 | public function getMode() 162 | { 163 | return $this->parser->mode; 164 | } 165 | 166 | /** 167 | * Set the cleanup flag 168 | * 169 | * @param $cleanup 170 | * @return $this 171 | */ 172 | public function cleanup($cleanup) 173 | { 174 | $this->cleanup = $cleanup; 175 | 176 | return $this; 177 | } 178 | 179 | /** 180 | * Cleanup the relations on a models array 181 | * 182 | * @param $models 183 | * @return array 184 | */ 185 | public function cleanupRelationsOnModels($models) 186 | { 187 | $response = []; 188 | 189 | if ($models instanceof Collection) { 190 | foreach ($models as $model) { 191 | $response[] = $this->cleanupRelations($model); 192 | } 193 | } 194 | 195 | return $response; 196 | } 197 | 198 | /** 199 | * Cleanup the relations on a single model 200 | * 201 | * @param $model 202 | * @return mixed 203 | */ 204 | public function cleanupRelations($model) 205 | { 206 | if (!($model instanceof Model)) { 207 | return $model; 208 | } 209 | 210 | // get the relations which already exists on the model (e.g. with $builder->with()) 211 | $allowedRelations = array_fill_keys($this->getRelationsRecursively($model), true); 212 | 213 | // parse the model to an array and get the relations which got added unintentionally 214 | // (e.g. when accessing a relation in an accessor method or somewhere else) 215 | $response = $model->toArray(); 216 | $loadedRelations = array_fill_keys($this->getRelationsRecursively($model), true); 217 | 218 | // remove the unintentionally added relations from the response 219 | return $this->removeUnallowedRelationsFromResponse($response, $allowedRelations, $loadedRelations); 220 | } 221 | 222 | /** 223 | * Get all currently loaded relations on a model recursively 224 | * 225 | * @param $model 226 | * @param null $prefix 227 | * @return array 228 | */ 229 | protected function getRelationsRecursively($model, $prefix = null) 230 | { 231 | $loadedRelations = $model->getRelations(); 232 | $relations = []; 233 | 234 | foreach ($loadedRelations as $key => $relation) { 235 | $relations[] = ($prefix ?: '') . $key; 236 | $relationModel = $model->{$key}; 237 | 238 | // if the relation is a collection, just use the first element as all elements of a relation collection are from the same model 239 | if ($relationModel instanceof Collection) { 240 | if (count($relationModel) > 0) { 241 | $relationModel = $relationModel[0]; 242 | } else { 243 | continue; 244 | } 245 | } 246 | 247 | // get the relations of the child model 248 | if ($relationModel instanceof Model) { 249 | $relations = array_merge($relations, $this->getRelationsRecursively($relationModel, ($prefix ?: '') . $key . '.')); 250 | } 251 | } 252 | 253 | return $relations; 254 | } 255 | 256 | /** 257 | * Remove all relations which are in the $loadedRelations but not in $allowedRelations from the model array 258 | * 259 | * @param $response 260 | * @param $allowedRelations 261 | * @param $loadedRelations 262 | * @param null $prefix 263 | * @return mixed 264 | */ 265 | protected function removeUnallowedRelationsFromResponse($response, $allowedRelations, $loadedRelations, $prefix = null) 266 | { 267 | foreach ($response as $key => $attr) { 268 | $relationKey = ($prefix ?: '') . $key; 269 | 270 | // handle associative arrays as they 271 | if (isset($loadedRelations[$relationKey])) { 272 | if (!isset($allowedRelations[$relationKey])) { 273 | unset($response[$key]); 274 | } else if (is_array($attr)) { 275 | $response[$key] = $this->removeUnallowedRelationsFromResponse($response[$key], $allowedRelations, $loadedRelations, ($prefix ?: '') . $relationKey . '.'); 276 | } 277 | 278 | // just pass numeric arrays to the method again as they may contain additional relations in their values 279 | } else if (is_array($attr) && is_numeric($key)) { 280 | $response[$key] = $this->removeUnallowedRelationsFromResponse($response[$key], $allowedRelations, $loadedRelations, $prefix); 281 | } 282 | } 283 | 284 | return $response; 285 | } 286 | 287 | /** 288 | * Convert the result to its string representation. 289 | * 290 | * @return string 291 | */ 292 | public function __toString() 293 | { 294 | return $this->getResponse()->__toString(); 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /tests/ApiHandlerTest.php: -------------------------------------------------------------------------------- 1 | params = [ 19 | //Fields 20 | '_fields' => 'title,description,comments.title,user.first_name', 21 | //Filters 22 | 'title-lk' => 'Example Title|Another Title', 23 | 'title' => 'Example Title', 24 | 'title-not-lk' => 'Example Title', 25 | 'title-not' => 'Example Title|Another Title', 26 | 'id-min' => 5, 27 | 'id-max' => 6, 28 | 'id-gt' => 7, 29 | 'id-st' => 8, 30 | 'id-in' => '1,2', 31 | 'id-not-in' => '3,4', 32 | //Pagination 33 | '_limit' => 5, 34 | '_offset' => 10, 35 | //With 36 | '_with' => 'comments.user', 37 | //Sort 38 | '_sort' => '-title,first_name,comments.created_at', 39 | //Config 40 | '_config' => 'mode-default,meta-filter-count,meta-total-count', 41 | ]; 42 | 43 | $this->fulltextSelectExpression = new Expression('MATCH(posts.title,posts.description) AGAINST("Something to search" IN BOOLEAN MODE) as `_score`'); 44 | 45 | //Test data 46 | $this->data = [ 47 | ['foo' => 'A1', 'bar' => 'B1'], 48 | ['foo' => 'A2', 'bar' => 'B2'], 49 | ]; 50 | 51 | //Mock the application 52 | $app = m::mock('AppMock'); 53 | $app->shouldReceive('instance')->once()->andReturn($app); 54 | Illuminate\Support\Facades\Facade::setFacadeApplication($app); 55 | 56 | //Mock the config 57 | $config = m::mock('ConfigMock'); 58 | $config->shouldReceive('get')->once() 59 | ->with('apihandler.prefix')->andReturn('_'); 60 | $config->shouldReceive('get')->once() 61 | ->with('apihandler.envelope')->andReturn(false); 62 | $config->shouldReceive('get')->once() 63 | ->with('apihandler.fulltext')->andReturn('default'); 64 | $config->shouldReceive('get')->once() 65 | ->with('apihandler.fulltext')->andReturn('native'); 66 | $config->shouldReceive('get')->once() 67 | ->with('apihandler.fulltext_score_column')->andReturn('_score'); 68 | $config->shouldReceive('get')->once() 69 | ->with('apihandler.cleanup_relations', false)->andReturn(false); 70 | Config::swap($config); 71 | 72 | $app->shouldReceive('make')->once()->andReturn($config); 73 | 74 | //Mock the input 75 | $input = m::mock('InputMock'); 76 | $input->shouldReceive('get')->once() 77 | ->with()->andReturn($this->params); 78 | Input::swap($input); 79 | 80 | //Mock the response 81 | $response = m::mock('ResponseMock'); 82 | $response->shouldReceive('json')->once()->andReturn(new JsonResponse(['meta' => [], 'data' => new Collection()])); 83 | Response::swap($response); 84 | 85 | //Mock pdo 86 | $pdo = m::mock('PdoMock'); 87 | $pdo->shouldReceive('quote')->once() 88 | ->with('Something to search')->andReturn('Something to search'); 89 | 90 | //Mock the connection the same way as laravel does: 91 | //tests/Database/DatabaseEloquentBuilderTest.php#L408-L418 (mockConnectionForModel($model, $database)) 92 | $grammar = new Illuminate\Database\Query\Grammars\MySqlGrammar; 93 | $processor = new Illuminate\Database\Query\Processors\MySqlProcessor; 94 | $connection = m::mock('Illuminate\Database\ConnectionInterface', ['getQueryGrammar' => $grammar, 'getPostProcessor' => $processor]); 95 | $connection->shouldReceive('select')->once()->with('select * from `posts`', [])->andReturn($this->data); 96 | $connection->shouldReceive('select')->once()->with('select * from `posts`', [], true)->andReturn($this->data); 97 | $connection->shouldReceive('raw')->once()->with('MATCH(posts.title,posts.description) AGAINST("Something to search" IN BOOLEAN MODE) as `_score`') 98 | ->andReturn($this->fulltextSelectExpression); 99 | $connection->shouldReceive('getPdo')->once()->andReturn($pdo); 100 | 101 | $resolver = m::mock('Illuminate\Database\ConnectionResolverInterface', ['connection' => $connection]); 102 | 103 | Post::setConnectionResolver($resolver); 104 | 105 | $this->apiHandler = new ApiHandler(); 106 | } 107 | 108 | public function testParseSingle() 109 | { 110 | $post = new Post(); 111 | $result = $this->apiHandler->parseSingle($post, 5, []); 112 | 113 | $this->assertInstanceOf('Marcelgwerder\ApiHandler\Result', $result); 114 | } 115 | 116 | public function testParseMultiple() 117 | { 118 | $post = new Post(); 119 | $result = $this->apiHandler->parseMultiple($post, [], []); 120 | 121 | $this->assertInstanceOf('Marcelgwerder\ApiHandler\Result', $result); 122 | } 123 | 124 | public function testGetBuilder() 125 | { 126 | 127 | $post = new Post(); 128 | 129 | $builder = $this->apiHandler->parseMultiple($post, ['title', 'description'], $this->params)->getBuilder(); 130 | $queryBuilder = $builder->getQuery(); 131 | 132 | // 133 | // Fields 134 | // 135 | 136 | $columns = $queryBuilder->columns; 137 | 138 | $this->assertContains('description', $columns); 139 | $this->assertContains('title', $columns); 140 | 141 | // 142 | // Filters 143 | // 144 | 145 | $wheres = $queryBuilder->wheres; 146 | 147 | //Test the nested filters 148 | foreach ($wheres as $where) { 149 | if ($where['type'] == 'Nested') { 150 | $query = $where['query']; 151 | $subWheres = $query->wheres; 152 | 153 | if ($subWheres[0]['boolean'] == 'and') { 154 | //assert for title-not 155 | $this->assertEquals(['type' => 'Basic', 'column' => 'posts.title', 'operator' => '!=', 'value' => 'Example Title', 'boolean' => 'and'], $subWheres[0]); 156 | $this->assertEquals(['type' => 'Basic', 'column' => 'posts.title', 'operator' => '!=', 'value' => 'Another Title', 'boolean' => 'and'], $subWheres[1]); 157 | } else { 158 | //assert for title-lk 159 | $this->assertEquals(['type' => 'Basic', 'column' => 'posts.title', 'operator' => 'LIKE', 'value' => 'Example Title', 'boolean' => 'or'], $subWheres[0]); 160 | $this->assertEquals(['type' => 'Basic', 'column' => 'posts.title', 'operator' => 'LIKE', 'value' => 'Another Title', 'boolean' => 'or'], $subWheres[1]); 161 | } 162 | 163 | } 164 | } 165 | 166 | //assert for title 167 | $this->assertContains(['type' => 'Basic', 'column' => 'posts.title', 'operator' => '=', 'value' => 'Example Title', 'boolean' => 'and'], $wheres); 168 | //assert for title-not-lk 169 | $this->assertContains(['type' => 'Basic', 'column' => 'posts.title', 'operator' => 'NOT LIKE', 'value' => 'Example Title', 'boolean' => 'and'], $wheres); 170 | 171 | //assert for id-min 172 | $this->assertContains(['type' => 'Basic', 'column' => 'posts.id', 'operator' => '>=', 'value' => 5, 'boolean' => 'and'], $wheres); 173 | 174 | //assert for id-max 175 | $this->assertContains(['type' => 'Basic', 'column' => 'posts.id', 'operator' => '<=', 'value' => 6, 'boolean' => 'and'], $wheres); 176 | 177 | //assert for id-gt 178 | $this->assertContains(['type' => 'Basic', 'column' => 'posts.id', 'operator' => '>', 'value' => 7, 'boolean' => 'and'], $wheres); 179 | 180 | //assert for id-st 181 | $this->assertContains(['type' => 'Basic', 'column' => 'posts.id', 'operator' => '<', 'value' => 8, 'boolean' => 'and'], $wheres); 182 | 183 | //assert for id-in 184 | $this->assertContains(['type' => 'In', 'column' => 'posts.id', 'values' => ['1', '2'], 'boolean' => 'and'], $wheres); 185 | 186 | //assert for id-not-in 187 | $this->assertContains(['type' => 'NotIn', 'column' => 'posts.id', 'values' => ['3', '4'], 'boolean' => 'and'], $wheres); 188 | 189 | // 190 | // Limit 191 | // 192 | 193 | $limit = $queryBuilder->limit; 194 | $this->assertEquals($this->params['_limit'], $limit); 195 | 196 | // 197 | // Offset 198 | // 199 | 200 | $offset = $queryBuilder->offset; 201 | $this->assertEquals($this->params['_offset'], $offset); 202 | 203 | // 204 | // Sort 205 | // 206 | 207 | $orders = $queryBuilder->orders; 208 | $this->assertContains(['column' => 'posts.title', 'direction' => 'desc'], $orders); 209 | $this->assertContains(['column' => 'posts.first_name', 'direction' => 'asc'], $orders); 210 | 211 | // 212 | //With 213 | // 214 | 215 | $eagerLoads = $builder->getEagerLoads(); 216 | 217 | $this->assertArrayHasKey('comments', $eagerLoads); 218 | $this->assertArrayHasKey('comments.user', $eagerLoads); 219 | 220 | //Check if auto fields are set on the base query 221 | $this->assertContains('posts.id', $columns); 222 | 223 | //Check if fields are set on the "comments" relation query 224 | $query = $post->newQuery(); 225 | call_user_func($eagerLoads['comments'], $query); 226 | $columns = $query->getQuery()->columns; 227 | 228 | $this->assertContains('title', $columns); 229 | $this->assertContains('customfk_post_id', $columns); 230 | $this->assertContains('user_id', $columns); 231 | 232 | //Check if fields are set on the "comments.user" relation query 233 | $query = $post->newQuery(); 234 | call_user_func($eagerLoads['comments.user'], $query); 235 | $columns = $query->getQuery()->columns; 236 | 237 | $this->assertContains('id', $columns); 238 | $this->assertContains('first_name', $columns); 239 | 240 | //Check if sorts are set on the "comments" relation query 241 | $query = $post->newQuery(); 242 | call_user_func($eagerLoads['comments'], $query); 243 | $orders = $query->getQuery()->orders; 244 | $this->assertContains(['column' => 'comments.created_at', 'direction' => 'asc'], $orders); 245 | 246 | // 247 | // Default fulltext search 248 | // 249 | 250 | $builder = $this->apiHandler->parseMultiple($post, ['title', 'description'], ['_q' => 'Something to search'])->getBuilder(); 251 | $queryBuilder = $builder->getQuery(); 252 | 253 | $wheres = $queryBuilder->wheres; 254 | 255 | //Test the nested filters 256 | foreach ($wheres as $where) { 257 | if ($where['type'] == 'Nested') { 258 | $query = $where['query']; 259 | $subWheres = $query->wheres; 260 | 261 | $this->assertEquals(['type' => 'Basic', 'column' => 'posts.title', 'operator' => 'LIKE', 'value' => '%Something%', 'boolean' => 'or'], $subWheres[0]); 262 | $this->assertEquals(['type' => 'Basic', 'column' => 'posts.title', 'operator' => 'LIKE', 'value' => '%to%', 'boolean' => 'or'], $subWheres[1]); 263 | $this->assertEquals(['type' => 'Basic', 'column' => 'posts.title', 'operator' => 'LIKE', 'value' => '%search%', 'boolean' => 'or'], $subWheres[2]); 264 | $this->assertEquals(['type' => 'Basic', 'column' => 'posts.description', 'operator' => 'LIKE', 'value' => '%Something%', 'boolean' => 'or'], $subWheres[3]); 265 | $this->assertEquals(['type' => 'Basic', 'column' => 'posts.description', 'operator' => 'LIKE', 'value' => '%to%', 'boolean' => 'or'], $subWheres[4]); 266 | $this->assertEquals(['type' => 'Basic', 'column' => 'posts.description', 'operator' => 'LIKE', 'value' => '%search%', 'boolean' => 'or'], $subWheres[5]); 267 | } 268 | } 269 | 270 | // 271 | // Native fulltext search 272 | // 273 | 274 | $builder = $this->apiHandler->parseMultiple($post, ['title', 'description'], ['_q' => 'Something to search', '_sort' => '_score'])->getBuilder(); 275 | $queryBuilder = $builder->getQuery(); 276 | 277 | //Test alias column in sort 278 | $orders = $queryBuilder->orders; 279 | $this->assertContains(['column' => '_score', 'direction' => 'asc'], $orders); 280 | 281 | //Test the where 282 | $wheres = $queryBuilder->wheres; 283 | $this->assertEquals(['type' => 'raw', 'sql' => 'MATCH(posts.title,posts.description) AGAINST("Something to search" IN BOOLEAN MODE)', 'boolean' => 'and'], $wheres[0]); 284 | 285 | //Test the select 286 | $columns = $queryBuilder->columns; 287 | $this->assertContains($this->fulltextSelectExpression, $columns); 288 | $this->assertContains('*', $columns); 289 | } 290 | 291 | public function testGetResponse() 292 | { 293 | $post = new Post(); 294 | 295 | $response = $this->apiHandler->parseMultiple($post, ['title', 'description'], ['_config' => 'response-envelope'])->getResponse(); 296 | $data = $response->getData(); 297 | 298 | $this->assertInstanceOf('Illuminate\Http\JsonResponse', $response); 299 | $this->assertObjectHasAttribute('meta', $data); 300 | $this->assertObjectHasAttribute('data', $data); 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /tests/Comment.php: -------------------------------------------------------------------------------- 1 | belongsTo('Post'); 16 | } 17 | 18 | /** 19 | * @Relation 20 | */ 21 | public function user() 22 | { 23 | return $this->belongsTo('User'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Post.php: -------------------------------------------------------------------------------- 1 | hasMany('Comment', 'customfk_post_id'); 16 | } 17 | } -------------------------------------------------------------------------------- /tests/User.php: -------------------------------------------------------------------------------- 1 | hasMany('Comment'); 16 | } 17 | } 18 | --------------------------------------------------------------------------------