diff --git a/en/02_Developer_Guides/02_Controllers/07_CMS_JSON_APIs.md b/en/02_Developer_Guides/02_Controllers/07_CMS_JSON_APIs.md new file mode 100644 index 000000000..228639d93 --- /dev/null +++ b/en/02_Developer_Guides/02_Controllers/07_CMS_JSON_APIs.md @@ -0,0 +1,129 @@ +--- +title: CMS JSON API's +summary: Creating standardised JSON API's for authenticated users in the CMS. +--- + +# CMS JSON API's + +This document contains a standard set of conventions to be used when creating JSON API's in the CMS that are used in conjunction with AJAX requests from authenticated users. + +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. + +New CMS JSON API's are likely to also make use of FormSchema in conjunction with the react Formbuilder. FormSchema diverges from REST principles as it utilises a combination of JSON and application/x-www-form-urlencoded POST requests. This method has proven effective, and there are currently no plans to alter its functionality. + +Exercise caution with the use of PUT requests, as its implementation can lead to confusion regarding the intended "insert or update" behavior in a REST context. As CMS JSON API's have a very narrow use case with no public consumption, it is recommended to prefer simply use POST over PUT or PATCH requests and not worry about strict REST compliance. + +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). + +## Creating a controller + +Create a subclass of [`LeftAndMain`](api:SilverStripe\Admin\LeftAndMain). This ensures that users must be logged into the admin interface to access the endpoint. Additionally, it grants 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" prefix to this class, for instance name it "MySomethingController". + +Define the URL segment 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 implement the following enhancements: +- 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 + +private static array $url_handlers = [ + 'GET view/$ItemID' => 'apiView', +]; + +public function apiView(): HTTPResponse +{ + $itemID = (string) $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); +} +``` + +## 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. + +When dealing with a Form, it's crucial to check whether any frontend form fields have had `->setReadonly(true)` or `->setDisabled(true)` invoked on them. In such cases, consider returning 400 status codes, similar to a failed `canEdit()` check, to appropriately handle these conditions. + +## Return values and error handling + +All endpoint methods must declare a return type of [`HTTPResponse`](api:SilverStripe\Control\HTTPResponse). + +All return values must utilise `jsonSuccess()`. + +Do not throw exceptions in the controller, as this leads to a suboptimal content-editor experience. Instead all non-success conditions must call `jsonError()` + +### jsonSuccess() + +When incorporating $optionalData in `jsonSuccess()`, refrain from adding any "success metadata" e.g. `['success' => true, 'data' => $data]`. Instead, rely on standard HTTP status codes to clearly indicate the success of the operation. + +For scenarios where nobody is returned upon success, employ `return $this->jsonSuccess(201);`. Alternatively, when the response includes JSON data, utilise `return $this->jsonSuccess(200, $data);` to appropriately structure the response. + +### jsonError() + +Include a concise message outlining the nature of the error when calling `jsonError()` to facilitate efficient troubleshooting of the underlying issues. These error messages are only intented for developers so do not use the `_t()` function to make them translatable. + +The `return` keyword is omitted for `jsonError()` because internally it triggers an exception, subsequently caught and converted into an `HTTPResponse` object. + +Despite the slightly unconventional format returned of `jsonError()` with multiple nodes (unlike standard usage of `jsonSuccess()`), its usage remains consistent with FormSchema. It's advisable to maintain this uniformity rather than introducing separate methods for FormSchema and non-FormSchema failures. + +Refer to the list of [400 status codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses) to appropriately choose and communicate the nature of the error. + +## JavaScript AJAX requests + +Leverage the `backend` helper by importing it using `import backend from 'lib/Backend';` when making JavaScript requests. This is preferred over traditional `fetch()` calls. + +The `backend` helper provides the advantage of being able to use a `catch()` block to handle 400-500 response codes, offering a more streamlined approach compared to using vanilla `fetch()`. + +Take advantage of the shorthand methods offered by the backend helper, such as `.get()` and `.post()`, for making more consise requests. + +## 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;`. + +## Passing data from JavaScript to PHP + +Utilise `import backend from 'lib/Backend';` along with `backend.post(url, {mykey:'Some value'}, csrfToken).then(() => ...);` to effectively make a POST request in your JavaScript code. + +In the corresponding controller, retrieve the POST data using `$json = json_decode($this->getRequest()->getBody());`. If `$json` is `null`, take appropriate action by calling `jsonError()` to handle the error gracefully. + +## 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. This adds an additional layer of security by confirming the legitimacy of the incoming requests. + +## Unit testing + +For effective unit testing, employ 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. + +Utilise `$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, employ `$this->mainSession->sendRequest('DELETE', $url, [], $headers);` to simulate the respective HTTP method. + +Implement dataProviders extensively and pass in parameters such as `$expectedBody` and `$expectedStatusCode`. Ensure that each call to `jsonSuccess()` and `jsonError()` have at least one corresponding case in your dataProvider. This approach guarantees thorough testing of the expected responses for different scenarios.