I want to add some api to my Yii2 site. Api must be only in json. I don't want to set Accept: application/json headers for each request. I can set 'response' => ['format' => \yii\web\Response::FORMAT_JSON] in application configuration but it breaks all pages. Also my api function returns data in xml.
I tried to use rest\ActiveRecord for my purposes. Maybe I do it's wrong. What I want.
To have my Yii2 based site with some api acсessed through https://example.com/api/controller/action. In project I want to see folder controllers/api which contains my controllers. Controllers must use standard \yii\db\ActiveRecord based models. Also controllers input paramaters only in json body or as part url and output data only in json.
You may need to set the following code in the controller's action somewhere before return or in beforeAction() method:
\Yii::$app->response->format = \yii\web\Response::FORMAT_JSON;
also since Yii 2.0.11 there is a dedicated asJson() method to return a response in JSON format:
return $this->asJson($array);
The more elegant solution is to use yii\filters\ContentNegotiator.
When the Accept header is missing ContentNegotiator assumes it allows any type and send response in first format defined in its $formats property. If the requested format is not among accepted formats the content negotiator will throw yii\web\NotAcceptableHttpException and app will respond with http status 406 Not Acceptable.
You can add it in your controller in behaviors() method like this:
public function behaviors()
{
return [
[
'class' => 'yii\filters\ContentNegotiator',
'formats' => [
'application/json' => \yii\web\Response::FORMAT_JSON,
],
],
];
}
If your controller extends yii\rest\Controller it already has the ContentNegotiator filter added among its behaviors. You only need to limit allowed formats like this:
public function behaviors()
{
$behaviors = parent::behaviors();
$behaviors['contentNegotiator']['formats'] = [
'application/json' => \yii\web\Response::FORMAT_JSON,
];
return $behaviors;
}
Using ContentNegotiator instead of explicitly forcing the JSON format in beforeAction() will allow for easier addition of other formats if they are needed in future.
Related
The docs are here: https://crud.readthedocs.io/en/latest/actions/bulk-delete.html
But what I don't understand is what should my request URL look like in order to hit bulk delete? I assume it is just the usual crud path to the model as a json file using the DELETE method. However this doesn't seem to work.
Presumably that's because I'm mapping it incorrectly to the actions. Here's what I've done as a Controller:
namespace App\Controller\Api;
use Cake\Controller\Controller;
class ApiAppController extends Controller
{
use \Crud\Controller\ControllerTrait;
public $components = [
'RequestHandler',
'Crud.Crud' => [
'actions' => [
'Crud.Index',
'Crud.View',
'Crud.Add',
'Crud.Edit',
'Crud.Delete',
'Crud.Bulk/Delete'
],
'listeners' => [
'Crud.Api',
'Crud.ApiPagination',
'Crud.ApiQueryLog',
'Crud.Search'
]
]
];
}
I also tried the controller like this:
use App\Controller\Api\ApiAppController;
/**
* Devices Controller
*
* #property \App\Model\Table\DevicesTable $Devices
*
* #method \App\Model\Entity\Device[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
*/
class DataController extends ApiAppController
{
public function initialize()
{
parent::initialize();
$this->Crud->mapAction('deleteAll', 'Crud.Bulk/Delete');
}
public function deleteAll()
{
$connection = ConnectionManager::get('default');
$results = $connection->execute('TRUNCATE TABLE data');
}
}
With various attempts at the URL like so:
DELETE http://my-site:8888/api/data.json
DELETE http://my-site:8888/api/data/delete.json
DELETE http://my-site:8888/api/data/delete-all.json
DELETE http://my-site:8888/api/data/all.json
It doesn't seem to even hit the CRUD plugin as I'm getting a CSRF token error. Normal crud routes don't get that CSRF error because they are picked up by the routing:
Router::prefix('api', function ($routes) {
$routes->extensions(['json', 'xml']);
$routes->resources('Data');
});
The cakephp docs on RESTful routing don't cover how to address bulk actions:
https://book.cakephp.org/3.0/en/development/routing.html#resource-routes
So there are a number of things I would look at to fix this.
Firstly would be your routing. If you're using DashedRoute then the url would be http://example.com/examples/delete-all.json. Unless you also pass the Accept: application/json header.
You can also set an action as the key in the actions config, which can be used to change the action name. See the documentation.
'actions' => [
'delete-all' => [
'className' => 'Crud.Bulk/Delete'
]
]
As for your CSRF token issue, you will need to unlock the action to be able to use DELETE without generating a token.
In your controller
$this->Security->setConfig('unlockedActions', ['deleteAll']);
Or you'll need to generate a CSRF token and send it with your request. Read more on CSRF in the book.
I am trying to test an ajax request with a basic install of Yii2. I am just using the SiteController::actionAbout() method to try this out.
public function actionAbout()
{
Yii::$app->response->format = \yii\web\Response::FORMAT_JSON;
return [
'message' => 'hello world',
'code' => 100,
];
}
And in the test:
public function sendAjax(\FunctionalTester $I)
{
$I->sendAjaxGetRequest('site/about');
$r = $I->grabResponse();
die(var_dump($r));
}
The grab response method is one I wrote myself in a \Helper\Functional.php file:
public function grabResponse()
{
return $this->getModule('Yii2')->_getResponseContent();
}
When I dump out the response, I just see the html from the site/index page.
This happens for any route except the site default. If I try to do the same thing using site/index then it returns:
string(36) "{"message":"hello world","code":100}"
What am I missing / doing wrong?
Note the difference between a Yii route and the actual URL.
The argument to sendAjaxGetRequest() is the URL, not the route (at least if you pass it as a string, see below).
Dependent on your UrlManager config you might have URLs like /index.php?r=site/about, where the route is site/about. You can use the Url helper of Yii to create the URL:
$I->sendAjaxGetRequest(\yii\helpers\Url::to(['site/about']));
I am not sure about this but if you have the Codeception Yii2 module installed you should also be able to pass the route like this:
$I->sendAjaxGetRequest(['site/about']);
I am currently using RestApi plugin for CakePHP 3 and I want to be able to append the extension .json to URL, like so:
domain.com/api/search/abc.json
Following CakePHP's docs about creating RESTful routes I was able to use the extension without throwing an error.
I have this on my routes.php (edit to add the whole code)
use Cake\Core\Plugin;
use Cake\Routing\RouteBuilder;
use Cake\Routing\Router;
use Cake\Routing\Route\DashedRoute;
Router::defaultRouteClass(DashedRoute::class);
Router::scope('/', function (RouteBuilder $routes) {
$routes->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']);
$routes->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']);
$routes->fallbacks(DashedRoute::class);
});
Plugin::routes();
Router::scope('/', function ($routes) {
$routes->extensions(['json']);
});
In my controller, if I do this:
public function search($term=''){
$this->httpStatusCode = 200;
$this->apiResponse['term'] = $term;
}
The response is:
{
"status": "OK",
"result": {
"term": "abc.json" # Notice the .json
}
}
So, I get abc.json, when I want abc.
Am I doing something wrong? Or am I supposed to strip the .json from $term?
While reusing existing scopes will merge the connected routes to the same routes collection, calls to RouteBuilder::extensions() will generally not affect previously connected routes, and they also do not affect reused/reopened scopes.
Quote from the docs:
Future routes connected in through this builder will have the connected
extensions applied. However, setting extensions does not modify existing routes.
API > \Cake\Routing\RouteBuilder::extensions()
You should add the extensions() call in the existing routing scope, so that it affects the routes that are being connected in there after the extensions() call.
See also
Cookbook > Routing > Routing File Extensions
I've created routes shown below:
Router::connect('/:api/:controller/:action/*', array(), array('api'=>'api'));
Router::connect('/:api/:controller', array('action' => 'index'), array('api'=>'api'));
Router::connect('/:api/', array('controller' => 'index', 'action' => 'index'), array('api'=>'api'));
Basically, I want all requests made through a particular endpoint to respond in JSON. In the case above all requests made with the api prefix. For example:
http://localhost/api/products
Should return a JSON response instead of an HTML. Note that it should work that way even without the .json extension being defined.
So I am guessing in your controller you check if the api prefix was set and if so you serialize the data you give back to the view? if so then just add:
$this->RequestHandler->renderAs($this, 'json');
I have read the RequestHandler part in cookbook. There are isXml(), isRss(), etc. But there's no isJson().
Any other way to check whether a request is JSON?
So when the url is mysite.com/products/view/1.json it will give JSON data, but without .json it will give the HTML View.
Thanks
I dont think cakePHP has some function like isJson() for json data, you could create your custom though, like:
//may be in your app controller
function isJson($data) {
return (json_decode($data) != NULL) ? true : false;
}
//and you can use it in your controller
if( $this->isJson($your_request_data) ) {
...
}
Added:
if you want to check .json extension and process accordingly, then you could do in your controller:
$this->request->params['ext']; //which would give you 'json' if you have .json extension
CakePHP is handling this correctly, because JSON is a response type and not a type of request. The terms request and response might be causing some confusing. The request object represents the header information of the HTTP request sent to the server. A browser usually sends POST or GET requests to a server, and those requests can not be formatted as JSON. So it's not possible for a request to be of type JSON.
With that said, the server can give a response of JSON and a browser can put in the request header that it supports a JSON response. So rather than check what the request was. Check what accepted responses are supported by the browser.
So instead of writing $this->request->isJson() you should write $this->request->accepts('application/json').
This information is ambiguously shown in the document here, but there is no reference see also links in the is(..) documentation. So many people look there first. Don't see JSON and assume something is missing.
If you want to use a request detector to check if the browser supports a JSON response, then you can easily add a one liner in your beforeFilter.
$this->request->addDetector('json',array('callback'=>function($req){return $req->accepts('application/json');}));
There is a risk associated with this approach, because a browser can send multiple response types as a possible response from the server. Including a wildcard for all types. So this limits you to only requests that indicate a JSON response is supported. Since JSON is a text format a type of text/plain is a valid response type for a browser expecting JSON.
We could modify our rule to include text/plain for JSON responses like this.
$this->request->addDetector('json',array('callback'=>function($req){
return $req->accepts('application/json') || $req->accepts('text/plain');
}));
That would include text/plain requests as a JSON response type, but now we have a problem. Just because the browser supports a text/plain response doesn't mean it's expecting a JSON response.
This is why it's better to incorporate a naming convention into your URL to indicate a JSON response. You can use a .json file extension or a /json/controller/action prefix.
I prefer to use a named prefix for URLs. That allows you to create json_action methods in your controller. You can then create a detector for the prefix like this.
$this->request->addDetector('json',array('callback'=>function($req){return isset($req->params['prefix']) && $req->params['prefix'] == 'json';}));
Now that detector will always work correctly, but I argue it's an incorrect usage of detecting a JSON request. Since there is no such thing as a JSON request. Only JSON responses.
You can make your own detectors. See: http://book.cakephp.org/2.0/en/controllers/request-response.html#inspecting-the-request
For example in your AppController.php
public function beforeFilter() {
$this->request->addDetector(
'json',
[
'callback' => [$this, 'isJson']
]
);
parent::beforeFilter();
}
public function isJson() {
return $this->response->type() === 'application/json';
}
Now you can use it:
$this->request->is('json'); // or
$this->request->isJson();
Have you looked through and followed the very detailed instructions in the book?:
http://book.cakephp.org/2.0/en/views/json-and-xml-views.html
class TestController extends Controller {
public $autoRender = false;
public function beforeFilter() {
$this->request->addDetector('json', array('env' => 'CONTENT_TYPE', 'pattern' => '/application\/json/i'));
parent::beforeFilter();
}
public function index() {
App::uses('HttpSocket', 'Network/Http');
$url = 'http://localhost/myapp/test/json';
$json = json_encode(
array('foo' => 'bar'),
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
);
$options = array('header' => array('Content-Type' => 'application/json'));
$request = new HttpSocket();
$body = $request->post($url, $json, $options)->body;
$this->response->body($body);
}
public function json() {
if ($this->request->isJson()) {
$data = $this->request->input('json_decode');
$value = property_exists($data, 'foo') ? $data->foo : '';
}
$body = (isset($value) && $value === 'bar') ? 'ok' : 'fail';
$this->response->body($body);
}
}
Thanks a lot Mr #Schlaefer. I read your comment and try, Wow it's working now.
//AppController.php
function beforeFilter() {
$this->request->addDetector(
'json', [
'callback' => [$this, 'isJson']
]
);
parent::beforeFilter();
...
}
public function isJson() {
return $this->response->type() === 'application/json';
}
//TasksController.php
public $components = array('Paginator', 'Flash', Session','RequestHandler');
//Get tasks function return all tasks in json format
public function getTasks() {
$limit = 20;
$conditions = array();
if (!empty($this->request->query['status'])) {
$conditions = ['Task.status' => $this->request->query['status']];
}
if (!empty($this->request->query['limit'])) {
$limit = $this->request->query['limit'];
}
$this->Paginator->settings = array('limit' => $limit, 'conditions' => $conditions);
$tasks = $this->paginate();
if ($this->request->isJson()) {
$this->set(
array(
'tasks' => $tasks,
'_serialize' => array('tasks')
));
}
}
In case anybody is reading this in the days of CakePHP 4, the correct and easy way to do this is by using $this->request->is('json').