Symfony Api Rest which validator process - php

My question concerns various process of json payload validations.
I have recensed :
- deserialization on a model, calling validator service and validate the hydrated object.
- using FormType (even if there are no forms...just json feeds) and validate the form builder after injecting $datas.
Which one do you prefer ?
Have you a better solution ? Such as maybe a middleware (unique bundle ou app that deals with all in/out-coming payloads - request/response)
Thank You

I validate/deserialize with the native listeners/tools FOSRestBundle provides.
Making use of the bundle you can have native form-validation ... or automatically deserialized and validated models plus a list of validation errors injected as controller arguments.
# app/config/config.yml
# You need SensioFrameworkExtraBundle for body converters to work
sensio_framework_extra:
request: { converters: true }
fos_rest:
zone:
- path: '^/api/(.*)+$'
# [..]
body_listener:
enabled: true
default_format: json
decoders:
json: fos_rest.decoder.jsontoform
# automatically injects query parameters into controller Actions
# see #FOSRest\QueryParam in the example below
param_fetcher_listener: force
# https://symfony.com/doc/master/bundles/FOSRestBundle/request_body_converter_listener.html
body_converter:
enabled: true
validate: true
validation_errors_argument: validationErrors
The body converter can deserialize and validate models automatically for you (without using any forms or manual steps). Example:
/**
* #ParamConverter(
* "post",
* converter = "fos_rest.request_body",
* options = {
* "validator" = {
* "groups" = {
* "validation-group-one",
* "validation-group-two",
* }
* },
* "deserializationContext" = {
* "groups" = {
* "serializer-group-one",
* "serializer-group-two"
* },
* "version"="1.0"
* }
* }
* )
*/
public function putPostAction(Post $post, ConstraintViolationListInterface $validationErrors)
{
if (!empty($validationErrors)) {
// return some 4xx reponse
}
// Do something with your deserialized and valid Post model
The bundle can serialize forms (and form-errors) to JSON, too.
i.e. a form with invalid fields will be rendered as:
{
"code": 400,
"message": "Validation Failed",
"errors": {
"errors": [
"This is a global form error."
],
"children": {
"oldPassword": {
"errors": [
"The old password is not correct."
]
},
"newPassword": [],
"submit": []
}
}
}
FOSRestBundle provides a request body listener that automatically decodes Content-Type: application/json to Content: application/x-www-form-urlencoded within the Request object so you can bind the request to the form with handleRequest as you'd do with normal HTML forms.
Quick tip: if you just want to validate your data asynchronously ... you can send the request with a query param (?validate=true in the following example) and return an early response with HTTP 200 (OK) / 202 (Accepted) before performing any business logic.
The following example shows an endpoint that accepts requests of the form:
{
"oldPassword": "xxxxxxx",
"newPassword": "yyyyyyy"
}
Corresponding controller action:
/**
* #FOSRest\Route(
* "/profile/change-password",
* name="api_put_password",
* methods={
* Request::METHOD_PUT
* }
* )
*
* #FOSRest\QueryParam(
* name="validate",
* allowBlank=false,
* default="false",
* strict=true,
* nullable=true,
* requirements="^(true|false)$"
* )
*/
public function putPasswordAction(Request $request, string $validate = 'false')
{
$validate = filter_var($validate, FILTER_VALIDATE_BOOLEAN);
$form = $this->formFactory->createNamed(null, ChangePasswordType::class, null, [
'action' => $this->router->generateUrl('api_put_password'),
'method' => $request->getMethod(),
]);
$form->handleRequest($request);
if (!$form->isValid()) {
$view = new View();
$view->setStatusCode(Response::HTTP_BAD_REQUEST);
$view->setData($form);
return $view;
}
if ($validate) {
$view = new View();
$responseCode = Response::HTTP_ACCEPTED;
$view->setStatusCode($responseCode);
$view->setData([
'code' => $responseCode,
'message' => 'Data is valid.',
'data' => null
]);
return $view;
}
$user = $this->securityContext->getToken()->getUser();
/** #var PasswordChangeRequest $passwordChangeRequest */
$passwordChangeRequest = $form->getData();
$user->setPassword($this->passwordEncoder->encodePassword($user, $passwordChangeRequest->getNewPassword()));
$this->userManager->persist($user);
$view = new View();
$view->setStatusCode(Response::HTTP_OK);
$view->setData([
'code' => Response::HTTP_OK,
'message' => 'Password changed successfully.',
'data' => $user
]);
$context = new Context();
$context->setGroups([
'profile'
]);
$view->setContext($context);
return $view;
}

Related

Laravel Lumen Guzzle times out with no obvious reason why, error code 28 curl

I'm currently working on a Laravel 8 project, I have two projects:
A Laravel 8 project used as an API, it exposes some endpoints
A Laravel Lumen 8 project which runs on it's own domain.
Both have Cors enabled, and both run on the same domain, I'm having issues with Guzzle in my Lumen project connecting to an endpoint that exists on my Laravel API, here's the scenario and request flow:
A request comes in to: /hub/microservice/fudge-api-reports on the Laravel api, this is it's controller method:
/**
* Route the microservice
*
* #param \Illuminate\Http\Request $request
* #return \Illuminate\Http\Response
*/
public function microservice(Request $request, $service)
{
$validator = Validator::make($request->all(), [
'endpoint' => 'required|string',
'method' => 'required|string|in:post,get',
'data' => 'nullable|array'
]);
if ($validator->fails()) {
return response()->json([
'message' => "One or more fields has been missed or is invalid.",
'errors' => $validator->messages()
], 400);
}
$microservice = Microservice::where('microservice', $service)->first();
if (!$microservice) {
return response()->json([
'message' => "The microservice you're trying to access is invalid or doesn't exist"
], 404);
}
// auth
$auth = 'Bearer ' . $microservice->hub_access_token;
// the url
$url = $microservice->hub_domain . $request->input('endpoint');
// define how to communicate with the microservice
if ($request->input('method') == 'post') {
if ($microservice->hub_access_token) {
$response = Http::timeout(60)->withHeaders([
'Authorization' => $auth
])->post($url, $request->input('data'));
} else {
$response = Http::timeout(60)->post($url, $request->input('data'));
}
} else {
if ($microservice->hub_access_token) {
$response = Http::timeout(60)->withHeaders([
'Authorization' => $auth
])->get($url);
} else {
$response = Http::timeout(60)->get($url);
}
}
// the response from the microservice
return response()->json($response->json(), $response->status());
}
The request then (based on the endpoint and method) goes to the microservice which runs on it's own domain, mine is: http://localhost:8001/, my URL would be a GET request, and would have an access token, so it makes it into the else statement above and into the first if, e.g: http://localhost:8001/api/reports?report=MyReport and has a token as the header.
The request comes into the fudge-api-reports Laravel Lumen project, and goes to a controller method, but first passes through my BeforeMiddleware where it performs a "log in" request back to the Laravel API to authenticate and check the abilities, this part of the middleware is:
<?php
namespace App\Http\Middleware;
use Closure;
use GuzzleHttp\Client;
class BeforeMiddleware
{
/**
* Request attributes
*
*/
public $attributes;
/**
* Get API url
*/
protected function getApiUrl()
{
return rtrim(config('fudge.fudge_api_domain'), '/');
}
/**
* Handle an incoming request.
*
* #param \Illuminate\Http\Request $request
* #param \Closure $next
* #return mixed
*/
public function handle($request, Closure $next, $ability)
{
$api = $this->getApiUrl();
$token = $request->input('token');
if (!$token && $request->header('Authorization')) {
$token = explode(' ', $request->header('Authorization'))[1];
}
// TODO: this part appears to ALWAYS time out, despite
// http://localhost:8000/api/hub/login working just fine via Postman
$client = new Client([
'base_uri' => $api,
'timeout' => 5
]);
$res = $client->request('POST', '/api/hub/login', [
'token' => $token,
'ability' => "reports:$ability"
]);
// the response
$res = $res->json();
$hasAbility = isset($res['has_ability']) && !empty($res['has_ability']) ? $res['has_ability'] : false;
// not authorised
if (!$hasAbility) {
return response()->json([
'message' => "You aren't authorised"
], 200);
}
// add the hub's user to the request
$request->attributes->add(['has_ability' => $hasAbility]);
// Post-Middleware Action
return $next($request);
}
}
If the Hub log in is successful, then a has_ability is returned with the value of true back to the the middleware, which then goes through the controller method and finally returns the response back to the initial request of: /hub/microservice/fudge-api-reports
My issue
in my BeforeMiddleware my Guzzle POST request always fails, and never returns a response, it always seems to time out accessing my /api/hub/login endpoint that exists in my Laravel project.
It works perfectly fine through Postman, and Cors is enabled, why would this always timeout in the context of the Middleware, what am I missing?

Symfony Serializer: Deserializing Json to Entity

I am trying to use Symfony's Serializer to deserialize a Json to my entity "DossierDTO".
class DossierDTO
{
#[Groups(['test'])]
public string $idActeurCreateur;
#[Groups(['test'])]
public string $idDossierVise;
#[Groups(['test'])]
public string $idProjet;
public ArrayCollection $personnes;
public ArrayCollection $terrains;
.
.
.
more fields
I would like to deserialize only the fields tagged with the #[Groups(['test'])] annotations.
Here is my call to fetch the json object and my attempt to deserialize it:
/**
* Make a request to API
* #param string $method: request method (POST, GET...)
* #param string $suffix: URI suffix (/example)
* #param array $body: request body
* #throws Exception
* #return ResponseInterface
*/
public function myRequest(string $method, string $suffix, ?array $body): ResponseInterface
{
$jsonContent = is_null($body) ? json_encode(new stdClass) : $this->serializer->serialize($body, 'json');
try {
$response = $this->client->request($method, $this->infos["uri"] . $suffix, [
'headers' => $this->infos["headers"],
'body' => $jsonContent
]);
} catch (Exception $e) {
$this->logger->error($e->getMessage());
}
$dossier = $this->serializer->deserialize($response->getContent(), DossierDTO::class, 'json', ["groups" => "test"]);
dd($dossier, $response->getContent());
}
And this is what my dump shows:
So basically, I don't get the fields that I would like to, even when I remove the "#[Groups(['test'])]" the result is the same.
It always shows me the two ArrayCollection fields (empty) and only these...
I'm working with Symfony 5.2.9
From your screenshot I can see that response JSON has nested object, keyed by "projet". Looks like you are mapping incorrect structure. Try this:
$this->serializer->deserialize($response->getContent()['projet'], DossierDTO::class, 'json', ["groups" => "test"]);

FOSRestBundle EntityType not working in JSON

I am using Symfony 3.4 and FOSRestBundle for my APIs.
All the services are working fine except this one where I am posting an entity with a form and an EntityType field.
Controller:
public function createAssistanceCallAction(Request $request)
{
$assistanceCall = new AssistanceCall();
$form = $this->createForm(AssistanceCallType::class, $assistanceCall);
$form->handleRequest($request);
dump($form->isSubmitted(), $form->isValid());die;
}
Entity property:
/**
* #var MobileAppUser
*
* #ORM\ManyToOne(targetEntity="MobileAppUser")
* #ORM\JoinColumn(name="mobile_app_user_id", referencedColumnName="id", nullable=false)
* #Assert\NotBlank
*/
protected $mobileAppUser;
Form:
/**
* {#inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('mobile_app_user', EntityType::class, array(
'class' => 'AppBundle:MobileAppUser',
))
->add('save', SubmitType::class)
;
}
It's working fine with a normal POST:
curl -X POST http://mysite.local/api/users/1/create-assistance-call -F 'assistance_call[mobile_app_user]=26'
dump($form->isSubmitted(), $form->isValid());die; // true and true
It's not working with JSON format:
curl -X POST \
http://mysite.local/api/users/1/create-assistance-call \
-d '{
"assistance_call": {
"mobile_app_user": {
"id": 1
}
}
}'
dump($form->isSubmitted(), $form->isValid());die; // false and false
What am I doing wrong in the JSON example?
Just to add to the first answer, you also need to modify your JSON payload.
You have to put directly your mobile_app_user id :
{
"assistance_call": {
"mobile_app_user": 1
}
}
Since you are sending your json object as a request body and not as normal POST fields - you should json_decode request content first and then use $form->submit(...) to load your form with the data. The old good handleRequest() won't do here. Take a look at the following example:
public function createAssistanceCallAction(Request $request)
{
$assistanceCall = new AssistanceCall();
$form = $this->createForm(AssistanceCallType::class, $assistanceCall);
//Notice the "true" argument passed to the json_decode
$data = json_decode($request->getContent(), true);
$form->submit($data);
dump($form->isSubmitted(), $form->isValid());die;
}
You can also use FOSRestBundle BodyListener to decode json for you. To do that - add the following configuration entry:
fos_rest:
body_listener:
decoders:
json: fos_rest.decoder.jsontoform
This still doesn't make $form->handleRequest() a good choice since it will only allow one method per form, so if you configure your form to do POST - the PUT requests will always fail without any explicit error messages.
So then you would amend the code above like this:
public function createAssistanceCallAction(Request $request)
{
$assistanceCall = new AssistanceCall();
$form = $this->createForm(AssistanceCallType::class, $assistanceCall);
$form->submit($request->request->all());
dump($form->isSubmitted(), $form->isValid());die;
}
Pay attention to the Content-Type header you send as if it is set to 'multipart/form-data' while having json payload in its body - decoder will fail.

How to handle invalid forms for a REST POST?

I am developing a website that offers a REST service. All the GET actions are OK and rendered using a .json.twig template, but I am having a hard time understanding how to output form errors if the query made to create a new record is not valid.
If I try to do a simple
return $form;
I get the following exception from SF:
"exception":[{"message":"Unable to find template \"SomeBundle:Customers:postCustomer.json.twig\"}]
The template does not exist, that's true, but I have no idea how to create one in JSON format to tell the requestor that his query is incomplete / malformed.
If I try anything else dealing with views but without specifying a template, the result is the same. Is there a way to do that automatically so that if the form is modified the change are reflected as well in the error ?
Or a way to tell FOSRestBundle / JMSSerializerBundle to deal with the serialization themselves ? Before switching to Twig responses the error was nicely handled, and I'd like to have that back, along with the Twig templates for normal operations.
For information, my current controller's action is:
/**
* #ApiDoc(
* resource=false,
* input="SomeBundle\Form\CustomerType",
* description="Create a new customer",
* section="Customers",
* statusCode={
* 201="Action successful",
* 403="Authorization required but incorrect / missing information or insufficient rights",
* 500="Returned if action failed for unknown reasons"
* }
* )
*
* --View(template="SomeBundle:Customers:add.json.twig", templateVar="form", statusCode=400)
* #View(templateVar="form", statusCode=400)
* #param Request $request
* #return \FOS\RestBundle\View\View
*/
public function postCustomerAction(Request $request) {
$data = json_decode($request->getContent(), true);
$manager = $this->getManager();
$customer = new Customer();
$form = $this->getForm($customer);
//$form->submit($data);
//$manager->create($customer);
// $form->handleRequest($request);
// if ($form->isSubmitted() && $form->isValid()) {
// $manager->create($customer);
//
// return $this->redirectView($this->generateUrl('api_get_customer_internal', ['uuid' => $customer->getInternalUuid()], true),
// 201);
// }
return $form;
//return $this->handleView($this->view($form, 400));
//return \FOS\RestBundle\View\View::create($form, 400);
}
And the FOSRestBundle configuration:
fos_rest:
param_fetcher_listener: true
body_listener: true
format_listener:
enabled: true
view:
view_response_listener: 'force'
formats:
json: true
templating_formats:
json: true
force_redirects:
html: true
failed_validation: HTTP_BAD_REQUEST
default_engine: twig
routing_loader:
include_format: false
default_format: json
serializer:
serialize_null: true
sensio_framework_extra:
view:
annotations: true
Thanks to jorge07 at https://github.com/FriendsOfSymfony/FOSRestBundle/issues/1620 I was able to find a way to circumvent that in a rather proper way (at least IMHO), here's the updated Controller action (no change in the fosrestbundle settings required):
/**
* #Route("/customers")
* #ApiDoc(
* resource=false,
* input="NetDev\CoreBundle\Form\CustomerType",
* description="Create a new customer",
* section="Customers",
* statusCode={
* 201="Action successful",
* 403="Authorization required but incorrect / missing information or insufficient rights",
* 500="Returned if action failed for unknown reasons"
* }
* )
*
* #View(template="NetDevRestBundle:Common:form_error.json.twig", templateVar="errors", statusCode=400)
*
* #RequestParam(name="customerName", nullable=false)
* #RequestParam(name="customerIndex", nullable=false)
*
* #return \FOS\RestBundle\View\View
*/
public function postCustomerAction(ParamFetcher $fetcher)
{
$customer = new Customer();
$form = $this->getForm($customer);
$form->submit($fetcher->all(), true);
if ($form->isValid()) {
$manager = $this->getManager();
$manager->create($customer);
return $this->redirectView($this->generateUrl('api_get_customer_internal', ['uuid' => $customer->getInternalUuid()], true), 201);
}
$err = $form->getErrors();
$errorsList = [];
foreach ($err as $it) {
$errorsList[(string)$it->getOrigin()->getPropertyPath()] = $it->getMessage();
}
return $this->view([$errorsList])
->setTemplateVar('errors')
;
}

How to return or display data in JSON format using FOSRestBundle

I am working in a Restful API using Symfony2 and FOSRestBundle. I have read view layer docs but is not clear to me how to handle output for API. What I want to achieve is simple: display or return or output the result as valid JSON. This is what I have at controller:
<?php
/**
* RestAPI: Company.
*/
namespace PDI\PDOneBundle\Controller\Rest;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Request\ParamFetcherInterface;
use Nelmio\ApiDocBundle\Annotation\ApiDoc;
use FOS\RestBundle\Controller\Annotations\QueryParam;
use FOS\RestBundle\Controller\Annotations\Get;
class CompanyRestController extends FOSRestController
{
/**
* Gets all companies.
*
* #return array
*
* #ApiDoc(
* resource = true,
* https = true,
* description = "Gets all companies",
* statusCodes = {
* 200 = "Returned when successful",
* 400 = "Returned when errors"
* }
* )
* #Get("/api/v1/companies")
*
*/
public function getCompaniesAction()
{
$response = array();
$em = $this->getDoctrine()->getManager();
$entities = $em->getRepository('PDOneBundle:Company')->findAll();
if ($entities) {
foreach ($entities as $entity) {
$response['companies'][] = [
'id' => $entity->getId(),
'createdAt' => $entity->getCreatedAt(),
'updatedAt' => $entity->getUpdatedAt(),
'name' => $entity->getName(),
'logo_url' => $entity->getLogoUrl(),
'division' => $entity->getDivision(),
'inactive' => $entity->getInactive(),
];
}
$response['status'] = 'ok';
} else {
$response['status'] = 'error';
}
return $response;
}
}
If I try this URL: /app_dev.php/api/v1/companies.json I got 404 error:
{"code":404,"message":"No route found for \"GET\/api\/v1\/companies.json\""}
If I try this URL: https://reptool.dev/app_dev.php/api/v1/companies error turns on:
Unable to find template "". 500 Internal Server Error -
InvalidArgumentException 3 linked Exceptions: Twig_Error_Loader »
InvalidArgumentException » InvalidArgumentException »
I've also check FOSRestBundleByExample but didn't get much help.
What I am missing here? How do I achieve what I need? Any advice?
FOSRest Config
I forgot to add the FOSRestBundle at config.yml:
#FOSRestBundle
fos_rest:
param_fetcher_listener: true
body_listener: true
format_listener:
rules:
- { path: ^/, priorities: [ json, html ], fallback_format: ~, prefer_extension: true }
media_type:
version_regex: '/(v|version)=(?P<version>[0-9\.]+)/'
body_converter:
enabled: true
validate: true
view:
mime_types:
json: ['application/json', 'application/json;version=1.0', 'application/json;version=1.1']
view_response_listener: 'force'
formats:
xml: false
json: true
templating_formats:
html: true
exception:
codes:
'Symfony\Component\Routing\Exception\ResourceNotFoundException': 404
'Doctrine\ORM\OptimisticLockException': HTTP_CONFLICT
messages:
'Symfony\Component\Routing\Exception\ResourceNotFoundException': true
allowed_methods_listener: true
access_denied_listener:
json: true
I feel your pain. I had troubles getting started as well. One important place to start is the config. Here's what I use in my implementation.
fos_rest:
param_fetcher_listener: true
view:
mime_types:
json: ['application/json', 'application/json;version=1.0', 'application/json;version=1.1']
view_response_listener: 'force'
formats:
xml: false
json: true
templating_formats:
html: true
format_listener:
rules:
- { path: ^/, priorities: [ json, html ], fallback_format: ~, prefer_extension: true }
media_type:
version_regex: '/(v|version)=(?P<version>[0-9\.]+)/'
exception:
codes:
'Symfony\Component\Routing\Exception\ResourceNotFoundException': 404
'Doctrine\ORM\OptimisticLockException': HTTP_CONFLICT
messages:
'Symfony\Component\Routing\Exception\ResourceNotFoundException': true
allowed_methods_listener: true
access_denied_listener:
json: true
body_listener: true
In the format_listener if you want JSON to be the default response, make sure it's set first in priorities. Otherwise your header will need to include Accept: application/json every time. This may be why you're getting a twig error as it's trying to use twig to render an HTML output.
Also, make sure you have a serializer like http://jmsyst.com/bundles/JMSSerializerBundle installed and included in your AppKernal.
In your controller I found it easiest to extend the FOSRestController like you did, but also return a view object instead of creating the array yourself. The serializer will handle all of that for you.
/**
* RestAPI: Company.
*/
namespace PDI\PDOneBundle\Controller\Rest;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Request\ParamFetcherInterface;
use Nelmio\ApiDocBundle\Annotation\ApiDoc;
use FOS\RestBundle\Controller\Annotations\QueryParam;
use FOS\RestBundle\Controller\Annotations\Get;
class CompanyRestController extends FOSRestController
{
/**
* Gets all companies.
*
* #return array
*
* #ApiDoc(
* resource = true,
* https = true,
* description = "Gets all companies",
* statusCodes = {
* 200 = "Returned when successful",
* 400 = "Returned when errors"
* }
* )
* #Get("/api/v1/companies")
*
*/
public function getCompaniesAction()
{
$response = array();
$em = $this->getDoctrine()->getManager();
$entities = $em->getRepository('PDOneBundle:Company')->findAll();
if(!$entities)
{
return $this->view(null, 400);
}
return $this->view($entities, 200);
}
}
I hope this helps a little.

Categories