-
Notifications
You must be signed in to change notification settings - Fork 67
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
d01a1eb
commit de45db1
Showing
1 changed file
with
154 additions
and
0 deletions.
There are no files selected for viewing
154 changes: 154 additions & 0 deletions
154
en/02_Developer_Guides/02_Controllers/07_CMS_JSON_APIs.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
--- | ||
title: CMS JSON APIs | ||
summary: Creating standardised JSON APIs for authenticated users in the CMS. | ||
--- | ||
|
||
# CMS JSON APIs | ||
|
||
This document contains a standard set of conventions to be used when creating JSON APIs in the CMS that are used in conjunction with AJAX requests from authenticated CMS users. | ||
|
||
To view an example of a controller that follows these standards see [`LinkFieldController`](https://github.com/silverstripe/silverstripe-linkfield/blob/4/src/Controllers/LinkFieldController.php). | ||
|
||
## Making the controller "REST-like" and its relation with `FormSchema` | ||
|
||
It's recommend you design your API with a "REST-like" approach for JSON requests in your application, acknowledging that certain aspects may not strictly adhere to pure REST principles. This means that you should use the HTTP methods GET, POST, and DELETE, though not others such as PUT or PATCH. | ||
|
||
The reason for not implementing PUT or PATCH is because `FormSchema` and react `FormBuilder` should be used whenever you need to render a form that uses the standard set of react form fields. `FormSchema` and `FormBuilder` will handle data submission, data validation and showing validation errors on the frontend. | ||
|
||
`FormSchema` diverges from REST principles as it utilises a combination of JSON to retrieve the `FormSchema` and submitting data using `application/x-www-form-urlencoded` POST requests. This method has proven effective, and there are currently no plans to alter its functionality. | ||
|
||
Because of this you should avoid updating large parts of a DataObject with either a POST, PUT or PATCH requests as you should be using `FormSchema` to update DataObjects. Only use POST requests to submit small amounts of data such as creating a new record without any data or updating sort order fields. | ||
|
||
## Creating a controller | ||
|
||
Create a subclass of [`LeftAndMain`](api:SilverStripe\Admin\LeftAndMain). This ensures that users must be logged in to the admin interface to access the endpoint. Additionally, it provides access to the methods [`LeftAndMain::jsonSuccess()`](api:SilverStripe\Admin\LeftAndMain::jsonSuccess()) and [`LeftAndMain::jsonError()`](api:SilverStripe\Admin\LeftAndMain::jsonError()). | ||
|
||
[warning] | ||
To enhance security, do not create a subclass of [`Controller`](api:SilverStripe\Control\Controller) routed using YAML on the `/admin` route. This practice is strongly discouraged as it circumvents the requirement to log in to the CMS to access the endpoints. | ||
[/warning] | ||
|
||
When naming this class add a "Controller" suffix to this class, for instance name it "MySomethingController". | ||
|
||
Define the URL segment of your controller using `private static string $url_segment = 'my-segment';`. For small optional modules, this may typically be the composer name of the module, for instance "linkfield". | ||
|
||
Include `private static string $required_permission_codes = 'CMS_ACCESS_CMSMain';`, possibly with a different permission, to allow non-admins to access the endpoints on the controller. | ||
|
||
As this is a subclass of `LeftAndMain`, it automatically gets added to the CMS menu. To remove it from the CMS menu, create a `_config.php` in the module (if it doesn't already exist) and add `CMSMenu::remove_menu_class(MySomethingController::class);`. | ||
|
||
## Handling requests with $url_handlers | ||
|
||
Utilise `private static array $url_handlers` to get the following benefits: | ||
|
||
- Ensure the HTTP request method aligns with the intended use for each method, for instance, restricting it to GET or POST. | ||
- Prevent potential conflicts with existing methods, such as [`LeftAndMain::sort()`](api:SilverStripe\Admin\LeftAndMain::sort()), by structuring the endpoint URL segment as `sort` and associating it with a method like `MySomethingController::apiSort()`. | ||
|
||
Use the request param `$ItemID` if you need a record ID into a URL so that you have an endpoint for a specific record. Use `$ItemID` because it's consistent with the request param used in Form Schema requests. For example, to use `$ItemID` in a GET request to view a single record: | ||
|
||
```php | ||
// MySomethingController.php | ||
namespace App\Controllers\MySomethingController; | ||
|
||
use SilverStripe\Admin\LeftAndMain; | ||
use SilverStripe\Control\HTTPResponse; | ||
|
||
class MySomethingController extends LeftAndMain | ||
{ | ||
// ... | ||
|
||
private static array $url_handlers = [ | ||
'GET view/$ItemID' => 'apiView', | ||
]; | ||
|
||
public function apiView(): HTTPResponse | ||
{ | ||
$itemID = $request->param('ItemID'); | ||
// Note: would normally validate that $itemID is a valid integer and that $obj exists | ||
$obj = MyDataObject::get()->byID($itemID); | ||
$data = ['ID' => $obj->ID, 'Title' => $obj->Title]; | ||
return $this->jsonSuccess(200, $data); | ||
} | ||
} | ||
``` | ||
|
||
Remember to add all public methods that are used as endpoints to `private static array $allowed_actions`. | ||
|
||
## Permission checks | ||
|
||
Incorporate essential permission checks, such as `canEdit()`, into all relevant endpoints to ensure secure access control. | ||
|
||
When returning DataObjects as JSON, remember to invoke `canView()` on each DataObject. In a CMS context where the number of DataObjects is typically limited, the performance impact of these checks should not be a significant concern. If the permission check fails then call `$this->jsonError(403);` to return a 403 status code. | ||
|
||
## Return values and error handling | ||
|
||
All public endpoint methods must declare a return type of [`HTTPResponse`](api:SilverStripe\Control\HTTPResponse). | ||
|
||
All return values must utilise `jsonSuccess()` to create the `HTTPResponse` as this method is used to standardise JSON responses in the CMS. | ||
|
||
Do not throw exceptions in the controller, as this leads to a poor content-editor experience. Instead all non-success conditions must call `jsonError()`. | ||
|
||
### Using `jsonSuccess()` | ||
|
||
When using the optional `$data` parameter in `jsonSuccess()` to return JSON in the response body, do not add any "success metadata" around it, for example `['success' => true, 'data' => $data]`. Instead, solely rely on standard HTTP status codes to clearly indicate the success of the operation. | ||
|
||
For scenarios where no JSON data is returned in the response body upon success, use the status code 201 without the `$data` parameter i.e. `return $this->jsonSuccess(201);`. Alternatively, when the response includes JSON data, usually return a 200 status code i.e. `return $this->jsonSuccess(200, $data);`. | ||
|
||
### Using `jsonError()` | ||
|
||
The `return` keyword is omitted for `jsonError()` because internally it triggers an exception, subsequently caught and converted into an `HTTPResponse` object. | ||
|
||
Generally you should not include a message outlining the nature of the error when calling `jsonError()`, instead just include the appropriate HTTP status code for the error type, for example call `$this->jsonError(403)` if a permission check fails. | ||
|
||
If you do include a message, remember that error messages are only intended for developers so do not use the `_t()` function to make them translatable. Do not use any returned messages on the frontend for things like toast notifications, instead those messages should be added directly in JavaScript. | ||
|
||
[info] | ||
Despite the slightly convoluted JSON format returned by `jsonError()` with multiple nodes, its usage remains consistent with `FormSchema`. It's better to use this method for uniformity rather than introducing separate methods for `FormSchema` and non-FormSchema failures. | ||
[/info.] | ||
|
||
## CSRF token | ||
|
||
When performing non-view operations, include an `'X-SecurityID'` header in your JavaScript request, with its value set to `SecurityToken::getSecurityID()`. | ||
|
||
Access the token value in JavaScript by logging in by using `import Config from 'lib/Config';` followed by `Config.get('SecurityID');` | ||
|
||
Ensure the security of your endpoints by validating the token header using `SecurityToken::inst()->checkRequest($this->getRequest())` on relevant endpoints. If this check fails then call `$this->jsonError(400);` which is consistent with the 400 status code used in [`FormRequestHandler::httpSubmission()`](api:SilverStripe\Forms\FormRequestHandler::httpSubmission()) when the CSRF check fails when submitting data using `FormSchema`. | ||
|
||
## Passing values from PHP to JavaScript | ||
|
||
To transmit values from PHP to JavaScript, override `LeftAndMain::getClientConfig()` within your controller. Begin your method with `$clientConfig = parent::getClientConfig();` to ensure proper inheritance. | ||
|
||
Include any relevant links to endpoints in the client configuration. For example, add `'myEndpointUrl' => $this->Link('my-endpoint')`, where `my-endpoint` is specified in `private static array $url_handlers`. | ||
|
||
In JavaScript, access these values using `import Config from 'lib/Config';` and retrieve the endpoint URL using `Config.getSection('Full\ClassName\Of\MyController').anArrayKey.myEndpointUrl;`. | ||
|
||
## JavaScript AJAX requests | ||
|
||
Use the `backend` helper which is a wrapper around `fetch()` when making JavaScript requests. Import it using `import backend from 'lib/Backend';`. | ||
|
||
The `backend` helper is able to use a `catch()` block to handle 400-500 response codes, offering a more streamlined approach compared to using vanilla `fetch()`. It also provides handy shorthand methods such as `.get()` and `.post()`, for writing concise code. | ||
|
||
The following code will make a POST request to an endpoint passing JSON data in the request body. | ||
|
||
```js | ||
// MyComponent.js | ||
|
||
const endpoint = `${Config.getSection(section).someKey.someUrl}`; | ||
const data = { somekey: 123 }; | ||
const headers = { 'X-SecurityID': Config.get('SecurityID') }; | ||
backend.post(endpoint, data, headers) | ||
.then(() => { | ||
// handle 200-299 status code response here | ||
}) | ||
.catch(() => { | ||
// handle 400-500 status code response here | ||
}); | ||
``` | ||
|
||
On the controller's endpoint method, retrieve the POST data using `$json = json_decode($this->getRequest()->getBody());`. | ||
|
||
## Unit testing | ||
|
||
Write unit tests with a subclass of [`FunctionalTest`](api:SilverStripe\Dev\FunctionalTest) instead of the regular [`SapphireTest`](api:SilverStripe\Dev\SapphireTest). This allows you to make HTTP requests to your endpoints and ensures comprehensive functional testing. | ||
|
||
Use `$this->get()` for endpoints designed to handle GET requests and `$this->post()` for those intended for POST requests. For non-GET/POST requests, such as DELETE, use `$this->mainSession->sendRequest('DELETE', $url, [], $headers);`. | ||
|
||
Make heavy use of dataProviders and pass in parameters such as `$expectedBody` and `$expectedStatusCode`. Ensure that every call to `jsonSuccess()` and `jsonError()` has at least one corresponding case in your dataProvider. |