public function search()
{
if ($this->articleValidator->validateSearch(request())) {
$response['response'] = TRUE;
$response['data']['articles'] = $this->articleService->searchArticles(request()->keyword, request()->category, request()->from, request()->to);
$response['html'] = view('partials/content-administrator-subsystem/articles', $response['data'])->render();
} else {
$response['response'] = $this->articleValidator->searchValidationErrors();
}
return json_encode($response);
exit;
}
I have this function inside my ArticlesPageController. I send a POST request with axios to this method.
class ArticleValidator implements ArticleValidatorInterface
{
protected $searchValidator;
/**
* Validates articles search request
*
* #param request - Request object
* #returns true/false if validation succeeded
*/
public function validateSearch($request)
{
$this->searchValidator = Validator::make($request->all(), [
'category' => 'array|min:1|exists:categories,id',
'from' => 'date',
'to' => 'date|after_or_equal:from'
]);
return !$this->searchValidator->fails();
}
/**
* Returns search validation errors
*
* #return validation errors or null if everything went well
*/
public function searchValidationErrors()
{
if ($this->searchValidator) {
print_r($this->searchValidator->errors()->getMessages());
return $this->searchValidator->errors();
}
return null;
}
}
This is validator class.
The problem is, if validator fails, I get such return:
{
"response": {
"to": ["validation.after_or_equal"]
}
}
As you can see, it is validation rule that has failed, the problem is, that I need to get actual messages not the rule that has failed.
I know that in normal flow, I can do return redirect()->withErrors($errors) and in a view I would get an $errors array, but now, when it is an AJAX call, I cant do any redirects. so how can I get actual messages and return them back?
As you can see here https://laravel.com/docs/5.5/validation#quick-writing-the-validation-logic, when you use the validate() method and your request was an AJAX one, you get the errors in JSON format in the response. You can see another way to use the Validator here https://laravel.com/docs/5.5/validation#automatic-redirection.
You may have to check for the corresponding version of the documentation because there were slight variations over time, but this should give you a kickstart.
Related
I am fairly new to Symfony 5.4 and recently created my first API using that version
For my specific API endpoint one of the parameters is an array of IDs.
I need to validate this array in the following way:
make sure that this IS an array;
make sure that IDs in the array actually refer to database records;
I implemented it in a straightforward way where I check the array before persisting the entity using typecasting and existing Repository:
$parentPropertyIds = (array)$request->request->get('parent_property_ids');
if ($parentPropertyIds) {
$parentCount = $doctrine->getRepository(Property::class)->countByIds($parentPropertyIds);
if ($parentCount !== count($parentPropertyIds)) {
return $this->json([
'status' => 'error',
'message' => 'parent_property_id_invalid'
], 422);
}
foreach ($parentPropertyIds as $parentPropertyId) {
$parentProperty = $doctrine->getRepository(Property::class)->find($parentPropertyId);
$property->addParent($parentProperty);
}
}
However, this makes my controller action become too "body-positive" and also feels like something that could be implemented in a more elegant way.
I was unable to find anything in Symfony 5.4 docs.
At the moment I am wondering if:
there is a way to filter/sanitize request parameter available in Symfony;
there is an elegant built-in way to apply custom validator constraint to a request param (similar to well-documented entity field validation);
Full endpoint code:
/**
* #Route("/property", name="property_new", methods={"POST"})
*/
public function create(ManagerRegistry $doctrine, Request $request, ValidatorInterface $validator): Response
{
$entityManager = $doctrine->getManager();
$property = new Property();
$property->setName($request->request->get('name'));
$property->setCanBeShared((bool)$request->request->get('can_be_shared'));
$parentPropertyIds = (array)$request->request->get('parent_property_ids');
if ($parentPropertyIds) {
$parentCount = $doctrine
->getRepository(Property::class)
->countByIds($parentPropertyIds);
if ($parentCount !== count($parentPropertyIds)) {
return $this->json([
'status' => 'error',
'message' => 'parent_property_id_invalid'
], 422);
}
foreach ($parentPropertyIds as $parentPropertyId) {
$parentProperty = $doctrine->getRepository(Property::class)->find($parentPropertyId);
$property->addParent($parentProperty);
}
}
$errors = $validator->validate($property);
if (count($errors) > 0) {
$messages = [];
foreach ($errors as $violation) {
$messages[$violation->getPropertyPath()][] = $violation->getMessage();
}
return $this->json([
'status' => 'error',
'messages' => $messages
], 422);
}
$entityManager->persist($property);
$entityManager->flush();
return $this->json([
'status' => 'ok',
'id' => $property->getId()
]);
}
You could use a combination of Data Transfer Object (DTO) with Validation service. There is a number of predefined constraints or you could create a custom one.
For expamle, how to use simple constraint as an annotation:
class PropertyDTO {
/**
* #Assert\NotBlank
*/
public string $name = "";
public bool $shared = false;
}
Then assign data to DTO:
$propertyData = new PropertyDTO();
$propertyData->name = $request->request->get('name');
...
In some cases it is a good idea to define a constructor in the DTO, then get all data from the request and pass it to DTO at once:
$data = $request->getContent(); // or $request->getArray(); depends on your content type
$propertyData = new PropertyDTO($data);
Then validate it:
$errors = $validator->validate($propertyData);
if (count($errors) > 0) {
/*
* Uses a __toString method on the $errors variable which is a
* ConstraintViolationList object. This gives us a nice string
* for debugging.
*/
$errorsString = (string) $errors;
return $this->json([
'status' => 'error',
'message' => 'parent_property_id_invalid'
], 422);
}
//...
I know, this is a complex case but maybe one of you might have an idea on how to do this.
Concept
I have the following process in my API:
Process query string parameters (FormRequest)
Replace key aliases by preferred keys
Map string parameters to arrays if an array ist expected
Set defaults (including Auth::user() for id-based parameters)
etc.
Check if the user is allowed to do the request (Middleware)
Using processed (validated and sanitized) query params
→ otherwise I had to do exceptions for every possible alias and mapping as well as checking if the paramter is checked and that doesn't seem reasonable to me.
Problem
Nevertheless, if you just assign the middleware via ->middleware('middlewareName') to the route and the FormRequest via dependency injection to the controller method, first the middleware is called and after that the FormRequest. As described above, that's not what I need.
Solution approach
I first tried dependency injection at the middleware but it didn't work.
My solution was to assign the middleware in the controller constructor. Dependency injection works here, but suddenly Auth::user() returns null.
Then, I came across the FormRequest::createFrom($request) method in \Illuminate\Foundation\Providers\FormRequestServiceProvider.php:34 and the possibility to pass the $request object to the middleware's handle() method. The result looks like this:
public function __construct(Request $request)
{
$middleware = new MyMiddleware();
$request = MyRequest::createFrom($request);
$middleware->handle($request, function() {})
}
But now the request is not validated yet. Just calling $request->validated() returns nothing. So I digged a little deeper and found that $resolved->validateResolved(); is done in \Illuminate\Foundation\Providers\FormRequestServiceProvider.php:30 but that doesn't seem to trigger the validation since it throws an exception saying that this method cannot be called on null but $request isn't null:
Call to a member function validated() on null
Now, I'm completely stumped. Does anyone know how to solve this or am I just doing it wrong?
Thanks in advance!
I guess, I figured out a better way to do this.
My misconception
While middleware is doing authentication, I was doing authorization there and therefore I have to use a Gate
Resulting code
Controller
...
public function getData(MyRequest $request)
{
$filters = $request->query();
// execute queries
}
...
FormRequest
class MyRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* #return bool
*/
public function authorize()
{
return Gate::allows('get-data', $this);
}
/**
* Get the validation rules that apply to the request.
*
* #return array
*/
public function rules()
{
return [
// ...
];
}
/**
* Prepare the data for validation.
*
* #return void
*/
protected function prepareForValidation()
{
$this->replace($this->cleanQueryParameters($this->query()));
}
private function cleanQueryParameters($queryParams): array
{
$queryParams = array_filter($queryParams, function($param) {
return is_array($param) ? count($param) : strlen($param);
});
$defaultStartDate = (new \DateTime())->modify('monday next week');
$defaultEndDate = (new \DateTime())->modify('friday next week');
$defaults = [
'article.created_by_id' => self::getDefaultEmployeeIds(),
'date_from' => $defaultStartDate->format('Y-m-d'),
'date_to' => $defaultEndDate->format('Y-m-d')
];
$aliases = [
// ...
];
$mapper = [
// ...
];
foreach($aliases as $alias => $key) {
if (array_key_exists($alias, $queryParams)) {
$queryParams[$key] = $queryParams[$alias];
unset($queryParams[$alias]);
}
}
foreach($mapper as $key => $fn) {
if (array_key_exists($key, $queryParams)) {
$fn($queryParams, $key);
}
}
$allowedFilters = array_merge(
Ticket::$allowedApiParameters,
array_map(function(string $param) {
return 'article.'.$param;
}, TicketArticle::$allowedApiParameters)
);
$arrayProps = [
// ..
];
foreach($queryParams as $param => $value) {
if (!in_array($param, $allowedFilters) && !in_array($param, ['date_from', 'date_to'])) {
abort(400, 'Filter "'.$param.'" not found');
}
if (in_array($param, $arrayProps)) {
$queryParams[$param] = guarantee('array', $value);
}
}
return array_merge($defaults, $queryParams);
}
}
Gate
class MyGate
{
/**
* Handle an incoming request.
*
* #param \Illuminate\Http\Request $request
* #return \Illuminate\Auth\Access\Response|Void
* #throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function authorizeGetDataCall(User $user, MyRequest $request): Response
{
Log::info('[MyGate] Checking permissions …');
if (in_array(LDAPGroups::Admin, session('PermissionGroups', []))) {
// no further checks needed
Log::info('[MyGate] User is administrator. No further checks needed');
return Response::allow();
}
if (
($request->has('group') && !in_array(Group::toLDAPGroup($request->get('group')), session('PermissionGroups', []))) ||
$request->has('owner.department') && !in_array(Department::toLDAPGroup($request->query('owner.department')), session('PermissionGroups', [])) ||
$request->has('creator.department') && !in_array(Department::toLDAPGroup($request->query('creator.department')), session('PermissionGroups', []))
) {
Log::warning('[MyGate] Access denied due to insufficient group/deparment membership', [ 'group/department' =>
$request->has('group') ?
Group::toLDAPGroup($request->get('group')) :
($request->has('owner.department') ?
Department::toLDAPGroup($request->query('owner.department')) :
($request->has('creator.department') ?
Department::toLDAPGroup($request->query('creator.department')) :
null))
]);
return Response::deny('Access denied');
}
if ($request->has('customer_id') || $request->has('article.created_by_id')) {
$ids = [];
if ($request->has('customer_id')) {
$ids = array_merge($ids, $request->query('customer_id'));
}
if ($request->has('article.created_by_id')) {
$ids = array_merge($ids, $request->query('article.created_by_id'));
}
$users = User::find($ids);
$hasOtherLDAPGroup = !$users->every(function($user) {
return in_array(Department::toLDAPGroup($user->department), session('PermissionGroups', []));
});
if ($hasOtherLDAPGroup) {
Log::warning('[MyGate] Access denied due to insufficient permissions to see specific other user\'s data', [ 'ids' => $ids ]);
return Response::deny('Access denied');;
}
}
if ($request->has('owner.login') || $request->has('creator.login')) {
$logins = [];
if ($request->has('owner.login')) {
$logins = array_merge(
$logins,
guarantee('array', $request->query('owner.login'))
);
}
if ($request->has('creator.login')) {
$logins = array_merge(
$logins,
guarantee('array', $request->query('creator.login'))
);
}
$users = User::where([ 'samaccountname' => $logins ])->get();
$hasOtherLDAPGroup = !$users->every(function($user) {
return in_array(Department::toLDAPGroup($user->department), session('PermissionGroups', []));
});
if ($hasOtherLDAPGroup) {
Log::warning('[MyGate] Access denied due to insufficient permissions to see specific other user\'s data', [ 'logins' => $logins ]);
return Response::deny('Access denied');
}
}
Log::info('[MyGate] Permission checks passed');
return Response::allow();
}
}
I'm getting this error when i try to register via google api
string(331) "Legacy People API has not been used in project ******* before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/legacypeople.googleapis.com/overview?project=******** then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry."
And when i go that url i'm receiving
Failed to load.
There was an error while loading /apis/api/legacypeople.googleapis.com/overview?project=******&dcccrf=1. Please try again.
My google.php code in /vendor/league/oauth2-google/src/Provider is
<?php
namespace League\OAuth2\Client\Provider;
use League\OAuth2\Client\Exception\HostedDomainException;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Token\AccessToken;
use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
use Psr\Http\Message\ResponseInterface;
class Google extends AbstractProvider
{
use BearerAuthorizationTrait;
const ACCESS_TOKEN_RESOURCE_OWNER_ID = 'id';
/**
* #var string If set, this will be sent to google as the "access_type" parameter.
* #link https://developers.google.com/accounts/docs/OAuth2WebServer#offline
*/
protected $accessType;
/**
* #var string If set, this will be sent to google as the "hd" parameter.
* #link https://developers.google.com/accounts/docs/OAuth2Login#hd-param
*/
protected $hostedDomain;
/**
* #var array Default fields to be requested from the user profile.
* #link https://developers.google.com/+/web/api/rest/latest/people
*/
protected $defaultUserFields = [
'id',
'name(familyName,givenName)',
'displayName',
'emails/value',
'image/url',
];
/**
* #var array Additional fields to be requested from the user profile.
* If set, these values will be included with the defaults.
*/
protected $userFields = [];
/**
* Use OpenID Connect endpoints for getting the user info/resource owner
* #var bool
*/
protected $useOidcMode = false;
public function getBaseAuthorizationUrl()
{
return 'https://accounts.google.com/o/oauth2/auth';
}
public function getBaseAccessTokenUrl(array $params)
{
return 'https://www.googleapis.com/oauth2/v4/token';
}
public function getResourceOwnerDetailsUrl(AccessToken $token)
{
if ($this->useOidcMode) {
// OIDC endpoints can be found https://accounts.google.com/.well-known/openid-configuration
return 'https://www.googleapis.com/oauth2/v3/userinfo';
}
// fields that are required based on other configuration options
$configurationUserFields = [];
if (isset($this->hostedDomain)) {
$configurationUserFields[] = 'domain';
}
$fields = array_merge($this->defaultUserFields, $this->userFields, $configurationUserFields);
return 'https://www.googleapis.com/plus/v1/people/me?' . http_build_query([
'fields' => implode(',', $fields),
'alt' => 'json',
]);
}
protected function getAuthorizationParameters(array $options)
{
$params = array_merge(
parent::getAuthorizationParameters($options),
array_filter([
'hd' => $this->hostedDomain,
'access_type' => $this->accessType,
// if the user is logged in with more than one account ask which one to use for the login!
'authuser' => '-1'
])
);
return $params;
}
protected function getDefaultScopes()
{
return [
'email',
'openid',
'profile',
];
}
protected function getScopeSeparator()
{
return ' ';
}
protected function checkResponse(ResponseInterface $response, $data)
{
if (!empty($data['error'])) {
$code = 0;
$error = $data['error'];
if (is_array($error)) {
$code = $error['code'];
$error = $error['message'];
}
throw new IdentityProviderException($error, $code, $data);
}
}
protected function createResourceOwner(array $response, AccessToken $token)
{
$user = new GoogleUser($response);
// Validate hosted domain incase the user edited the initial authorization code grant request
if ($this->hostedDomain === '*') {
if (empty($user->getHostedDomain())) {
throw HostedDomainException::notMatchingDomain($this->hostedDomain);
}
} elseif (!empty($this->hostedDomain) && $this->hostedDomain !== $user->getHostedDomain()) {
throw HostedDomainException::notMatchingDomain($this->hostedDomain);
}
return $user;
}
}
How to fix this issue?
Legacy People API has not been used in project ******* before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/legacypeople.googleapis.com/overview?project=********
As the error message states you have not enabled the people api in your project and as you have included email and profile and are trying to request profiled data about the user.
return 'https://www.googleapis.com/plus/v1/people/me?' . http_build_query([
'fields' => implode(',', $fields),
'alt' => 'json',
You need to enable the people api in our project before you can request data. Click the link and follow the instructions below.
Go to Google developer console click library on the left. Then search for the API you are looking to use and click enable button
Wait a couple of minutes then run your code again. Then you will be able to make requests to the people api.
return 'https://www.googleapis.com/plus/v1/people/me?' . http_build_query([
'fields' => implode(',', $fields),
'alt' => 'json',
Legacy endpoint:
I also recommend up update your endpoint to the new people.get endpoint
https://people.googleapis.com/v1/people/me
So at the moment my application is structured such that Controllers will access a "Processor" class for certain objects (Business logic). This processor object then accesses all the relevant repositories necessary to perform the action (Data access logic).
For instance, let's pretend there is a UserProcessor class which we are using to attempt updating the user's email via some function: updateEmail($user_id, $new_email). The validation is then handled on the email, but lets say this validation fails. Obviously there are multiple vectors by which the updateEmail function can fail. Many of these will throw exceptions, but in the case of the validation and a few others, they will not (they aren't totally unexpected errors thus an exception is not proper?).
My problem occurs because of the potential for multiple failures. I'm unsure how to handle updateEmail's non-exception type failures. I could just have updateEmail return a response object as necessary which solves everything. But something about this doesn't feel right, isn't the generation of a response object supposed to be handled in the Controller?
I could also create an errors variable, which the controller accesses on receiving False from updateEmail. But this ends up being very generic in-terms of my api, which returns "status", "message", and "payload". In my current form I have a generic message for errors like: "validation error(s) have occurred." and then in the payload specific errors are listed. I could create a errorMessage variable in my UserProcessor, but at this point I might as well return a response object as I need to also store the HTTP error code?
Am I overthinking this? Should the processor just handle responses?
EDIT:
class UserProcessor {
private $user;
private $error_code;
private $error_message;
private $error_payload;
public function __construct(UserRepositoryContract $user){
$this->user = $user;
}
public function error(){
return array(
'code' => $this->error_code,
'message' => $this->error_message,
'payload' => $this->error_payload
);
}
public function updateEmail($user_id, $new_email, $confirmation_email){
$validator = $this->validateEmail(array(
'email' => $new_email,
'email_confirmation' => $confirmation_email
));
if( $validator->fails() ){
$this->error_code = 400;
$this->error_message = 'validation error(s) have occurred.';
$this->error_payload = $validator->errors();
return False;
}
$confirmation_code = str_random(30);
$returned = $this->user->update($user_id, array(
'email' => $new_email,
'confirmed' => 0,
'confirmation_code' => $confirmation_code
));
if( !$returned ){
$this->error_code = 500;
$this->error_message = 'an internal error occurred while ';
$this->error_message .= 'attempting to update user record.';
return False;
}
$this->sendConfirmationCodeEmail($user_id);
return True;
}
}
class UserController extends Controller{
private $user;
private $processor;
public function __construct(UserRepositoryContract $user, UserProcessor $processor){
$this->middleware('auth.api', [ 'except' => ['verifyEmail', 'updateEmail', 'changeName', 'changePassword', 'deleteAccount'] ]);
$this->user = $user;
$this->processor = $processor;
}
public function updateEmail(Request $request, $user_id){
$response = $this->processor->updateEmail($user_id, $request->email, $request->email_confirmation);
if( !$response ){
$error = $this->processor->error();
return $this->responseBuilder(
'fail',
$error['code'],
$error['message'],
$errors['payload']
);
}
return $this->responseBuilder('success', 200, 'successfully updated user\'s email.');
}
}
I have a form, which has to be passed by some other validations than unusual (about 4 fields are depending from each other). Thing is, when its failed, I redirect the user back, but then the form loses its values, I dont want it. I know it can be done with session, but there might be a "sanitier" way. Code is usual:
public function printAction()
{
if ($this->getRequest()->getMethod() == "POST")
{
$form->bindRequest($this->getRequest());
if ($form->isValid())
{
.... more validation.... Failed!
return $this->redirect($this->generateUrl("SiteHomePeltexStockStockHistory_print"));
// and this is when I lose the values.... I dont want it
}
}
}
You can use the same action for both GET and POST requests related to a form. If validation fails, just don't redirect and the same form will be redisplayed with entered values and validation error messages:
/**
* #Template
*/
public function addAction(Request $request)
{
$form = /* ... */;
if ($request->isMethod('POST')) {
$form->bind($request);
if ($form->isValid()) {
// do something and redirect
}
// the form is not valid, so do nothing and the form will be redisplayed
}
return [
'form' => $form->createView(),
];
}
You can passe your parametters to the new page when making the new redirection:
$this->redirect($this->generateUrl('SiteHomePeltexStockStockHistory_print', array('name1' => 'input1', 'name2' => 'input2', 'name3' => $input3, ....)));
or directly pass an array of post values:
$this->redirect($this->generateUrl('SiteHomePeltexStockStockHistory_print', array('values' => $values_array)));
You may want to do something like this
class FooController extends Controller
{
/**
* #Route("/new")
* #Method({"GET"})
*/
public function newAction()
{
// This view would send the form content to /create
return $this->render('YourBundle:form:create.html.twig', array('form' => $form));
}
/**
* #Route("/create")
* #Method({"POST"})
*/
public function createAction(Request $request)
{
// ... Code
if ($form->isValid()) {
if (/* Still valid */) {
// Whatever you do when validation passed
}
}
// Validation failed, just pass the form
return $this->render('YourBundle:form:create.html.twig', array('form' => $form));
}
}