tobscure/json-api-server

This package is abandoned and no longer maintained. The author suggests using the tobyz/json-api-server package instead.

A fully automated JSON:API server implementation in PHP.

dev-master 2020-05-09 08:25 UTC

This package is auto-updated.

Last update: 2020-05-09 08:25:43 UTC


README

Pre Release License

A fully automated JSON:API server implementation in PHP.
Define your schema, plug in your models, and we'll take care of the rest. 🍻

Installation

composer require tobyz/json-api-server

Usage

use Tobyz\JsonApiServer\Adapter\EloquentAdapter;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Schema\Type;

$api = new JsonApi('http://example.com/api');

$api->resource('articles', new EloquentAdapter(Article::class), function (Type $type) {
    $type->attribute('title');
    $type->hasOne('author')->type('people');
    $type->hasMany('comments');
});

$api->resource('people', new EloquentAdapter(User::class), function (Type $type) {
    $type->attribute('firstName');
    $type->attribute('lastName');
    $type->attribute('twitter');
});

$api->resource('comments', new EloquentAdapter(Comment::class), function (Type $type) {
    $type->attribute('body');
    $type->hasOne('author')->type('people');
});

/** @var Psr\Http\Message\ServerRequestInterface $request */
/** @var Psr\Http\Message\Response $response */
try {
    $response = $api->handle($request);
} catch (Exception $e) {
    $response = $api->error($e);
}

Assuming you have a few Eloquent models set up, the above code will serve a complete JSON:API that conforms to the spec, including support for:

  • Showing individual resources (GET /api/articles/1)
  • Listing resource collections (GET /api/articles)
  • Sorting, filtering, pagination, and sparse fieldsets
  • Compound documents with inclusion of related resources
  • Creating resources (POST /api/articles)
  • Updating resources (PATCH /api/articles/1)
  • Deleting resources (DELETE /api/articles/1)
  • Error handling

The schema definition is extremely powerful and lets you easily apply permissions, getters, setters, validation, and custom filtering and sorting logic to build a fully functional API in minutes.

Handling Requests

use Tobyz\JsonApiServer\JsonApi;

$api = new JsonApi('http://example.com/api');

try {
    $response = $api->handle($request);
} catch (Exception $e) {
    $response = $api->error($e);
}

Tobyz\JsonApiServer\JsonApi is a PSR-15 Request Handler. Instantiate it with your API's base URL. Convert your framework's request object into a PSR-7 Request implementation, then let the JsonApi handler take it from there. Catch any exceptions and give them back to JsonApi to generate a JSON:API error response.

Defining Resources

Define your API's resources using the resource method. The first argument is the resource type. The second is an instance of Tobyz\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.

use Tobyz\JsonApiServer\Schema\Type;

$api->resource('comments', $adapter, function (Type $type) {
    // define your schema
});

We provide an EloquentAdapter to hook your resources up with Laravel Eloquent models. Set it up with the name of the model that your resource represents. You can implement your own adapter if you use a different ORM.

use Tobyz\JsonApiServer\Adapter\EloquentAdapter;

$adapter = new EloquentAdapter(User::class);

Attributes

Define an attribute field on your resource using the attribute method:

$type->attribute('firstName');

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, use the property method:

$type->attribute('firstName')
    ->property('fname');

Relationships

Define relationship fields on your resource using the hasOne and hasMany methods:

$type->hasOne('user');
$type->hasMany('comments');

By default the resource type 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, call the type method:

$type->hasOne('author')
    ->type('people');

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, use the property method.

Relationship Links

Relationships include self and related links automatically. For some relationships it may not make sense to have them accessible via their own URL; you may disable these links by calling the noLinks method:

$type->hasOne('mostRelevantPost')
    ->noLinks();

Note: Accessing these URLs is not yet implemented.

Relationship Linkage

By default relationships include no resource linkage. You can toggle this by calling the linkage or noLinkage methods.

$type->hasOne('user')
    ->linkage();

Warning: Be careful when enabling linkage on to-many relationships as pagination is not supported.

Relationship Inclusion

To make a relationship available for inclusion via the include query parameter, call the includable method.

$type->hasOne('user')
    ->includable();

Warning: Be careful when making to-many relationships includable as pagination is not supported.

Relationships included via the include query parameter are automatically eager-loaded by the adapter. However, you may wish to define your own eager-loading logic, or prevent a relationship from being eager-loaded. You can do so using the loadable and notLoadable methods:

$type->hasOne('user')
    ->includable()
    ->loadable(function ($models, ServerRequestInterface $request) {
        collect($models)->load(['user' => function () { /* constraints */ }]);
    });

$type->hasOne('user')
    ->includable()
    ->notLoadable();

Polymorphic Relationships

Define a relationship as polymorphic using the polymorphic method:

$type->hasOne('commentable')
    ->polymorphic();

$type->hasMany('taggable')
    ->polymorphic();

This will mean that the resource type associated with the relationship will be derived from the model of each related resource. Consequently, nested includes cannot be requested on these relationships.

Getters

Use the get method to define custom retrieval logic for your field, instead of just reading the value straight from the model property. (If you're using Eloquent, you could also define attribute casts or accessors on your model to achieve a similar thing.)

$type->attribute('firstName')
    ->get(function ($model, ServerRequestInterface $request) {
        return ucfirst($model->first_name);
    });

Visibility

Resource Visibility

You can 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:

$type->scope(function ($query, ServerRequestInterface $request, string $id = null) {
    $query->where('user_id', $request->getAttribute('userId'));
});

The third argument to this callback ($id) is only populated if the request is to access a single resource. If the request is to a resource listing, it will be null.

If you want to prevent listing the resource altogether (ie. return 403 Forbidden from GET /articles), you can use the notListable method:

$type->notListable();

Field Visibility

You can specify logic to restrict the visibility of a field using the visible and hidden methods:

$type->attribute('email')
    // Make a field always visible (default)
    ->visible()

    // Make a field visible only if certain logic is met
    ->visible(function ($model, ServerRequestInterface $request) {
        return $model->id == $request->getAttribute('userId');
    })

    // Always hide a field (useful for write-only fields like password)
    ->hidden()

    // Hide a field only if certain logic is met
    ->hidden(function ($model, ServerRequestInterface $request) {
        return $request->getAttribute('userIsSuspended');
    });

Expensive Fields

If a field is particularly expensive to calculate (for example, if you define a custom getter which runs a query), you can opt to only show the field when a single resource has been requested (ie. the field will not be included on resource listings). Use the single method to do this:

$type->attribute('expensive')
    ->single();

Writability

By default, fields are read-only. You can allow a field to be written to via PATCH and POST requests using the writable and readonly methods:

$type->attribute('email')
    // Make an attribute writable
    ->writable()

    // Make an attribute writable only if certain logic is met
    ->writable(function ($model, ServerRequestInterface $request) {
        return $model->id == $request->getAttribute('userId');
    })

    // Make an attribute read-only (default)
    ->readonly()

    // Make an attribute writable *unless* certain logic is met
    ->readonly(function ($model, ServerRequestInterface $request) {
        return $request->getAttribute('userIsSuspended');
    });

Default Values

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:

$type->attribute('joinedAt')
    ->default(new DateTime);

$type->attribute('ipAddress')
    ->default(function (ServerRequestInterface $request) {
        return $request->getServerParams()['REMOTE_ADDR'] ?? null;
    });

If you're using Eloquent, you could also define default attribute values to achieve a similar thing (although you wouldn't have access to the request object).

Validation

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:

$type->attribute('email')
    ->validate(function (callable $fail, $email, $model, ServerRequestInterface $request) {
        if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $fail('Invalid email');
        }
    });

This works for relationships too – the related models will be retrieved via your adapter and passed into your validation function.

$type->hasMany('groups')
    ->validate(function (callable $fail, array $groups, $model, ServerRequestInterface $request) {
        foreach ($groups as $group) {
            if ($group->id === 1) {
                $fail('You cannot assign this group');
            }
        }
    });

You can easily use Laravel's Validation component for field validation with the rules function:

use Tobyz\JsonApiServer\Laravel\rules;

$type->attribute('username')
    ->validate(rules('required', 'min:3', 'max:30'));

Setters & Savers

Use the set method to define custom mutation logic for your field, instead of just setting the value straight on the model property. (If you're using Eloquent, you could also define attribute casts or mutators on your model to achieve a similar thing.)

$type->attribute('firstName')
    ->set(function ($model, $value, ServerRequestInterface $request) {
        $model->first_name = strtolower($value);
    });

If your field 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:

$type->attribute('locale')
    ->save(function ($model, $value, ServerRequestInterface $request) {
        $model->preferences()
            ->where('key', 'locale')
            ->update(['value' => $value]);
    });

Filtering

You can define a field as filterable to allow the resource index to be filtered by the field's value. This works for both attributes and relationships:

$type->attribute('firstName')
    ->filterable();

$type->hasMany('groups')
    ->filterable();
    
// eg. GET /api/users?filter[firstName]=Toby&filter[groups]=1,2,3

The EloquentAdapter automatically parses and applies >, >=, <, <=, and .. operators on attribute filter values, so you can do:

GET /api/users?filter[postCount]=>=10
GET /api/users?filter[postCount]=5..15

To define filters with custom logic, or ones that do not correspond to an attribute, use the filter method:

$type->filter('minPosts', function ($query, $value, ServerRequestInterface $request) {
    $query->where('postCount', '>=', $value);
});

Sorting

You can define an attribute as sortable to allow the resource index to be sorted by the attribute's value:

$type->attribute('firstName')
    ->sortable();
    
$type->attribute('lastName')
    ->sortable();
    
// e.g. GET /api/users?sort=lastName,firstName

You can set a default sort string to be used when the consumer has not supplied one using the defaultSort method on the schema builder:

$type->defaultSort('-updatedAt,-createdAt');

To define sort fields with custom logic, or ones that do not correspond to an attribute, use the sort method:

$type->sort('relevance', function ($query, string $direction, ServerRequestInterface $request) {
    $query->orderBy('relevance', $direction);
});

Pagination

By default, resource listings are automatically paginated with 20 records per page. You can change this amount using the paginate method on the schema builder, or you can remove it by calling the dontPaginate method:

$type->paginate(50); // default to listing 50 resources per page
$type->dontPaginate(); // default to listing all resources

Consumers may request a different limit using the page[limit] query parameter. By default the maximum possible limit is capped at 50; you can change this cap using the limit method, or you can remove it by calling the noLimit method:

$type->limit(100); // set the maximum limit for resources per page to 100
$type->noLimit(); // remove the maximum limit for resources per page

Countability

By default a query will be performed to count the total number of resources in a collection. This will be used to populate a total attribute in the document's meta object, as well as the last pagination link. For some types of resources, or when a query is resource-intensive (especially when certain filters or sorting is applied), it may be undesirable to have this happen. So it can be toggled using the countable and uncountable methods:

$type->countable();
$type->uncountable();

Meta Information

You can add meta information to any resource or relationship field using the meta method:

$type->meta('requestTime', function (ServerRequestInterface $request) {
    return new DateTime;
});

Creating Resources

By default, resources are not creatable (ie. POST requests will return 403 Forbidden). You can allow them to be created using the creatable and notCreatable methods on the schema builder. Pass a closure that returns true if the resource should be creatable, or no value to have it always creatable.

$type->creatable();

$type->creatable(function (ServerRequestInterface $request) {
    return $request->getAttribute('isAdmin');
});

Customizing the Model

When creating a resource, an empty model is supplied by the adapter. You may wish to override this and provide a custom model in special circumstances. You can do so using the createModel method:

$type->createModel(function (ServerRequestInterface $request) {
    return new CustomModel;
});

Updating Resources

By default, resources are not updatable (i.e. PATCH requests will return 403 Forbidden). You can allow them to be updated using the updatable and notUpdatable methods on the schema builder:

$type->updatable();

$type->updatable(function (ServerRequestInterface $request) {
    return $request->getAttribute('isAdmin');
});

Deleting Resources

By default, resources are not deletable (i.e. DELETE requests will return 403 Forbidden). You can allow them to be deleted using the deletable and notDeletable methods on the schema builder:

$type->deletable();

$type->deletable(function (ServerRequestInterface $request) {
    return $request->getAttribute('isAdmin');
});

Events

The server will fire several events, allowing you to hook into the following points in a resource's lifecycle: listing, listed, showing, shown, creating, created, updating, updated, deleting, deleted. (If you're using Eloquent, you could also use model events to achieve a similar thing, although you wouldn't have access to the request object.)

To listen for an event, simply call the matching method name on the schema and pass a closure to be executed, which will receive the model and the request:

$type->onCreating(function ($model, ServerRequestInterface $request) {
    // do something before a new model is saved
});

Authentication

You are responsible for performing your own authentication. An effective way to pass information about the authenticated user is by setting attributes on your request object before passing it into the request handler.

You should indicate to the server if the consumer is authenticated using the authenticated method. This is important because it will determine whether the response will be 401 Unauthorized or 403 Forbidden in the case of an unauthorized request.

$api->authenticated();

Examples

  • TODO

Contributing

Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.

License

MIT