emteknetnz / silverstripe-rest-api
Rest API for Silverstripe
Installs: 42
Dependents: 0
Suggesters: 0
Security: 0
Stars: 6
Watchers: 1
Forks: 0
Open Issues: 7
Type:silverstripe-vendormodule
Requires
- php: ^7.4 || ^8.0
- silverstripe/framework: ^4 || ^5
Requires (Dev)
- phpunit/phpunit: ^9.6
- silverstripe/versioned: ^1 || ^2
README
NOTE: This module is currently a pre-release. The API and/or behaviour may change as newer pre-release versions are tagged.
This module allows you to quickly and easily create secure REST API endpoints that can be used for both providing database records as JSON as well as optionally allow data to be updated through the API.
Simply subclass the RestApiEndpoint
class and define your endpoint with private static array
configuration.
An endpoint provides data for one DataObject type, for instance SiteTree
. To provide data for more DataObject types simply add more endpoints.
This module is not intended to replace regular Silverstripe controller endpoints if you endpoint provides non-DataObject data.
These instructions assume you have created a project that have silverstripe/recipe-cms
in the composer.json as some of the code instructions include the SiteTree
class. You can still use this module without silverstripe/recipe-cms
as silverstripe/framework
is the only requirement, though silverstripe/versioned
is required for the publish
, unpublish
and archive
actions.
Also a common gotcha, if your dataobject isn't showing in the JSON response then you probably need to add a canView()
to your dataobject that returns true
. Alternatively you can simply disable the canView()
check by setting CALL_CAN_METHODS
to CREATE_EDIT_DELETE_ACTION
(which lacks VIEW
) in your endpoint config.
Contents:
- Installation
- Quickstart
- Querying data
- HTTP requests and status codes
- Endpoint configuration options
- Relations
- Individual fields
- CSRF token
- API token
- Extension hooks
Installation
composer require emteknetnz/silverstripe-rest-api
Works on both Silverstripe CMS 4 and 5.
Quickstart
Copy paste the following code snippets to quickly setup a public readonly endpoint the provides SiteTree
data.
This assumes that you have an existing project created with silverstripe/cms
installed so that you have the SiteTree
class available.
src/MySiteTreeEndpoint.php
<?php use emteknetnz\RestApi\Controllers\RestApiEndpoint; use SilverStripe\CMS\Model\SiteTree; class MySiteTreeEndpoint extends RestApiEndpoint { private static array $api_config = [ RestApiEndpoint::PATH => 'api/pages', RestApiEndpoint::DATA_CLASS => SiteTree::class, RestApiEndpoint::ACCESS => RestApiEndpoint::PUBLIC, RestApiEndpoint::FIELDS => [ 'title' => 'Title', 'absoluteLink' => 'AbsoluteLink', 'content' => 'Content', 'lastEdited' => 'LastEdited', ], ]; }
Run https://mysite.test/dev/build?flush=1
Visit https://mysite.test/api/pages
to see endpoint data.
Visit https://mysite.test/api/pages/1
to see endpoint data for page with an ID
of 1
Querying data
Filter data by adding querystring parameters to a GET
request made to the endpoint
Filtering
To filter on an exact value:
?filter=[<field>]=<value>
The use a search filter such as PartialMatchFilter
use:
?filter=[<field>:<SearchFilter>]=<value>
When using a search filter in the querystring omit the 'Filter' suffix from its name. For example to use the StartsWithFilter
to search for titles starting with "Hello" use StartsWith
in the querystring:
?filter=[<title>:StartsWith]=Hello
To use a search filter modifier such as "case" use:
?filter=[<field>:<SearchFilter>:<Modifier>]=<value>
For example to return all pages with the word "About" in them matched case-sensitive. Note that Silverstripe ORM uses the nocase
search modifier by default if it is not specified.
?filter[title:PartialMatch:case]=About
To use multiple filters, for example to filter on the title
and lastEdited
fields:
?filter[title:PartialMatch]=rockets&filter[lastEdited:GreaterThan]=2022-01-01
The following search filters are available:
ExactMatch
StartsWith
EndsWith
PartialMatch
GreaterThan
GreaterThanOrEqual
LessThan
LessThanOrEqual
The following search filter modifiers are available:
case
no-case
not
Sorting
To sort by a field in ascending order:
?sort=<field>
To sort by a field in descending order:
?sort=-<field>
To sort by multiple fields use a comma to separate them:
?sort=-<field1>,<field2>
For example, to sort all pages by publishedYear descending first and title ascending second:
?sort=-publishedYear,title
Limiting and offsetting
To limit the number of records:
?limit=<number>
To offset records:
?offset=<number>
For example to get the second page of 10 records:
?limit=10&offset=10
The default limit is 30
, and the max limit that can be specified via the querystring is 100
. Both these limits can be changed in the endpoint config.
HTTP requests and status codes
Failure codes
The following failure codes are used in a variety of requests
Response body will be JSON with a "success":false
node and a "message"
node that describes the error.
OPTIONS
The OPTIONS
HTTP request is always allowed and will return a list of allowed operations for the endpoint in the allow
response header
GET
The GET
HTTP request is used to read data from the endpoint. You can view a list of nodes by visiting the endpoint URL, or view a single node by visiting the endpoint URL with the ID of the node. e.g.
Examples:
curl -X GET https://mysite.test/api/pages
curl -X GET https://mysite.test/api/pages/<id>
HEAD
The same as GET
though only returns the headers that would be be returned by GET
with an empty body
Examples:
curl --head https://mysite.test/api/pages
curl --head https://mysite.test/api/pages/123
POST
The POST
HTTP request is used to create a new record. The body of the request should be a JSON object with the data to create the record that matches the endpoint configuration i.e. specify the jsonKey
to update on the DataObject, not the dataObjectKey
.
Specifying field values is optional, though may be required depending on DataObject validation.
Example:
curl -X POST https://mysite.test/api/pages -d '{"title":"My title"}'
PATCH
The PATCH
HTTP request is used to update an existing record where ID is specified in the URL. The body of the request should be a JSON object with the data to update the record that matches the endpoint configuration i.e. specify the jsonKey
to update on the DataObject, not the dataObjectKey
.
Specifying field values is optional, though may be required depending on DataObject validation.
Example:
curl -X PATCH https://mysite.test/api/pages/123 -d '{"title":"My updated title"}'
DELETE
The DELETE
HTTP request is used to delete an existing record where ID is specified in the URL. If the Versioned
extension from the silverstripe/versioned
module has been applied to the DataObject then the doArchive()
method is called on the DataObject which deletes it from other the draft and live versions of the site. If the Versioned
extension has not bee applied then the delete()
method will be called on DataObject instead.
Example:
curl -X DELETE https://mysite.test/api/pages/123
PUT
The PUT
HTTP request is only used to run a predefined list of actions. It is NOT used to create or update data which PUT
is commonly used for in other REST API implementations. Instead use POST
or PATCH
respectively for create or update operations.
Actions can only be run on existing records. The action parameter is added to the URL after the ID of the existing record.
The following actions are available:
Example:
curl -X PUT https://mysite.test/api/pages/123/publish
Endpoint configuration options
Endpoint configuration is done using the private array static $api_config
field on your subclass of RestApiEndpoint
. Remember to ?flush=1
to apply the new configuration. The following table includes a list of configuration constants available on the RestApiEndpoint
class.
Relations
Your endpoint can contain data from relations on the dataobject, i.e. has_one
, has_many
or many_many
relations. Configuration for relations follows the same rules and the top-level configuration.
For each if there is a Team
class with a db
field Title
and a has_many
relation Players
, and the Player
class has a db
field LastName
, you can use the following endpoint configuration to show all the of the Players
on every Team
.
Note that when including relations, as there is no ability to paginate the relation data ALL relations will be included in the response, instead of the default limit which is 30
.
private static array $api_config = [ RestApiEndpoint::PATH = 'api/teams'; RestApiEndpoint::DATA_CLASS => Team::class, RestApiEndpoint::FIELDS => [ // db fields 'title' => 'Title', 'yearFounded' => 'YearFounded', // has_one relation 'city' => [ RestApiEndpoint::RELATION => 'City', RestApiEndpoint::FIELDS => [ 'name' => 'Name', ], ], ], // has_many relation 'players' => [ RestApiEndpoint::RELATION => 'Players', RestApiEndpoint::FIELDS => [ 'lastName' => 'LastName, ], ], ], ];
has_one
Relations that are defined in the endpoint configuration can be set via POST
or PATCH
requests using a magic field <jsonField>__ID
.
For the example above, to update the CityID
from 14
to 15
on an existing Team
DataObject with an ID
of 77
:
curl -X PATCH https://mysite.test/api/teams/77 -d '{"city__ID":"15"}'
Individual fields
The normal notation for including a field is <jsonField>
=> <dataObjectField>
. You can configure individual fields to have their own ACCESS
and ALLOWED_OPERATIONS
if you wish to restrict those fields. When doing this the notation changes to an array notation where DATA_OBJECT_FIELD
is what <dataObjectField>
is with the regular notation.
For example, to set the PrivateField
on the Team
class to only be accessible to logged in members who pass a permission check on a custom CAN_ACCESS_PRIVATE_FIELD
permission:
private static array $api_config = [ RestApiEndpoint::PATH = 'api/teams'; RestApiEndpoint::DATA_CLASS => Team::class, RestApiEndpoint::FIELDS => [ 'title' => 'Title', 'privateField' => [ RestApiEndPoint::DATA_OBJECT_FIELD => 'PrivateField', RestApiEndpoint::ACCESS => 'CAN_ACCESS_PRIVATE_FIELD', ], ], ];
CSRF token
If the endpoint ACCESS
is set to anything except PUBLIC
then an x-csrf-token
header needs to be sent with the request, unless an x-api-token
is sent instead. A valid token is generated by SecurityToken::getSecurityID()
. Within the CMS it is available to javascript from window.ss.config.SecurityID;
.
For instance the following javascript code will make a GET
request that includes the x-csrf-token
header when logged into Silverstripe CMS:
fetch( '/api/pages', { headers: { 'x-csrf-token': window.ss.config.SecurityID } } ) .then(response => response.json()) .then(responseJson => console.log(responseJson));
When working with non-public API endpoints you may wish to disable the csrf token check so that you can quickly test GET
queries in your browsers location bar. You can do this by calling SecurityToken::disable()
in app/_config.php
, though if you do this be very careful this isn't then disabled in production too. To be safe, wrap this in a check to an environment variable of your choosing that you set in your local .env file, for example:
use SilverStripe\Core\Environment; use SilverStripe\Security\SecurityToken; // ... if (Environment::getEnv('DISABLE_API_CSRF_TOKEN_CHECK')) { SecurityToken::disable(); }
The x-csrf-token
header is available as a constant on RestApiEndpoint::CSRF_TOKEN_HEADER
.
API token
Non-public API's can be configured to allow members to authenticate using an HTTP header instead of having to log in to the CMS.
If API authentication is used, the user will be logged in only for the duration of the request i.e. they will be logged out before the JSON response is returned.
This module provides a permission "Use an API token" which is API_TOKEN_AUTHENTICATION
which must be assigned to a group that users using API tokens must belong to. The endpoints ALLOW_API_TOKEN
config must be set to true
.
When a user and endpoint is set up to allow using an API token, pass an x-api-token
header with the value of the API Token to authenticate. Note that API token authentication will bypass MFA if it was set up for that user.
Setting up an API user and group using the CMS
Creating the API user and group
- Log in to the CMS as an administrator
- Go to the Security section
- Create a new group called "API Users"
- Click on the Permissions tab (top right)
- Tick "Use an API token" - this is the label for the permission code
API_TOKEN_AUTHENTICATION
- Save the Group
- Click "Add Member"
- Create a new user with a "First name" of "api-user", an Email of "api-user@example.com", and a long random password
- Assign them to the "Api Users" group
- Tick the "Generate new API token" checkbox and click "Save"
- Copy the API token that is generated - you will only be shown this once
Additional group permissions
The "api-user" still needs to pass all necessary permissions checks for the API to work i.e. so that canView()
checks still pass. You can either:
- Update the "API Users" group to have the necessary permissions, or
- Set the endpoints
CHECK_CAN_METHODS
toNONE
though you MUST ensure that the APIACCESS
is set to a permission code that is only assigned to dedicated api users.
Programmatically updating a users API token
Programmatically update a users API token with $member->refreshApiToken();
followed by $member->write();
. The returned value is the unencrypted API token. The members ApiToken
field will be the encrypted API token.
Note that for newly created users, $member->write()
must be called at least once before calling $member->refreshApiToken();
to ensure that the API token is properly encrypted.
Extension hooks
You may need to add custom logic to your API which can do with using the following extension hooks available in the table below. Implement a hook by adding one of these methods directly to your subclass of RestApiEndpoint
using the protected
visibility. You can also implement them on extension classes with a public
visibility.
For example the following implementation of the onEditBeforeWrite()
hook will update the Content
field of a DataObject updated via a PATCH
request before saving, even though the Content
field is not exposed in the API.
Note to run this code example you need to be logged in to the CMS to use and pass an x-csrf-token
header when making requests.
src/MySiteTreeEndpoint.php
<?php use emteknetnz\RestApi\Controllers\RestApiEndpoint; use SilverStripe\CMS\Model\SiteTree; use SilverStripe\ORM\DataObject; class MySiteTreeEndpoint extends RestApiEndpoint { private static array $api_config = [ RestApiEndpoint::PATH => 'api/pages', RestApiEndpoint::DATA_CLASS => SiteTree::class, RestApiEndpoint::ACCESS => RestApiEndpoint::LOGGED_IN, RestApiEndpoint::ALLOWED_OPERATIONS => RestApiEndpoint::VIEW_CREATE_EDIT_DELETE_ACTION, RestApiEndpoint::FIELDS => [ 'title' => 'Title', ], ]; protected function onEditBeforeWrite(SiteTree $page) { // You wouldn't normally do this, this is only for demo purposes $page->Content .= '<p>This was updated using the API</p>'; } }
Notes:
- If your extension hook updates the DataObject or another DataObject then it is likely you should use a different extension hook such as
onAfterWrite()
on the Dataobject itself rather than on the endpoint. This is because it usually shouldn't matter whether the object was created/updated/deleted via the API or a different way. These hooks are intended to facilitate the implementation of API specific code such as logging operations done via the API. - For the
onView*()
hooks if you are adding extra data to the JSON for the response, remember to callcanView()
for any DataObjects being added as required. - For both of the
onEdit*Write()
hooks the$changedFields
param is return value of$obj->getChangedFields()
before the object was written to.