|
| 1 | +# tobscure/json-api-server |
| 2 | + |
| 3 | +[](https://travis-ci.org/tobscure/json-api-server) |
| 4 | +[](https://github.com/tobscure/json-api-server/releases) |
| 5 | +[](https://packagist.org/packages/tobscure/json-api-server) |
| 6 | + |
| 7 | +**A fully automated framework-agnostic [JSON:API](http://jsonapi.org) server implementation in PHP.** |
| 8 | +Define your schema, plug in your models, and we'll take care of the rest. 🍻 |
| 9 | + |
| 10 | +```bash |
| 11 | +composer require tobscure/json-api-server |
| 12 | +``` |
| 13 | + |
| 14 | +```php |
| 15 | +use Tobscure\JsonApiServer\Api; |
| 16 | +use Tobscure\JsonApiServer\Adapter\EloquentAdapter; |
| 17 | +use Tobscure\JsonApiServer\Schema\Builder; |
| 18 | + |
| 19 | +$api = new Api('http://example.com/api'); |
| 20 | + |
| 21 | +$api->resource('articles', new EloquentAdapter(new Article), function (Builder $schema) { |
| 22 | + $schema->attribute('title'); |
| 23 | + $schema->hasOne('author', 'people'); |
| 24 | + $schema->hasMany('comments'); |
| 25 | +}); |
| 26 | + |
| 27 | +$api->resource('people', new EloquentAdapter(new User), function (Builder $schema) { |
| 28 | + $schema->attribute('firstName'); |
| 29 | + $schema->attribute('lastName'); |
| 30 | + $schema->attribute('twitter'); |
| 31 | +}); |
| 32 | + |
| 33 | +$api->resource('comments', new EloquentAdapter(new Comment), function (Builder $schema) { |
| 34 | + $schema->attribute('body'); |
| 35 | + $schema->hasOne('author', 'people'); |
| 36 | +}); |
| 37 | + |
| 38 | +/** @var Psr\Http\Message\ServerRequestInterface $request */ |
| 39 | +/** @var Psr\Http\Message\Response $response */ |
| 40 | +try { |
| 41 | + $response = $api->handle($request); |
| 42 | +} catch (Exception $e) { |
| 43 | + $response = $api->error($e); |
| 44 | +} |
| 45 | +``` |
| 46 | + |
| 47 | +Assuming you have a few [Eloquent](https://laravel.com/docs/5.7/eloquent) models set up, the above code will serve a **complete JSON:API that conforms to the [spec](https://jsonapi.org/format/)**, including support for: |
| 48 | + |
| 49 | +- **Showing** individual resources (`GET /api/articles/1`) |
| 50 | +- **Listing** resource collections (`GET /api/articles`) |
| 51 | +- **Sorting**, **filtering**, **pagination**, and **sparse fieldsets** |
| 52 | +- **Compound documents** with inclusion of related resources |
| 53 | +- **Creating** resources (`POST /api/articles`) |
| 54 | +- **Updating** resources (`PATCH /api/articles/1`) |
| 55 | +- **Deleting** resources (`DELETE /api/articles/1`) |
| 56 | +- **Error handling** |
| 57 | + |
| 58 | +The schema definition is extremely powerful and lets you easily apply [permissions](#visibility), [getters](#getters), [setters](#setters-savers), [validation](#validation), and custom [filtering](#filtering) and [sorting](#sorting) logic to build a fully functional API in minutes. |
| 59 | + |
| 60 | +### Handling Requests |
| 61 | + |
| 62 | +```php |
| 63 | +use Tobscure\JsonApiServer\Api; |
| 64 | + |
| 65 | +$api = new Api('http://example.com/api'); |
| 66 | + |
| 67 | +try { |
| 68 | + $response = $api->handle($request); |
| 69 | +} catch (Exception $e) { |
| 70 | + $response = $api->error($e); |
| 71 | +} |
| 72 | +``` |
| 73 | + |
| 74 | +`Tobscure\JsonApiServer\Api` is a [PSR-15 Request Handler](https://www.php-fig.org/psr/psr-15/). Instantiate it with your API's base URL. Convert your framework's request object into a [PSR-7 Request](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface) implementation, then let the `Api` handler take it from there. Catch any exceptions and give them back to `Api` if you want a JSON:API error response. |
| 75 | + |
| 76 | +### Defining Resources |
| 77 | + |
| 78 | +Define your API's resources using the `resource` method. The first argument is the [resource type](https://jsonapi.org/format/#document-resource-object-identification). The second is an implementation of `Tobscure\JsonApiServer\Adapter\AdapterInterface` which will allow the handler to interact with your models. The third is a closure in which you'll build the schema for your resource. |
| 79 | + |
| 80 | +```php |
| 81 | +use Tobscure\JsonApiServer\Schema\Builder; |
| 82 | + |
| 83 | +$api->resource('comments', $adapter, function (Builder $schema) { |
| 84 | + // define your schema |
| 85 | +}); |
| 86 | +``` |
| 87 | + |
| 88 | +We provide an `EloquentAdapter` to hook your resources up with Laravel [Eloquent](https://laravel.com/docs/5.7/eloquent) models. Set it up with an instance of the model that your resource represents. You can [implement your own adapter](https://github.com/tobscure/json-api-server/blob/master/src/Adapter/AdapterInterface.php) if you use a different ORM. |
| 89 | + |
| 90 | +```php |
| 91 | +use Tobscure\JsonApiServer\Adapter\EloquentAdapter; |
| 92 | + |
| 93 | +$adapter = new EloquentAdapter(new User); |
| 94 | +``` |
| 95 | + |
| 96 | +### Attributes |
| 97 | + |
| 98 | +Define an [attribute field](https://jsonapi.org/format/#document-resource-object-attributes) on your resource using the `attribute` method: |
| 99 | + |
| 100 | +```php |
| 101 | +$schema->attribute('firstName'); |
| 102 | +``` |
| 103 | + |
| 104 | +By default the attribute will correspond to the property on your model with the same name. (`EloquentAdapter` will `snake_case` it automatically for you.) If you'd like it to correspond to a different property, provide it as a second argument: |
| 105 | + |
| 106 | +```php |
| 107 | +$schema->attribute('firstName', 'fname'); |
| 108 | +``` |
| 109 | + |
| 110 | +### Relationships |
| 111 | + |
| 112 | +Define [relationship fields](https://jsonapi.org/format/#document-resource-object-relationships) on your resource using the `hasOne` and `hasMany` methods: |
| 113 | + |
| 114 | +```php |
| 115 | +$schema->hasOne('user'); |
| 116 | +$schema->hasMany('comments'); |
| 117 | +``` |
| 118 | + |
| 119 | +By default the [resource type](https://jsonapi.org/format/#document-resource-object-identification) that the relationship corresponds to will be derived from the relationship name. In the example above, the `user` relationship would correspond to the `users` resource type, while `comments` would correspond to `comments`. If you'd like to use a different resource type, provide it as a second argument: |
| 120 | + |
| 121 | +```php |
| 122 | +$schema->hasOne('author', 'people'); |
| 123 | +``` |
| 124 | + |
| 125 | +Like attributes, the relationship will automatically read and write to the relation on your model with the same name. If you'd like it to correspond to a different relation, provide it as a third argument. |
| 126 | + |
| 127 | +Has-one relationships are available for [inclusion](https://jsonapi.org/format/#fetching-includes) via the `include` query parameter. You can include them by default, if the `include` query parameter is empty, by calling the `included` method: |
| 128 | + |
| 129 | +```php |
| 130 | +$schema->hasOne('user') |
| 131 | + ->included(); |
| 132 | +``` |
| 133 | + |
| 134 | +Has-many relationships must be explicitly made available for inclusion via the `includable` method. This is because pagination of included resources is not supported, so performance may suffer if there are large numbers of related resources. |
| 135 | + |
| 136 | +```php |
| 137 | +$schema->hasMany('comments') |
| 138 | + ->includable(); |
| 139 | +``` |
| 140 | + |
| 141 | +### Getters |
| 142 | + |
| 143 | +Use the `get` method to define custom retrieval logic for your field, instead of just reading the value straight from the model property. (Of course, if you're using Eloquent, you could also define [casts](https://laravel.com/docs/5.7/eloquent-mutators#attribute-casting) or [accessors](https://laravel.com/docs/5.7/eloquent-mutators#defining-an-accessor) on your model to achieve a similar thing.) |
| 144 | + |
| 145 | +```php |
| 146 | +$schema->attribute('firstName') |
| 147 | + ->get(function ($model, $request) { |
| 148 | + return ucfirst($model->first_name); |
| 149 | + }); |
| 150 | +``` |
| 151 | + |
| 152 | +### Visibility |
| 153 | + |
| 154 | +You can specify logic to restrict the visibility of a field using any one of the `visible`, `visibleIf`, `hidden`, and `hiddenIf` methods: |
| 155 | + |
| 156 | +```php |
| 157 | +$schema->attribute('email') |
| 158 | + // Make a field always visible (default) |
| 159 | + ->visible() |
| 160 | + |
| 161 | + // Make a field visible only if certain logic is met |
| 162 | + ->visibleIf(function ($model, $request) { |
| 163 | + return $model->id == $request->getAttribute('userId'); |
| 164 | + }) |
| 165 | + |
| 166 | + // Always hide a field (useful for write-only fields like password) |
| 167 | + ->hidden() |
| 168 | + |
| 169 | + // Hide a field only if certain logic is met |
| 170 | + ->hiddenIf(function ($model, $request) { |
| 171 | + return $request->getAttribute('userIsSuspended'); |
| 172 | + }); |
| 173 | +``` |
| 174 | + |
| 175 | +You can also restrict the visibility of the whole resource using the `scope` method. This will allow you to modify the query builder object provided by your adapter: |
| 176 | + |
| 177 | +```php |
| 178 | +$schema->scope(function ($query, $request) { |
| 179 | + $query->where('user_id', $request->getAttribute('userId')); |
| 180 | +}); |
| 181 | +``` |
| 182 | + |
| 183 | +### Making Fields Writable |
| 184 | + |
| 185 | +By default, fields are read-only. You can allow a field to be written to using any one of the `writable`, `writableIf`, `readonly`, and `readonlyIf` methods: |
| 186 | + |
| 187 | +```php |
| 188 | +$schema->attribute('email') |
| 189 | + // Make an attribute writable |
| 190 | + ->writable() |
| 191 | + |
| 192 | + // Make an attribute writable only if certain logic is met |
| 193 | + ->writableIf(function ($model, $request) { |
| 194 | + return $model->id == $request->getAttribute('userId'); |
| 195 | + }) |
| 196 | + |
| 197 | + // Make an attribute read-only (default) |
| 198 | + ->readonly() |
| 199 | + |
| 200 | + // Make an attribute writable *unless* certain logic is met |
| 201 | + ->readonlyIf(function ($model, $request) { |
| 202 | + return $request->getAttribute('userIsSuspended'); |
| 203 | + }); |
| 204 | +``` |
| 205 | + |
| 206 | +### Default Values |
| 207 | + |
| 208 | +You can provide a default value for a field to be used when creating a new resource if there is no value provided by the consumer. Pass a value or a closure to the `default` method: |
| 209 | + |
| 210 | + |
| 211 | +```php |
| 212 | +$schema->attribute('joinedAt') |
| 213 | + ->default(new DateTime); |
| 214 | + |
| 215 | +$schema->attribute('ipAddress') |
| 216 | + ->default(function ($request) { |
| 217 | + return $request->getServerParams()['REMOTE_ADDR'] ?? null; |
| 218 | + }); |
| 219 | +``` |
| 220 | + |
| 221 | +If you're using Eloquent, you could also define [default attribute values](https://laravel.com/docs/5.7/eloquent#default-attribute-values) to achieve a similar thing, although you wouldn't have access to the request object. |
| 222 | + |
| 223 | +### Validation |
| 224 | + |
| 225 | +You can ensure that data provided for a field is valid before it is saved. Provide a closure to the `validate` method, and call the first argument if validation fails: |
| 226 | + |
| 227 | +```php |
| 228 | +$schema->attribute('email') |
| 229 | + ->validate(function ($fail, $email) { |
| 230 | + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { |
| 231 | + $fail('Invalid email'); |
| 232 | + } |
| 233 | + }); |
| 234 | + |
| 235 | +$schema->hasMany('groups') |
| 236 | + ->validate(function ($fail, $groups) { |
| 237 | + foreach ($groups as $group) { |
| 238 | + if ($group->id === 1) { |
| 239 | + $fail('You cannot assign this group'); |
| 240 | + } |
| 241 | + } |
| 242 | + }); |
| 243 | +``` |
| 244 | + |
| 245 | +See [Macros](#macros) below to learn how to use Laravel's [Validation](https://laravel.com/docs/5.7/validation) component in your schema. |
| 246 | + |
| 247 | +### Setters & Savers |
| 248 | + |
| 249 | +Use the `set` method to define custom mutation logic for your field, instead of just setting the value straight on the model property. (Of course, if you're using Eloquent, you could also define [casts](https://laravel.com/docs/5.7/eloquent-mutators#attribute-casting) or [mutators](https://laravel.com/docs/5.7/eloquent-mutators#defining-a-mutator) on your model to achieve a similar thing.) |
| 250 | + |
| 251 | +```php |
| 252 | +$schema->attribute('firstName') |
| 253 | + ->set(function ($model, $value, $request) { |
| 254 | + return $model->first_name = strtolower($value); |
| 255 | + }); |
| 256 | +``` |
| 257 | + |
| 258 | +If your attribute corresponds to some other form of data storage rather than a simple property on your model, you can use the `save` method to provide a closure to be run _after_ your model is saved: |
| 259 | + |
| 260 | +```php |
| 261 | +$schema->attribute('locale') |
| 262 | + ->save(function ($model, $value, $request) { |
| 263 | + $model->preferences()->update(['value' => $value])->where('key', 'locale'); |
| 264 | + }); |
| 265 | +``` |
| 266 | + |
| 267 | +### Filtering |
| 268 | + |
| 269 | +You can define a field as `filterable` to allow the resource index to be [filtered](https://jsonapi.org/recommendations/#filtering) by the field's value. This works for both attributes and relationships: |
| 270 | + |
| 271 | +```php |
| 272 | +$schema->attribute('firstName') |
| 273 | + ->filterable(); |
| 274 | + |
| 275 | +$schema->hasMany('groups') |
| 276 | + ->filterable(); |
| 277 | + |
| 278 | +// e.g. GET /api/users?filter[firstName]=Toby&filter[groups]=1,2,3 |
| 279 | +``` |
| 280 | + |
| 281 | +You can optionally pass a closure to customize how the filter is applied to the query builder object provided by your adapter: |
| 282 | + |
| 283 | +```php |
| 284 | +$schema->attribute('minPosts') |
| 285 | + ->hidden() |
| 286 | + ->filterable(function ($query, $value, $request) { |
| 287 | + $query->where('postCount', '>=', $value); |
| 288 | + }); |
| 289 | +``` |
| 290 | + |
| 291 | +### Sorting |
| 292 | + |
| 293 | +You can define an attribute as `sortable` to allow the resource index to be [sorted](https://jsonapi.org/format/#fetching-sorting) by the attribute's value: |
| 294 | + |
| 295 | +```php |
| 296 | +$schema->attribute('firstName') |
| 297 | + ->sortable(); |
| 298 | + |
| 299 | +$schema->attribute('lastName') |
| 300 | + ->sortable(); |
| 301 | + |
| 302 | +// e.g. GET /api/users?sort=lastName,firstName |
| 303 | +``` |
| 304 | + |
| 305 | +### Pagination |
| 306 | + |
| 307 | +By default, resources are automatically [paginated](https://jsonapi.org/format/#fetching-pagination) with 20 records per page. You can change this limit using the `paginate` method on the schema builder: |
| 308 | + |
| 309 | +```php |
| 310 | +$schema->paginate(50); |
| 311 | +``` |
| 312 | + |
| 313 | +### Creating Resources |
| 314 | + |
| 315 | +By default, resources are not [creatable](https://jsonapi.org/format/#crud-creating) (i.e. `POST` requests will return `403 Forbidden`). You can allow them to be created using the `creatable`, `creatableIf`, `notCreatable`, and `notCreatableIf` methods on the schema builder: |
| 316 | + |
| 317 | +```php |
| 318 | +$schema->creatableIf(function ($request) { |
| 319 | + return $request->getAttribute('isAdmin'); |
| 320 | +}); |
| 321 | +``` |
| 322 | + |
| 323 | +### Deleting Resources |
| 324 | + |
| 325 | +By default, resources are not [deletable](https://jsonapi.org/format/#crud-deleting) (i.e. `DELETE` requests will return `403 Forbidden`). You can allow them to be deleted using the `deletable`, `deletableIf`, `notDeletable`, and `notDeletableIf` methods on the schema builder: |
| 326 | + |
| 327 | +```php |
| 328 | +$schema->deletableIf(function ($request) { |
| 329 | + return $request->getAttribute('isAdmin'); |
| 330 | +}); |
| 331 | +``` |
| 332 | + |
| 333 | +### Macros |
| 334 | + |
| 335 | +You can define macros on the `Tobscure\JsonApiServer\Schema\Attribute` class to aid construction of your API schema. Below is an example that sets up a `rules` macro which will add a validator to validate the attribute value using Laravel's [Validation](https://laravel.com/docs/5.7/validation) component: |
| 336 | + |
| 337 | +```php |
| 338 | +use Tobscure\JsonApiServer\Schema\Attribute; |
| 339 | + |
| 340 | +Attribute::macro('rules', function ($rules) use ($validator) { |
| 341 | + $this->validate(function ($fail, $value) use ($validator, $rules) { |
| 342 | + $key = $this->name; |
| 343 | + $validation = Validator::make([$key => $value], [$key => $rules]); |
| 344 | + |
| 345 | + if ($validation->fails()) { |
| 346 | + $fail((string) $validation->messages()); |
| 347 | + } |
| 348 | + }); |
| 349 | +}); |
| 350 | +``` |
| 351 | + |
| 352 | +```php |
| 353 | +$schema->attribute('username') |
| 354 | + ->rules(['required', 'min:3', 'max:30']); |
| 355 | +``` |
| 356 | + |
| 357 | +## Examples |
| 358 | + |
| 359 | +- [Flarum](https://github.com/flarum/core/tree/master/src/Api) is forum software that uses tobscure/json-api-server to power its API. |
| 360 | + |
| 361 | +## Contributing |
| 362 | + |
| 363 | +Feel free to send pull requests or create issues if you come across problems or have great ideas. See the [Contributing Guide](https://github.com/tobscure/json-api-server/blob/master/CONTRIBUTING.md) for more information. |
| 364 | + |
| 365 | +### Running Tests |
| 366 | + |
| 367 | +```bash |
| 368 | +$ vendor/bin/phpunit |
| 369 | +``` |
| 370 | + |
| 371 | +## License |
| 372 | + |
| 373 | +This code is published under the [The MIT License](LICENSE). This means you can do almost anything with it, as long as the copyright notice and the accompanying license file is left intact. |
0 commit comments