Why do I get an error associated with PHP8 #[ApiResource()] attribute when using "php bin/console cache:clear"command in Symfony project? - api-platform.com

I'm a rookie in development and it's my first time to use PHP8 attributes so I don't have a clue to guess the problem's orgin. :s
I'm trying to configure APIPlatform to determine which properties users can see associated with basic HTTP operations in my API project.
For this, I'm using PHP8 attributes for ApiPlatform : #[ApiResource()] and #[Groups()].
I'm using PHP8 - Symfony 5.3 - VSCode 1.58.2 - Window 10
Problems :
It seems in the documentation that the HTTP Operations configure with normalizationContext are ok (as expected, PUT is no longer visible in my api documentation) but the properties are not affected (all the properties are still send when I'm doing a request). I assumed that serialization groups aren't working properly...
I tried to clear symfony cache with the command "php bin/console cache:clear". I received a message to tell me that I have a parse error in my #[ApiResource()] attribute but I can't find it.
Here my code :
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use App\Repository\ClientRepository;
use Doctrine\Common\Collections\Collection;
use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* #ORM\Entity(repositoryClass=ClientRepository::class)
*/
#[ApiResource(
collectionOperations: [
'get' => [
'normalizationContext' => [
'groups' => [
'read_Client_collection',
],
],
],
'post' => [
'denormalizationContext' => [
'groups' => [
'write_Client_item',
],
],
],
],
itemOperations: [
'get' => [
'normalizationContext' => [
'groups' => [
'read_Client_item',
],
],
],
'delete',
'patch' => [
'denormalizationContext' => [
'groups' => [
'write_Client_item',
],
],
],
],
)]
class Client
{
/**
* #ORM\Id
* #ORM\GeneratedValue
* #ORM\Column(type="integer")
*/
#[Groups(['read_Client_collection', 'read_User_collection', 'read_User_item'])]
private $id;
/**
* #ORM\Column(type="string", length=75)
*/
#[Groups(['read_Client_collection', 'read_Client_item', 'read_User_collection', 'read_User_item', 'write_Client_item'])]
private $companyName;
/**
* #ORM\Column(type="string", length=20, nullable=true)
*/
#[Groups(['read_Client_item', 'write_Client_item'])]
private $phoneNumber;
/**
* #ORM\Column(type="string", length=255)
*/
#[Groups(['read_Client_item', 'write_Client_item'])]
private $address;
/**
* #ORM\Column(type="string", length=45)
*/
#[Groups(['read_Client_item', 'write_Client_item'])]
private $SiretNumber;
/**
* #ORM\OneToMany(targetEntity=User::class, mappedBy="client")
*/
private $users;
public function __construct()
{
$this->users = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getCompanyName(): ?string
{
return $this->companyName;
}
public function setCompanyName(string $companyName): self
{
$this->companyName = $companyName;
return $this;
}
public function getAddress(): ?string
{
return $this->address;
}
public function setAddress(string $address): self
{
$this->address = $address;
return $this;
}
public function getSiretNumber(): ?string
{
return $this->SiretNumber;
}
public function setSiretNumber(string $SiretNumber): self
{
$this->SiretNumber = $SiretNumber;
return $this;
}
public function getPhoneNumber(): ?string
{
return $this->phoneNumber;
}
public function setPhoneNumber(?string $phoneNumber): self
{
$this->phoneNumber = $phoneNumber;
return $this;
}
/**
* #return Collection|User[]
*/
public function getUsers(): Collection
{
return $this->users;
}
public function addUser(User $user): self
{
if (!$this->users->contains($user)) {
$this->users[] = $user;
$user->setClient($this);
}
return $this;
}
public function removeUser(User $user): self
{
if ($this->users->removeElement($user)) {
// set the owning side to null (unless already changed)
if ($user->getClient() === $this) {
$user->setClient(null);
}
}
return $this;
}
}
Here the error in my terminal when I'm trying "php bin/console cache:clear" command (the line 32 is the one juste before "itemOperations: [") :
ParseError {#6596
#message: "syntax error, unexpected ','"
#code: 0
#file: "D:\...\project\src\Entity\Client.php"
#line: 32
trace: {
D:\...\project\src\Entity\Client.php:32 {
Symfony\Component\ErrorHandler\DebugClassLoader->loadClass(string $class): void^
› ],
› ],
› itemOperations: [
}
Symfony\Component\ErrorHandler\DebugClassLoader->loadClass() {}
spl_autoload_call() {}
D:\...\project\vendor\doctrine\annotations\lib\Doctrine\Common\Annotations\DocParser.php:1123 { …}
D:\...\project\vendor\doctrine\annotations\lib\Doctrine\Common\Annotations\DocParser.php:1245 { …}
D:\...\project\vendor\doctrine\annotations\lib\Doctrine\Common\Annotations\DocParser.php:1300 { …}
D:\...\project\vendor\doctrine\annotations\lib\Doctrine\Common\Annotations\DocParser.php:1220 { …}
D:\...\project\vendor\doctrine\annotations\lib\Doctrine\Common\Annotations\DocParser.php:1053 { …}
D:\...\project\vendor\doctrine\annotations\lib\Doctrine\Common\Annotations\DocParser.php:1035 { …}
D:\...\project\vendor\doctrine\annotations\lib\Doctrine\Common\Annotations\DocParser.php:869 { …}
D:\...\project\vendor\doctrine\annotations\lib\Doctrine\Common\Annotations\DocParser.php:719 { …}
D:\...\project\vendor\doctrine\annotations\lib\Doctrine\Common\Annotations\DocParser.php:376 { …}
D:\...\project\vendor\doctrine\annotations\lib\Doctrine\Common\Annotations\AnnotationReader.php:178 { …}
D:\...\project\vendor\doctrine\annotations\lib\Doctrine\Common\Annotations\PsrCachedReader.php:155 { …}
D:\...\project\vendor\doctrine\annotations\lib\Doctrine\Common\Annotations\PsrCachedReader.php:88 { …}
D:\...\project\vendor\api-platform\core\src\Util\AnnotationFilterExtractorTrait.php:47 { …}
D:\...\project\vendor\api-platform\core\src\Util\AnnotationFilterExtractorTrait.php:112 { …}
D:\...\project\vendor\api-platform\core\src\Bridge\Symfony\Bundle\DependencyInjection\Compiler\AnnotationFilterPass.php:65 { …}
D:\...\project\vendor\symfony\dependency-injection\Compiler\Compiler.php:91 { …}
D:\...\project\vendor\symfony\dependency-injection\ContainerBuilder.php:749 { …}
D:\...\project\vendor\symfony\http-kernel\Kernel.php:545 { …}
D:\...\project\vendor\symfony\http-kernel\Kernel.php:786 { …}
D:\...\project\vendor\symfony\http-kernel\Kernel.php:125 { …}
D:\...\project\vendor\symfony\framework-bundle\Console\Application.php:168 { …}
D:\...\project\vendor\symfony\framework-bundle\Console\Application.php:74 { …}
D:\...\project\vendor\symfony\console\Application.php:167 { …}
D:\...\project\vendor\symfony\runtime\Runner\Symfony\ConsoleApplicationRunner.php:56 { …}
D:\...\project\vendor\autoload_runtime.php:35 { …}
D:\...\project\bin\console:11 { …}
}
}
I tried without success to :
change my groups name from "write:Client:collection" to "write_Client_Collection"
add an attributes:[] before CollectionOperations
#[ApiResource(
attributes: [
'collectionOperations' => [
// ...
],
'itemOperations' => [
// ...
]
])
]
put all the attributes specifications on a single line to see like this :
#[ApiResource(collectionOperations: ['get' => ['normalizationContext' => ['groups' => ['read_Client_collection',],],],'post' => ['denormalizationContext' => ['groups' => ['write_Client_item',],],],],itemOperations: ['get' => ['normalizationContext' => ['groups' => ['read_Client_item',],],],'delete','patch' => ['denormalizationContext' => ['groups' => ['write_Client_item',],],],],)]
It works for the parse error... but my API documentation was totally empty.
I tried to change my windows end of line CRLF to linux LF in VS Code but it doesn't work.
I'm running out of ideas...
Thank you for your help :)
Notice : The problem is not specific to this entity file : when I suppressed the ApiResource attribute in the Client entity, the problem switch to an other resource entity of the project.

I manage to fix my problems... I don't know if somenone else could be interrested in the solution but just in case...
Problem 1 : normalization groups doesn't work :
By writing
'normalizationContext' => [
I mixed 2 different syntaxes :
#[ApiResource(
normalizationContext: ['groups' => ['read']],
denormalizationContext: ['groups' => ['write']],
)]
with
#[ApiResource(attributes: [
'normalization_context' => ['groups' => ['read']],
'denormalization_context' => ['groups' => ['write']],
])]
I replaced 'normalizationContext' by 'normalization_context' and groups works fine now...
Problem 2 : parse error in #[ApiAttributes()] when I'm using "symfony cache:clear" command :
My PHP 8.0.3 is determined by .php-version file at the root of the folder where my symfony project is but the rest of my windows system run PHP 7.3.12.
I change my system PHP version for 8.0.3 too and the problem is now solved.
(On window 10, for the rookie like me :
right click on windows icon in bottom left screen corner
click on "System"
click on "advanced system parameters" (right part of the screen)
click on "environnement variables" button
In system variables, select "paths" and click on "modify"
If the php version you want is not in the list, add the path.
select it and move it to the top of the php version list.)
I presume the problem is due to my composer installation : I made a global installation for it, so when I change composer.json file to indicate php >=8.0, this action create a conflict with the PHP version running on the system. A message tell me so when I tried a "composer update" command.
Perhaps I'm wrong so if someone could confirm my interpretations, it could be great. Thank you all.
EDIT :
I figure it out how to have composer with the same PHP version choosen for the project instead of the system PHP version.
After the creation of .php-version file in the project parent folder (where you note the php version to specify to Symfony client which one you want for this project specifically), you have to run 'symfony composer diagnose' command to change composer PHP version accordingly.

This also works:
#[ApiResource(
attributes: [
'normalization_context' => [
'groups' => ['clients:read'],
],
'denormalization_context' => [
'groups' => ['clients:write'],
],
],
)]

Related

Merge FormRequest rules

This question is based on this thread: Merge 2 rules FormFequest for validate an update and store action in laravel5.5
Context: Let's suppose I have these 2 requests and I want to merge the SocialMediaFormRequest rules in ReadersFormRequest rules.
ReadersFormRequest
class ReadersFormRequest extends FormRequest
{
public function rules(SocialMediaFormRequest $social)
{
$mediaRules = $social->rules();
$rules = [
'first_name'=>'required',
'last_name'=>'required',
'birthday'=>'required',
'region'=>'required',
'photo_url'=>'required',
'support'=>'required',
'riwayas_id'=>'required',
'description'=>'required',
];
return array_merge($rules,$mediaRules);
}
}
SocialMediaFormRequest
class SocialMediaFormRequest extends FormRequest
{
public function rules()
{
return [
'url'=>'required|url',
'title'=>'required'
];
}
}
Form that I received
first_name: "example"
last_name: "example"
birthday: 2022-06-13
region: somewhere
photo_url: "https:XXX"
support: false
riwayas_id: 1
description: ""
media.url: "https:YYY"
media.title: "stackoverflow"
Question: How can I only pass the argument media.XXX in my form SocialMediaFormRequest?
You can use prepareForValidation() method in the form request to sanitize the inputs : https://laravel.com/docs/9.x/validation#preparing-input-for-validation
So, if in SocialMediaFormRequest you receive the full request you can only get the required fields like that:
public function prepareForValidation()
{
$this->replace([
'url' => $this->url ?? ($this->media['url'] ?? null),
'title' => $this->title ?? ($this->media['title'] ?? null),
]);
}
Also, in ReadersFormRequest when you inject the other request or resolve it from the container it doesn't work correctly, so it is better to get the rules like that:
public function rules()
{
$mediaRules = (new SocialMediaFormRequest())->rules();
and in order to access the media.* attributes in ReadersFormRequest you can again use prepareForValidation:
public function prepareForValidation()
{
$this->merge([
'url' => $this->media['url'] ?? null,
'title' => $this->media['title'] ?? null,
]);
}

Laravel dusk with browserstack to run tests on multiple devices and browsers

I am using this ("browserstack/browserstack-local": "^1.1") package to run dusk tests on BrowserStack. Now the requirement is to run tests on multiple and different devices with different browsers. Currently, I am following this approach to run tests.
private function browserStackCaps($local_identifier)
{
return [
'project' => config('app.name'),
'browserstack.local' => 'true',
'browser' => env('BROWSER'),
'device' => env('DEVICE'),
'acceptSslCert' => true,
'resolution' => '1920x1080'
];
}
The drawback of this approach is I have to change the device name and browser name in the .env file every time I need to run tests on a different device/browser. Is there any way I can run tests on the provided array? The array that contains devices and browser information.
I know this is old, but I found this page while searching for a solution. I ended up building one myself that would probably meet your use-case. The biggest hurdle that I had was $this->browse() in a normal Dusk test was using a single instance of Laravel\Dusk\Browser and the new capabilities were not being pulled in. This implementation adds a function called performTest to the DuskTestCase.php file. This function loops through a set of capabilities and instantiates a new instance of Laravel\Dusk\Browser for each test. This function works similarly to the existing browse function in Laravel Dusk. You call performTest by passing it a callable that accepts a single parameter which is an instance of Laravel\Dusk\Browser
Dusk Test Case
<?php
namespace Tests;
use Laravel\Dusk\TestCase as BaseTestCase;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities;
abstract class DuskTestCase extends BaseTestCase
{
use CreatesApplication;
protected array $capabilities;
private const BROWSERS = [
'ios_14_iphone_xs_safari' => [
"os_version" => "14",
"device" => "iPhone XS",
"real_mobile" => "true",
"browserstack.local" => "true",
'acceptSslCerts' => 'true'
],
'mac_osx_catalina_safari' => [
"os" => "OS X",
"os_version" => "Catalina",
"browser" => "Safari",
"browser_version" => "13.0",
"browserstack.local" => "true",
"browserstack.selenium_version" => "3.14.0",
"resolution" => "1920x1080",
'acceptSslCerts' => 'true',
]
];
/**
* Create the RemoteWebDriver instance.
*
* #return \Facebook\WebDriver\Remote\RemoteWebDriver
*/
protected function driver()
{
$browserStackConnectionUrl = config('browserstack.connection_url');
return RemoteWebDriver::create(
$browserStackConnectionUrl, $this->capabilities
);
}
protected function performTest(Callable $test){
foreach(self::BROWSERS as $browserName => $capabilitySet){
try {
$this->capabilities = $capabilitySet;
$browser = $this->newBrowser($this->driver());
$test($browser);
$browser->quit();
fprintf(STDOUT, "\e[0;32m√ {$browserName}\r\n");
}
catch(\Exception $exception){
fprintf(STDOUT, "\e[0;31mX {$browserName}\r\n");
throw $exception;
}
}
}
}
Example Test
<?php
namespace Tests\Browser;
use Tests\DuskTestCase;
use Laravel\Dusk\Browser;
class ExampleTest extends DuskTestCase
{
public function testExample()
{
$this->performTest(function(Browser $browser){
$browser->visit('/')
->assertDontSee('Foobar');
});
}
}
config/browserstack.php
<?php
return [
'connection_url' => env('BROWSERSTACK_CONNECTION_URL')
];
you can implement this at your end. Fetch the list of Browsers and Devices you want to execute your tests on using the REST API and use the same.
REST API to be used:
curl -u "username:password"
https://api.browserstack.com/automate/browsers.json
Read more on this here:
https://www.browserstack.com/docs/automate/api-reference/selenium/browser#get-browser-list

How to add auth user id or session to logging in Laravel 5.7?

My Goal
I'm willing to add the current authenticated user id to log lines (I'm using syslog driver) as a custom log formatting.
As part of my research, I've seen there are nice packages and tutorials to log user activity to database. But at least for now my intention is to move forward without extra packages and logging to syslog as I'm currently doing.
A typical log call in my app would look like:
logger()->info("Listing all items");
I could also do something like:
$uid = Auth::id();
logger()->info("Listing all items for {$uid}");
It would be similar to what a documentation example suggests:
I'm currently doing this for certain lines, but since I'd like to have it in all log calls it would become like repeating myself every time I log something.
Desired Output
Current:
Dec 10 23:54:05 trinsic Laravel[13606]: local.INFO: Hello world []
Desired:
Dec 10 23:54:05 trinsic Laravel[13606]: local.INFO [AUTH_USER_ID=2452]: Hello world []
My Approach
I have tried to tap the logger with success on changing the format, as suggested on docs.
But my current problem is that at the point the CustomizeFormatter class is excecuted, the Auth id seems not to be resolved just yet (not sure about this, but dd() returns null, so that's my guess):
<?php
namespace App\Logging;
use Illuminate\Support\Facades\Auth;
use Monolog\Formatter\LineFormatter;
class CustomizeFormatter
{
/**
* Customize the given logger instance.
*
* #param \Illuminate\Log\Logger $logger
* #return void
*/
public function __invoke($logger)
{
$authUser = Auth::id(); // null
foreach ($logger->getHandlers() as $handler) {
$formatter = new LineFormatter("%channel%.%level_name% [AUTH={$authUser}]: %message% %extra%");
$handler->setFormatter($formatter);
}
}
}
Setup
Laravel 5.7
Log Driver: syslog
// config/logging.php
'syslog' => [
'driver' => 'syslog',
'tap' => [ App\Logging\CustomizeFormatter::class ],
'level' => 'debug',
],
My Question(s)
Is there any way to resolve the auth user at this point, or any other approach to achieve this?
Try passing the request object as a constructor arg to your custom formatter class, then access the user from there:
<?php
namespace App\Logging;
use Illuminate\Support\Facades\Auth;
use Monolog\Formatter\LineFormatter;
class CustomizeFormatter
{
public $request;
public function __construct(Request $request)
{
$this->request = $request;
}
/**
* Customize the given logger instance.
*
* #param \Illuminate\Log\Logger $logger
* #return void
*/
public function __invoke($logger)
{
$authUser = $this->request->user();
foreach ($logger->getHandlers() as $handler) {
$formatter = new LineFormatter("%channel%.%level_name% [AUTH={$authUser->id}]: %message% %extra%");
$handler->setFormatter($formatter);
}
}
}
Based on the answer of DigitalDrifter and part of this post from Emir Karşıyakalı I managed to get a fair enough solution.
I could not grab the User ID since (as per my understanding at this point) it can't be resolved just yet. But I felt satisfied with getting a more or less accurate client id and session id, so I can trace a user interaction thread on logs:
<?php
namespace App\Loggers;
use Monolog\Formatter\LineFormatter;
class LocalLogger
{
private $request;
public function __construct(\Illuminate\Http\Request $request)
{
$this->request = $request;
}
public function __invoke($logger)
{
foreach ($logger->getHandlers() as $handler) {
$handler->setFormatter($this->getLogFormatter());
}
}
protected function getLogFormatter()
{
$uniqueClientId = $this->getUniqueClientId();
$format = str_replace(
'[%datetime%] ',
sprintf('[%%datetime%%] %s ', $uniqueClientId),
LineFormatter::SIMPLE_FORMAT
);
return new LineFormatter($format, null, true, true);
}
protected function getUniqueClientId()
{
$clientId = md5($this->request->server('HTTP_USER_AGENT').'/'.$this->request->ip());
$sessionId = \Session::getId();
return "[{$clientId}:{$sessionId}]";
}
}
Config:
// config/logger.php
'syslog' => [
'driver' => 'syslog',
'level' => 'debug',
'tap' => [App\Loggers\LocalLogger::class],
],
Result
Now I can get something like:
Dec 11 13:54:13 trinsic Fimedi[13390]: [2018-12-11 16:54:13] c6c6cb03fafd4b31493478e76b490650 local.INFO: Hello world
or
Dec 11 13:55:44 trinsic Fimedi[13390]: [2018-12-11 16:55:44] [c6c6cb03fafd4b31493478e76b490650:xUs2WxIb3TvKcpCpFNPFyvBChE88Nk0YbwZI3KrY] local.INFO: Hello world
Depending on how I calculate the user/session ids.

A different way to version api output with laravel?

I'm about to ...
extend my App/Orm/MyModel.php with Http/Json/V1/MyModel.php so I can keep the $appends, $hides, toArray() neatly tucked away in a V1
namespace and prefix some routing for V1
probably do some custom resolvers for route model binding
And I'm thinking ... really? They haven't built this in... what am I missing here? There's gotta be a quick, turn-key for this. I'm interested in knowing how other people are doing this, so please chime in.
Try Resources instead of Models
Have a look at resources:
https://laravel.com/docs/5.7/eloquent-resources
And add your logic to resources so that you display different versions of a model depending on the API version. You can still make use of $appends and $hidden.
With this approach we return a Resource of a model rather than the model itself.
Here is an example of a UserResource for different API versions:
class UserResource extends JsonResource
{
private $apiVersion;
public function __construct($resource, int $apiVersion = 2) {
$this->apiVersion = $apiVersion; // OPTION 1: Pass API version in the constructor
parent::__construct($resource);
}
public function toArray($request): array
{
// OPTION 2: Get API version in the request (ideally header)
// $apiVersion = $request->header('x-api-version', 2);
/** #var User $user */
$user = $this->resource;
return [
'type' => 'user',
'id' => $user->id,
$this->mergeWhen($this->apiVersion < 2, [
'name' => "{$user->first_name} {$user->last_name}",
], [
'name' => [
'first' => $user->first_name,
'last' => $user->last_name
],
]),
'score' => $user->score,
];
}
}
The you can call:
$user = User::find(5);
return new UserResource($user);
If you need a different connection you can do:
$user = User::on('second_db_connection')->find(5);
So V1 API gets:
{
id: 5,
name: "John Smith",
score: 5
}
and V2 API gets:
{
id: 5,
name: {
first: "John",
last: "Smith",
},
score: 5
}
Now if later you wanted to rename score to points in your DB, and in V3 of your API you also wanted to change your JSON output, but maintain backwards compatibility you can do:
$this->mergeWhen($this->apiVersion < 3, [
'score' => $user->points,
], [
'points' => $user->points,
])
Prefix routes
You can easily prefix routes as mentioned here: https://laravel.com/docs/5.7/routing#route-group-prefixes
Route::prefix('v1')->group(function () {
Route::get('users', function () {
// ...
});
});
Explicit Route Model Binding
To do custom route model bindings have a look at: https://laravel.com/docs/5.7/routing#route-model-binding
e.g.
Route::bind('user', function ($value) {
return App\User::where('name', $value)->first() ?? abort(404); // your customer logic
});

add Symfony Assert in a Callback

I, i have to add an Assert to an atribute when other atribute is equal than something. Like this:
/**
* #Assert\Callback(methods={"isChildMinor",)
*/
class PatientData
{
/**
* #Assert\Date()
*/
public $birthday;
public $role;
public function isChildMinor(ExecutionContext $context)
{
if ($this->role == 3 && check #assert\isMinor() to $birtday) {
=>add violation
}
}
so, i want check if the patient is minor (with assert or somethings else) if the role is equal than 3. How do this?
There are several ways to do, what you want.
1) You could make it right in the form. Like that:
use Symfony\Component\Validator\Constraints as Assert;
public function buildForm(FormBuilderInterface $builder, array $options)
{
$yourEntity = $builder->getData();
//here you start the field, you want to validate
$fieldOptions = [
'label' => 'Field Name',
'required' => true,
];
if ($yourEntity->getYourProperty != 'bla-bla-bla') {
$fieldOptions[] = 'constraints' => [
new Assert\NotBlank([
'message' => 'This is unforgivable! Fill the field with "bla-bla-bla" right now!',
]),
],
}
$builder->add('myField', TextType::class, $fieldOptions);
2) Other way - is to make your custom validation callback in your Entity and play with direct asserts there. It's possible, I think.
3) But the optimal way, from my point of view - is to use several asserts with validation groups. You need to specify Assert\isMinor(groups={"myCustomGroup"}) on birthday field. And then, in your form:
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'validation_groups' => function (FormInterface $form) {
$yourEntity = $form->getData();
if ($yourEntity->role !== 3) {
return ['Default', 'myCustomGroup'];
}
return ['Default'];
},
Hope this'll be helpful for you.

Resources