emteknetnz/silverstripe-rest-api

Rest API for Silverstripe

Installs: 41

Dependents: 0

Suggesters: 0

Security: 0

Stars: 6

Watchers: 1

Forks: 0

Open Issues: 7

Type:silverstripe-vendormodule

0.1.8 2023-11-25 03:28 UTC

This package is auto-updated.

Last update: 2024-10-13 01:07:17 UTC


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

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

  1. Log in to the CMS as an administrator
  2. Go to the Security section
  3. Create a new group called "API Users"
  4. Click on the Permissions tab (top right)
  5. Tick "Use an API token" - this is the label for the permission code API_TOKEN_AUTHENTICATION
  6. Save the Group
  7. Click "Add Member"
  8. Create a new user with a "First name" of "api-user", an Email of "api-user@example.com", and a long random password
  9. Assign them to the "Api Users" group
  10. Tick the "Generate new API token" checkbox and click "Save"
  11. 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 to NONE though you MUST ensure that the API ACCESS 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 call canView() 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.