diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 58caff2209f37..00a686580d01f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,8 +14,9 @@ This will help reviewers and should be a good start for the documentation. Additionally (see https://symfony.com/releases): - Always add tests and ensure they pass. - Bug fixes must be submitted against the lowest maintained branch where they apply - (lowest branches are regularly merged to upper ones so they get the fixes too.) + (lowest branches are regularly merged to upper ones so they get the fixes too). - Features and deprecations must be submitted against the latest branch. + - For new features, provide some code snippets to help understand usage. - Changelog entry should follow https://symfony.com/doc/current/contributing/code/conventions.html#writing-a-changelog-entry - Never break backward compatibility (see https://symfony.com/bc). --> diff --git a/.github/expected-missing-return-types.diff b/.github/expected-missing-return-types.diff index fe0381a0b0a7e..cf4c237e3070c 100644 --- a/.github/expected-missing-return-types.diff +++ b/.github/expected-missing-return-types.diff @@ -899,11 +899,11 @@ index f38069e471..0966eb3e89 100644 + public function decode(string $data, string $format, array $context = []): mixed; /** -@@ -45,4 +45,4 @@ interface DecoderInterface +@@ -44,4 +44,4 @@ interface DecoderInterface * @return bool */ -- public function supportsDecoding(string $format /* , array $context = [] */); -+ public function supportsDecoding(string $format /* , array $context = [] */): bool; +- public function supportsDecoding(string $format); ++ public function supportsDecoding(string $format): bool; } diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php index 44ba45f581..3398115497 100644 diff --git a/CHANGELOG-6.0.md b/CHANGELOG-6.0.md index 4416525001ff2..54219602ca6bb 100644 --- a/CHANGELOG-6.0.md +++ b/CHANGELOG-6.0.md @@ -7,6 +7,44 @@ in 6.0 minor versions. To get the diff for a specific change, go to https://github.com/symfony/symfony/commit/XXX where XXX is the change hash To get the diff between two versions, go to https://github.com/symfony/symfony/compare/v6.0.0...v6.0.1 +* 6.0.11 (2022-07-29) + + * bug #47069 [Security] Allow redirect after login to absolute URLs (Tim Ward) + * bug #47073 [HttpKernel] Fix non-scalar check in surrogate fragment renderer (aschempp) + * bug #47003 [Cache] Ensured that redis adapter can use multiple redis sentinel hosts (warslett) + * bug #43329 [Serializer] Respect default context in DateTimeNormalizer::denormalize (hultberg) + * bug #47070 [Messenger] Fix function name in TriggerSql on postgresql bridge to support table name with schema (zimny9932) + * bug #47086 Workaround disabled "var_dump" (nicolas-grekas) + * bug #40828 [BrowserKit] Merge fields and files recursively if they are multidimensional array (januszmk) + * bug #47010 [String] Fix `width` method in `AbstractUnicodeString` (TBoileau) + * bug #47048 [Serializer] Fix XmlEncoder encoding attribute false (alamirault) + * bug #46957 [HttpFoundation] Fix `\Stringable` support in `InputBag::get()` (chalasr) + * bug #47022 [Console] get full command path for command in search path (remicollet) + * bug #47000 [ErrorHandler] Fix return type patching for list and class-string pseudo types (derrabus) + * bug #43998 [HttpKernel] [HttpCache] Don't throw on 304 Not Modified (aleho) + * bug #46792 [Bridge] Corrects bug in test listener trait (magikid) + * bug #46985 [DoctrineBridge] Avoid calling `AbstractPlatform::hasNativeGuidType()` (derrabus) + * bug #46958 [Serializer] Ignore getter with required parameters (Fix #46592) (astepin) + * bug #46981 [Mime]  quote address names if they contain parentheses (xabbuh) + * bug #46960 [FrameworkBundle] Fail gracefully when forms use disabled CSRF (HeahDude) + * bug #46973 [DependencyInjection] Fail gracefully when attempting to autowire composite types (derrabus) + * bug #45884 [Serializer] Fix inconsistent behaviour of nullable objects in key/value arrays (phramz) + * bug #46963 [Mime] Fix inline parts when added via attachPart() (fabpot) + * bug #46968 [PropertyInfo] Make sure nested composite types do not crash ReflectionExtractor (derrabus) + * bug #46931 Flush backend output buffer after closing. (bradjones1) + * bug #46947 [Serializer] Prevent that bad Ignore method annotations lead to incorrect results (astepin) + * bug #46948 [Validator] : Fix "PHP Warning: Undefined array key 1" in NotCompromisedPasswordValidator (KevinVanSonsbeek) + * bug #46905 [BrowserKit] fix sending request to paths containing multiple slashes (xabbuh) + * bug #46244 [Validator] Fix traverse option on Valid constraint when used as Attribute (tobias-93) + * bug #42033 [HttpFoundation] Fix deleteFileAfterSend on client abortion (nerg4l) + * bug #46941 [Messenger] Fix calls to deprecated DBAL methods (derrabus) + * bug #46863 [Mime] Fix invalid DKIM signature with multiple parts (BrokenSourceCode) + * bug #46808 [HttpFoundation] Fix TypeError on null `$_SESSION` in `NativeSessionStorage::save()` (chalasr) + * bug #46811 [DoctrineBridge] Fix comment for type on Query::setValue (middlewares) (l-vo) + * bug #46790 [HttpFoundation] Prevent PHP Warning: Session ID is too long or contains illegal characters (BrokenSourceCode) + * bug #46800 Spaces in system temp folder path cause deprecation errors in php 8 (demeritcowboy) + * bug #46797 [Messenger] Ceil waiting time when multiplier is a float on retry (WissameMekhilef) + * 6.0.10 (2022-06-26) * bug #46779 [String] Add an invariable word in french (lemonlab) diff --git a/CHANGELOG-6.1.md b/CHANGELOG-6.1.md index c8755b2eab6fb..0b83806b53047 100644 --- a/CHANGELOG-6.1.md +++ b/CHANGELOG-6.1.md @@ -7,6 +7,45 @@ in 6.1 minor versions. To get the diff for a specific change, go to https://github.com/symfony/symfony/commit/XXX where XXX is the change hash To get the diff between two versions, go to https://github.com/symfony/symfony/compare/v6.1.0...v6.1.1 +* 6.1.4 (2022-08-26) + + * bug #47372 [Console] Fix OutputFormatterStyleStack::getCurrent return type (alamirault) + * bug #47391 [LokaliseBridge] Fix push command --delete-missing options when there are no missing messages (rwionczek) + * bug #47368 [Security] Count remember me cookie parts before accessing the second (MatTheCat) + * bug #47358 Fix broken request stack state if throwable is thrown. (Warxcell) + * bug #47304 [Serializer] Fix caching context-aware encoders/decoders in ChainEncoder/ChainDecoder (Guite) + * bug #47150 [Serializer] Revert deprecation of `ContextAwareEncoderInterface` and `ContextAwareDecoderInterface` (nicolas-grekas) + * bug #47329 Email image parts: regex for single closing quote (rr-it) + * bug #47335 [Security] [AbstractToken] getUserIdentifier() must return a string (mpiot) + * bug #47283 [HttpFoundation] Prevent accepted rate limits with no remaining token to be preferred over denied ones (MatTheCat) + * bug #47128 [Serializer] Throw InvalidArgumentException if the data needed in the constructor doesn't belong to a backedEnum (allison guilhem) + * bug #47273 [HttpFoundation] Do not send Set-Cookie header twice for deleted session cookie (X-Coder264) + * bug #47255 [Serializer] Fix get accessor regex in AnnotationLoader (jsor) + * bug #47238 [HttpKernel] Fix passing `null` to `\trim()` method in LoggerDataCollector (SVillette) + * bug #47216 [Translation] Crowdin provider throw Exception when status is 50x (alamirault) + * bug #47209 Always attempt to listen for notifications (goetas) + * bug #47211 [Validator] validate nested constraints only if they are in the same group (xabbuh) + * bug #47218 [Console] fix dispatch signal event check for compatibility with the contract interface (xabbuh) + * bug #47200 [Form] ignore missing keys when mapping DateTime objects to uninitialized arrays (xabbuh) + * bug #47189 [Validator] Add additional hint when `egulias/email-validator` needs to be installed (mpdude) + * bug #47195 [FrameworkBundle] fix writes to static $kernel property (xabbuh) + * bug #47185 [String] Fix snake conversion (simPod) + * bug #47175 [DowCrawler] Fix locale-sensitivity of whitespace normalization (nicolas-grekas) + * bug #47172 [Translation] Fix reading intl-icu domains with LocoProvider (nicolas-grekas) + * bug #47171 [TwigBridge] suggest to install the Twig bundle when the required component is already installed (xabbuh) + * bug #47169 [Serializer] Fix throwing right exception in ArrayDenormalizer with invalid type (norkunas) + * bug #47162 [Mailer] Fix error message in case of an SMTP error (fabpot) + * bug #47161 [Mailer] Fix logic (fabpot) + * bug #47157 [Messenger] Fix Doctrine transport on MySQL (nicolas-grekas) + * bug #47155 [Filesystem] Remove needless `mb_*` calls (HellFirePvP) + * bug #46190 [Translation] Fix translator overlapse (Xavier RENAUDIN) + * bug #47142 [Mailer] Fix error message in case of an STMP error (fabpot) + * bug #45333 [Console] Fix ConsoleEvents::SIGNAL subscriber dispatch (GwendolenLynch) + * bug #47145 [HttpClient] Fix shared connections not being freed on PHP < 8 (nicolas-grekas) + * bug #47143 [HttpClient] Fix memory leak when using StreamWrapper (nicolas-grekas) + * bug #47130 [HttpFoundation] Fix invalid ID not regenerated with native PHP file sessions (BrokenSourceCode) + * bug #47129 [FrameworkBundle] remove the ChatterInterface alias when the chatter service is removed (xabbuh) + * 6.1.3 (2022-07-29) * bug #47069 [Security] Allow redirect after login to absolute URLs (Tim Ward) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index ec4fddccc86be..99d8c121d9fce 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -21,8 +21,8 @@ The Symfony Connect username in parenthesis allows to get more information - Jordi Boggiano (seldaek) - Roland Franssen (ro0) - Victor Berchet (victor) - - Tobias Nyholm (tobias) - Yonel Ceruto (yonelceruto) + - Tobias Nyholm (tobias) - Oskar Stark (oskarstark) - Ryan Weaver (weaverryan) - Javier Eguiluz (javier.eguiluz) @@ -34,35 +34,35 @@ The Symfony Connect username in parenthesis allows to get more information - Samuel ROZE (sroze) - Pascal Borreli (pborreli) - Romain Neutron - - Joseph Bielawski (stloyd) - Jules Pietri (heah) + - Joseph Bielawski (stloyd) - Drak (drak) - Abdellatif Ait boudad (aitboudad) - Lukas Kahwe Smith (lsmith) - Jan Schädlich (jschaedl) - Martin Hasoň (hason) - - Jeremy Mikola (jmikola) - Jérôme Tamarelle (gromnan) + - Jeremy Mikola (jmikola) - Jean-François Simon (jfsimon) - Benjamin Eberlei (beberlei) - Kevin Bond (kbond) - Igor Wiedler - Valentin Udaltsov (vudaltsov) - Vasilij Duško (staff) + - HypeMC (hypemc) - Matthias Pigulla (mpdude) - Laurent VOULLEMIER (lvo) - Pierre du Plessis (pierredup) - - Grégoire Paris (greg0ire) - - Jonathan Wage (jwage) - Antoine Makdessi (amakdessi) - - HypeMC (hypemc) + - Grégoire Paris (greg0ire) - Gabriel Ostrolucký (gadelat) + - Jonathan Wage (jwage) - David Maicher (dmaicher) - Titouan Galopin (tgalopin) - Alexandre Salomé (alexandresalome) - William DURAND - - ornicar - Alexander Schranz (alexander-schranz) + - ornicar - Dany Maillard (maidmaid) - Eriksen Costa - Diego Saint Esteben (dosten) @@ -70,26 +70,26 @@ The Symfony Connect username in parenthesis allows to get more information - Alexander Mols (asm89) - Gábor Egyed (1ed) - Francis Besset (francisbesset) + - Alexandre Daubois (alexandre-daubois) - Vasilij Dusko | CREATION - - Bulat Shakirzyanov (avalanche123) - Mathieu Santostefano (welcomattic) + - Bulat Shakirzyanov (avalanche123) - Iltar van der Berg - - Alexandre Daubois (alexandre-daubois) - Miha Vrhovnik (mvrhov) - Saša Stamenković (umpirsky) - Mathieu Piot (mpiot) - - Guilhem N (guilhemn) - Alex Pott + - Guilhem N (guilhemn) - Vladimir Reznichenko (kalessil) - Sarah Khalil (saro0h) - Konstantin Kudryashov (everzet) + - Vincent Langlet (deviling) - Bilal Amarni (bamarni) + - Tomas Norkūnas (norkunas) - Eriksen Costa - Florin Patan (florinpatan) - Peter Rehm (rpet) - - Tomas Norkūnas (norkunas) - Henrik Bjørnskov (henrikbjorn) - - Vincent Langlet (deviling) - Konstantin Myakshin (koc) - Andrej Hudec (pulzarraider) - Julien Falque (julienfalque) @@ -104,8 +104,8 @@ The Symfony Connect username in parenthesis allows to get more information - Fran Moreno (franmomu) - Jáchym Toušek (enumag) - Malte Schlüter (maltemaltesich) - - Vasilij Dusko - Mathias Arlaud (mtarld) + - Vasilij Dusko - Denis (yethee) - Arnout Boks (aboks) - Charles Sarrazin (csarrazi) @@ -125,14 +125,15 @@ The Symfony Connect username in parenthesis allows to get more information - Toni Uebernickel (havvg) - Bart van den Burg (burgov) - Jordan Alliot (jalliot) + - Smaine Milianni (ismail1432) - John Wards (johnwards) - Dariusz Ruminski - Lars Strojny (lstrojny) - - Smaine Milianni (ismail1432) + - Yanick Witschi (toflar) - Antoine Hérault (herzult) - Konstantin.Myakshin + - Rokas Mikalkėnas (rokasm) - Arman Hosseini (arman) - - Yanick Witschi (toflar) - Arnaud Le Blanc (arnaud-lb) - Maxime STEINHAUSSER - Peter Kokot (maastermedia) @@ -145,22 +146,23 @@ The Symfony Connect username in parenthesis allows to get more information - YaFou - Gary PEGEOT (gary-p) - Chris Wilkinson (thewilkybarkid) - - Rokas Mikalkėnas (rokasm) - Brice BERNARD (brikou) - Roman Martinuk (a2a4) - Gregor Harlan (gharlan) + - Antoine Lamirault - Baptiste Clavié (talus) - Adrien Brault (adrienbrault) - Michal Piotrowski - marc.weistroff - lenar + - Jesse Rushlow (geeshoe) - Théo FIDRY - jeremyFreeAgent (jeremyfreeagent) - Włodzimierz Gajda (gajdaw) - Christian Scheb - Guillaume (guill) - Mathieu Lechat (mat_the_cat) - - Jesse Rushlow (geeshoe) + - Tugdual Saunier (tucksaun) - Jacob Dreesen (jdreesen) - Joel Wurtz (brouznouf) - Michael Babker (mbabker) @@ -169,7 +171,6 @@ The Symfony Connect username in parenthesis allows to get more information - zairig imad (zairigimad) - Colin Frei - Javier Spagnoletti (phansys) - - Tugdual Saunier (tucksaun) - excelwebzone - Jérôme Parmentier (lctrs) - HeahDude @@ -209,6 +210,7 @@ The Symfony Connect username in parenthesis allows to get more information - Timo Bakx (timobakx) - Juti Noppornpitak (shiroyuki) - Joe Bennett (kralos) + - Hugo Alliaume (kocal) - Anthony MARTIN - Colin O'Dell (colinodell) - Sebastian Hörl (blogsh) @@ -223,16 +225,16 @@ The Symfony Connect username in parenthesis allows to get more information - Chi-teck - Guilliam Xavier - Nate Wiebe (natewiebe13) - - Hugo Alliaume (kocal) - Michael Voříšek - SpacePossum - - Antoine Lamirault + - Andreas Schempp (aschempp) - Pablo Godel (pgodel) - Romaric Drigon (romaricdrigon) - Andréia Bohner (andreia) - Jannik Zschiesche - Rafael Dohms (rdohms) - George Mponos (gmponos) + - Fritz Michael Gschwantner (fritzmg) - Aleksandar Jakovljevic (ajakov) - jwdeitch - Jurica Vlahoviček (vjurica) @@ -243,7 +245,8 @@ The Symfony Connect username in parenthesis allows to get more information - Farhad Safarov (safarov) - Jérémy Derussé - Nicolas Philippe (nikophil) - - Andreas Schempp (aschempp) + - Hubert Lenoir (hubert_lenoir) + - Florent Mata (fmata) - mcfedr (mcfedr) - Denis Brumann (dbrumann) - Maciej Malarz (malarzm) @@ -260,6 +263,7 @@ The Symfony Connect username in parenthesis allows to get more information - Dmitrii Poddubnyi (karser) - soyuka - Rouven Weßling (realityking) + - BoShurik - Zmey - Clemens Tolboom - Oleg Voronkovich @@ -269,6 +273,7 @@ The Symfony Connect username in parenthesis allows to get more information - Ben Hakim - Sylvain Fabre (sylfabre) - Filippo Tessarotto (slamdunk) + - Tom Van Looy (tvlooy) - 77web - Bohan Yang (brentybh) - Bastien Jaillot (bastnic) @@ -279,7 +284,6 @@ The Symfony Connect username in parenthesis allows to get more information - Dawid Nowak - Amal Raghav (kertz) - Jonathan Ingram - - Fritz Michael Gschwantner (fritzmg) - Artur Kotyrba - Tyson Andre - Thomas Landauer (thomas-landauer) @@ -291,11 +295,9 @@ The Symfony Connect username in parenthesis allows to get more information - Sebastien Morel (plopix) - Sergey (upyx) - Yoann RENARD (yrenard) - - BoShurik + - Thomas Lallement (raziel057) - Timothée Barray (tyx) - James Halsall (jaitsu) - - Hubert Lenoir (hubert_lenoir) - - Florent Mata (fmata) - Mikael Pajunen - Warnar Boekkooi (boekkooi) - Marco Petersen (ocrampete16) @@ -303,13 +305,13 @@ The Symfony Connect username in parenthesis allows to get more information - Dmitrii Chekaliuk (lazyhammer) - Clément JOBEILI (dator) - Vilius Grigaliūnas - - Tom Van Looy (tvlooy) - Marek Štípek (maryo) - Patrick Landolt (scube) - François Pluchino (francoispluchino) - Daniel Espendiller - Arnaud PETITPAS (apetitpa) - Dorian Villet (gnutix) + - Wojciech Kania - Alexey Kopytko (sanmai) - Sergey Linnik (linniksa) - Richard Miller @@ -330,7 +332,6 @@ The Symfony Connect username in parenthesis allows to get more information - Julien Pauli - Islam Israfilov (islam93) - Oleg Andreyev (oleg.andreyev) - - Thomas Lallement (raziel057) - Daniel Gorgan - Hendrik Luup (hluup) - Martin Herndl (herndlm) @@ -366,6 +367,7 @@ The Symfony Connect username in parenthesis allows to get more information - Dominique Bongiraud - Hidde Wieringa (hiddewie) - Christopher Davis (chrisguitarguy) + - Lukáš Holeczy (holicz) - Florian Lonqueu-Brochard (florianlb) - Leszek Prabucki (l3l0) - Emanuele Panzeri (thepanz) @@ -391,7 +393,6 @@ The Symfony Connect username in parenthesis allows to get more information - Pascal Montoya - Julien Brochet - Michaël Perrin (michael.perrin) - - Wojciech Kania - Tristan Darricau (tristandsensio) - Fabien S (bafs) - Victor Bocharsky (bocharsky_bw) @@ -425,6 +426,7 @@ The Symfony Connect username in parenthesis allows to get more information - Iker Ibarguren (ikerib) - Manuel Reinhard (sprain) - Johann Pardanaud + - Alexis Lefebvre - Indra Gunawan (indragunawan) - Tim Goudriaan (codedmonkey) - Harm van Tilborg (hvt) @@ -449,7 +451,6 @@ The Symfony Connect username in parenthesis allows to get more information - Xavier Perez - Arjen Brouwer (arjenjb) - Tavo Nieves J (tavoniievez) - - Lukáš Holeczy (holicz) - Arjen van der Meijden - Patrick McDougle (patrick-mcdougle) - Jerzy (jlekowski) @@ -467,14 +468,17 @@ The Symfony Connect username in parenthesis allows to get more information - David Badura (davidbadura) - Uwe Jäger (uwej711) - Eugene Leonovich (rybakit) + - Damien Alexandre (damienalexandre) - Joseph Rouff (rouffj) - Félix Labrecque (woodspire) - GordonsLondon - Roman Anasal - Jan Sorgalla (jsor) - Piotr Kugla (piku235) + - Quynh Xuan Nguyen (seriquynh) - Ray - Philipp Cordes (corphi) + - Simon Podlipsky (simpod) - Chekote - bhavin (bhavin4u) - Pavel Popov (metaer) @@ -494,6 +498,7 @@ The Symfony Connect username in parenthesis allows to get more information - Frank de Jonge - Chris Tanaskoski - julien57 + - Loïc Frémont (loic425) - Ben Ramsey (ramsey) - Matthieu Auger (matthieuauger) - Josip Kruslin (jkruslin) @@ -505,12 +510,12 @@ The Symfony Connect username in parenthesis allows to get more information - Beau Simensen (simensen) - Robert Kiss (kepten) - Zan Baldwin (zanbaldwin) - - Alexis Lefebvre - Antonio J. García Lagar (ajgarlag) - Alexandre Quercia (alquerci) - Marcos Sánchez - Jérôme Tanghe (deuchnord) - Kim Hemsø Rasmussen (kimhemsoe) + - Maximilian Reichel (phramz) - Dane Powell - jaugustin - Dmytro Borysovskyi (dmytr0) @@ -530,7 +535,6 @@ The Symfony Connect username in parenthesis allows to get more information - Martin Kirilov (wucdbm) - Chris Smith (cs278) - Florian Klein (docteurklein) - - Damien Alexandre (damienalexandre) - Bilge - Rhodri Pugh (rodnaph) - Manuel Kiessling (manuelkiessling) @@ -544,15 +548,14 @@ The Symfony Connect username in parenthesis allows to get more information - Andrew Moore (finewolf) - Bertrand Zuchuat (garfield-fr) - Marc Morera (mmoreram) - - Quynh Xuan Nguyen (seriquynh) - Gabor Toth (tgabi333) - realmfoo - Thomas Tourlourat (armetiz) - Andrey Esaulov (andremaha) - - Simon Podlipsky (simpod) - Grégoire Passault (gregwar) - Jerzy Zawadzki (jzawadzki) - Ismael Ambrosi (iambrosi) + - Yannick Ihmels (ihmels) - Saif Eddin G - Emmanuel BORGES (eborges78) - Aurelijus Valeiša (aurelijus) @@ -566,6 +569,7 @@ The Symfony Connect username in parenthesis allows to get more information - Adrian Rudnik (kreischweide) - Pavel Batanov (scaytrase) - Francesc Rosàs (frosas) + - Andrii Dembitskyi - Bongiraud Dominique - janschoenherr - Marko Kaznovac (kaznovac) @@ -577,7 +581,6 @@ The Symfony Connect username in parenthesis allows to get more information - James Hemery - Egor Taranov - Philippe Segatori - - Loïc Frémont (loic425) - Adrian Nguyen (vuphuong87) - benjaminmal - Thierry T (lepiaf) @@ -595,6 +598,7 @@ The Symfony Connect username in parenthesis allows to get more information - Erkhembayar Gantulga (erheme318) - Fractal Zombie - Gunnstein Lye (glye) + - Thomas Talbot (ioni) - Kévin THERAGE (kevin_therage) - Noémi Salaün (noemi-salaun) - Michel Hunziker @@ -638,6 +642,7 @@ The Symfony Connect username in parenthesis allows to get more information - rtek - Inal DJAFAR (inalgnu) - Christian Gärtner (dagardner) + - Artem Stepin (astepin) - Adrien Jourdier (eclairia) - Ivan Grigoriev (greedyivan) - Tomasz Kowalczyk (thunderer) @@ -652,6 +657,7 @@ The Symfony Connect username in parenthesis allows to get more information - Thomas P - Kristijan Kanalaš (kristijan_kanalas_infostud) - Felix Labrecque + - mondrake (mondrake) - Yaroslav Kiliba - “Filip - Simon Watiau (simonwatiau) @@ -679,6 +685,7 @@ The Symfony Connect username in parenthesis allows to get more information - Dalibor Karlović - Randy Geraads - Sanpi (sanpi) + - James Gilliland (neclimdul) - Eduardo Gulias (egulias) - Andreas Leathley (iquito) - Nathanael Noblet (gnat) @@ -724,7 +731,6 @@ The Symfony Connect username in parenthesis allows to get more information - Tamas Szijarto - stlrnz - Adrien Wilmet (adrienfr) - - Yannick Ihmels (ihmels) - Alex Bacart - hugovms - Michele Locati @@ -763,7 +769,6 @@ The Symfony Connect username in parenthesis allows to get more information - Shakhobiddin - Kai - Lee Rowlands - - Maximilian Reichel (phramz) - siganushka (siganushka) - Alain Hippolyte (aloneh) - Karoly Negyesi (chx) @@ -793,7 +798,6 @@ The Symfony Connect username in parenthesis allows to get more information - Vadim Kharitonov (vadim) - Oscar Cubo Medina (ocubom) - Karel Souffriau - - Andrii Dembitskyi - Christophe L. (christophelau) - Daniël Brekelmans (dbrekelmans) - Simon Heimberg (simon_heimberg) @@ -814,12 +818,14 @@ The Symfony Connect username in parenthesis allows to get more information - Thiago Cordeiro (thiagocordeiro) - Julien Maulny - Brian King + - Paul Oms - Steffen Roßkamp - Alexandru Furculita (afurculita) - Michel Salib (michelsalib) - Valentin Jonovs - geoffrey - Bastien DURAND (deamon) + - Benoit Galati (benoitgalati) - Jon Gotlin (jongotlin) - Jeanmonod David (jeanmonod) - Daniel González (daniel.gonzalez) @@ -833,6 +839,7 @@ The Symfony Connect username in parenthesis allows to get more information - Markus Bachmann (baachi) - Roger Guasch (rogerguasch) - Luis Tacón (lutacon) + - Alex Hofbauer (alexhofbauer) - Andrii Popov (andrii-popov) - lancergr - Ivan Nikolaev (destillat) @@ -842,6 +849,7 @@ The Symfony Connect username in parenthesis allows to get more information - ampaze - Arturs Vonda - Xavier Briand (xavierbriand) + - Daniel Badura - Asmir Mustafic (goetas) - vagrant - Asier Illarramendi (doup) @@ -851,7 +859,6 @@ The Symfony Connect username in parenthesis allows to get more information - Vlad Gregurco (vgregurco) - Boris Vujicic (boris.vujicic) - Chris Sedlmayr (catchamonkey) - - mondrake (mondrake) - Kamil Kokot (pamil) - Seb Koelen - Christoph Mewes (xrstf) @@ -893,13 +900,14 @@ The Symfony Connect username in parenthesis allows to get more information - Pablo Díez (pablodip) - Damien Fa - Kevin McBride + - BrokenSourceCode - Sergio Santoro - - James Gilliland (neclimdul) - Philipp Rieber (bicpi) - Dennis Væversted (srnzitcom) - Manuel de Ruiter (manuel) - nikos.sotiropoulos - Eduardo Oliveira (entering) + - Jonathan Johnson (jrjohnson) - Eugene Wissner - Ricardo Oliveira (ricardolotr) - Roy Van Ginneken (rvanginneken) @@ -907,6 +915,7 @@ The Symfony Connect username in parenthesis allows to get more information - Barry vd. Heuvel (barryvdh) - Jon Dufresne - Chad Sikorra (chadsikorra) + - Mathias Brodala (mbrodala) - Evan S Kaufman (evanskaufman) - Jonathan Sui Lioung Lee Slew (jlslew) - mcben @@ -914,6 +923,7 @@ The Symfony Connect username in parenthesis allows to get more information - Filip Procházka (fprochazka) - stoccc - Markus Lanthaler (lanthaler) + - Gigino Chianese (sajito) - Xav` (xavismeh) - Remi Collet - Mathieu Rochette (mathroc) @@ -957,6 +967,7 @@ The Symfony Connect username in parenthesis allows to get more information - Rodrigo Borrego Bernabé (rodrigobb) - John Bafford (jbafford) - Emanuele Iannone + - Gasan Guseynov (gassan) - Ondrej Machulda (ondram) - Denis Gorbachev (starfall) - Martin Morávek (keeo) @@ -990,12 +1001,12 @@ The Symfony Connect username in parenthesis allows to get more information - Markus S. (staabm) - Geoffrey Tran (geoff) - Elan Ruusamäe (glen) + - Brad Jones - Nicolas de Marqué (nicola) - a.dmitryuk - Jannik Zschiesche - Jan Ole Behrens (deegital) - Mantas Var (mvar) - - Paul Oms - Yann LUCAS (drixs6o9) - Sebastian Krebs - Htun Htun Htet (ryanhhh91) @@ -1036,7 +1047,9 @@ The Symfony Connect username in parenthesis allows to get more information - Iliya Miroslavov Iliev (i.miroslavov) - Safonov Nikita (ns3777k) - Simon DELICATA + - Thibault Buathier (gwemox) - vitaliytv + - Arnaud Frézet - Nicolas Martin (cocorambo) - luffy1727 - LHommet Nicolas (nicolaslh) @@ -1044,7 +1057,6 @@ The Symfony Connect username in parenthesis allows to get more information - Amirreza Shafaat (amirrezashafaat) - Laurent Clouet - Adoni Pavlakis (adoni) - - Alex Hofbauer (alexhofbauer) - Maarten Nusteling (nusje2000) - Ahmed EBEN HASSINE (famas23) - Eduard Bulava (nonanerz) @@ -1096,6 +1108,7 @@ The Symfony Connect username in parenthesis allows to get more information - Jacek Wilczyński (jacekwilczynski) - Hany el-Kerdany - Wang Jingyu + - Benjamin Georgeault (wedgesama) - Åsmund Garfors - Maxime Douailin - Jean Pasdeloup @@ -1105,6 +1118,7 @@ The Symfony Connect username in parenthesis allows to get more information - tamar peled - Reinier Kip - Geoffrey Brier (geoffrey-brier) + - Sofien Naas - Christophe Meneses (c77men) - Vladimir Tsykun - Andrei O @@ -1167,6 +1181,7 @@ The Symfony Connect username in parenthesis allows to get more information - Israel J. Carberry - Julius Kiekbusch - Miquel Rodríguez Telep (mrtorrent) + - Tamás Nagy (t-bond) - Sergey Kolodyazhnyy (skolodyazhnyy) - umpirski - Benjamin @@ -1174,6 +1189,7 @@ The Symfony Connect username in parenthesis allows to get more information - Chris Heng (gigablah) - Oleksii Svitiashchuk - Tristan Bessoussa (sf_tristanb) + - FORT Pierre-Louis (plfort) - Richard Bradley - Nathanaël Martel (nathanaelmartel) - Nicolas Jourdan (nicolasjc) @@ -1189,7 +1205,6 @@ The Symfony Connect username in parenthesis allows to get more information - Andreas Erhard (andaris) - Evgeny Efimov (edefimov) - John VanDeWeghe - - Daniel Badura - Oleg Mifle - gnito-org - Michael Devery (mickadoo) @@ -1198,6 +1213,7 @@ The Symfony Connect username in parenthesis allows to get more information - Markkus Millend - Clément - Jorrit Schippers (jorrit) + - Aurimas Niekis (aurimasniekis) - maxime.perrimond - rvoisin - cthulhu @@ -1286,6 +1302,7 @@ The Symfony Connect username in parenthesis allows to get more information - develop - flip111 - Artem Oliinyk (artemoliynyk) + - Marvin Feldmann (breyndotechse) - fruty - VJ - RJ Garcia @@ -1298,9 +1315,9 @@ The Symfony Connect username in parenthesis allows to get more information - Ondrej Exner - Mark Sonnabaum - Adiel Cristo (arcristo) - - Artem Stepin (astepin) - Fabian Kropfhamer (fabiank) - Junaid Farooq (junaidfarooq) + - Chris Jones (magikid) - Massimiliano Braglia (massimilianobraglia) - Swen van Zanten (swenvanzanten) - Frankie Wittevrongel @@ -1346,6 +1363,7 @@ The Symfony Connect username in parenthesis allows to get more information - Tayfun Aydin - Arne Groskurth - Ilya Chekalsky + - zenas1210 - Ostrzyciel - Julien DIDIER (juliendidier) - Ilia Sergunin (maranqz) @@ -1384,6 +1402,7 @@ The Symfony Connect username in parenthesis allows to get more information - BrokenSourceCode - Fabian Haase - Nikita Popov (nikic) + - Robert Fischer (sandoba) - Tarjei Huse (tarjei) - Besnik Br - Michael Olšavský @@ -1475,6 +1494,7 @@ The Symfony Connect username in parenthesis allows to get more information - Martin (meckhardt) - Radosław Kowalewski - JustDylan23 + - buffcode - Juraj Surman - Victor - Andreas Allacher @@ -1493,7 +1513,6 @@ The Symfony Connect username in parenthesis allows to get more information - John Stevenson - everyx - Stanislav Gamayunov (happyproff) - - Jonathan Johnson (jrjohnson) - Alexander McCullagh (mccullagh) - Paul L McNeely (mcneely) - Mike Meier (mykon) @@ -1504,6 +1523,7 @@ The Symfony Connect username in parenthesis allows to get more information - Francis Turmel (fturmel) - Nikita Nefedov (nikita2206) - Bernat Llibre + - Daniel Burger - cgonzalez - Ben - Joni Halme @@ -1534,6 +1554,7 @@ The Symfony Connect username in parenthesis allows to get more information - Zhuravlev Alexander (scif) - Stefano Degenkamp (steef) - James Michael DuPont + - kor3k kor3k (kor3k) - Eric Schildkamp - agaktr - Vincent CHALAMON @@ -1725,7 +1746,6 @@ The Symfony Connect username in parenthesis allows to get more information - robmro27 - Vallel Blanco - Bastien Clément - - Thomas Talbot - Benjamin Franzke - Pavinthan - Sylvain METAYER @@ -1784,7 +1804,6 @@ The Symfony Connect username in parenthesis allows to get more information - Evgeniy Koval - Claas Augner - Balazs Csaba - - Benoit Galati (benoitgalati) - Bill Hance (billhance) - Douglas Reith (douglas_reith) - Harry Walter (haswalt) @@ -1817,7 +1836,6 @@ The Symfony Connect username in parenthesis allows to get more information - Michel Bardelmeijer - Ikko Ashimine - Erwin Dirks - - Brad Jones - Markus Ramšak - den - George Dietrich @@ -1889,7 +1907,6 @@ The Symfony Connect username in parenthesis allows to get more information - Jordane VASPARD (elementaire) - Erwan Nader (ernadoo) - Faizan Akram Dar (faizanakram) - - Gasan Guseynov (gassan) - Greg Szczotka (greg606) - Ian Littman (iansltx) - Nathan DIdier (icz) @@ -1901,7 +1918,6 @@ The Symfony Connect username in parenthesis allows to get more information - Florent Viel (luxifer) - Maks 3w (maks3w) - Mamikon Arakelyan (mamikon) - - Mathias Brodala (mbrodala) - Michiel Boeckaert (milio) - Mike Milano (mmilano) - Guillaume Lajarige (molkobain) @@ -1945,21 +1961,23 @@ The Symfony Connect username in parenthesis allows to get more information - Antoine Bluchet (soyuka) - Patrick Kaufmann - Anton Dyshkant + - Kirill Nesmeyanov (serafim) - Reece Fowell (reecefowell) - Guillaume Gammelin - Valérian Galliat - d-ph - Renan Taranto (renan-taranto) - - Thomas Talbot - Rikijs Murgs - Uladzimir Tsykun - Amaury Leroux de Lens (amo__) - Christian Jul Jensen + - Franck RANAIVO-HARISOA (franckranaivo) - Alexandre GESLIN - The Whole Life to Learn - Mikkel Paulson - ergiegonzaga - Liverbool (liverbool) + - Julien Boudry - Dalibor Karlović - Sam Malone - Ha Phan (haphan) @@ -1976,15 +1994,18 @@ The Symfony Connect username in parenthesis allows to get more information - Ganesh Chandrasekaran (gxc4795) - Sander Marechal - Franz Wilding (killerpoke) + - Ferenczi Krisztian (fchris82) - Oleg Golovakhin (doc_tr) - Icode4Food (icode4food) - Radosław Benkel + - Bert ter Heide (bertterheide) - Kevin Nadin (kevinjhappy) - jean pasqualini (darkilliant) - Ross Motley (rossmotley) - ttomor - Mei Gwilym (meigwilym) - Michael H. Arieli + - Jitendra Adhikari (adhocore) - Tom Panier (neemzy) - Fred Cox - Luciano Mammino (loige) @@ -1994,8 +2015,10 @@ The Symfony Connect username in parenthesis allows to get more information - Anne-Sophie Bachelard - Marvin Butkereit - Ben Oman + - Jack Worman (jworman) - Chris de Kok - Andreas Kleemann (andesk) + - Hubert Moreau (hmoreau) - Manuele Menozzi - Anton Babenko (antonbabenko) - Irmantas Šiupšinskas (irmantas) @@ -2033,6 +2056,7 @@ The Symfony Connect username in parenthesis allows to get more information - tamirvs - gauss - julien.galenski + - Florian Guimier - Christian Neff (secondtruth) - Chris Tiearney - Oliver Hoff @@ -2043,6 +2067,7 @@ The Symfony Connect username in parenthesis allows to get more information - Goran Juric - Laurent G. (laurentg) - Nicolas Macherey + - Bhujagendra Ishaya - Guido Donnari - Mert Simsek (mrtsmsk0) - Lin Clark @@ -2070,12 +2095,12 @@ The Symfony Connect username in parenthesis allows to get more information - Paul Mitchum (paul-m) - Angel Koilov (po_taka) - Dan Finnie - - Sofien Naas - Ken Marfilla (marfillaster) - Max Grigorian (maxakawizard) - benatespina (benatespina) - Denis Kop - Jean-Guilhem Rouel (jean-gui) + - Ivan Yivoff - EdgarPE - jfcixmedia - Dominic Tubach @@ -2086,11 +2111,11 @@ The Symfony Connect username in parenthesis allows to get more information - Serge (nfx) - Mikkel Paulson - Michał Strzelecki - - Aurimas Niekis (aurimasniekis) - Hugo Fonseca (fonsecas72) - Martynas Narbutas - Bailey Parker - Antanas Arvasevicius + - Kris Kelly - Eddie Abou-Jaoude (eddiejaoude) - Haritz Iturbe (hizai) - Nerijus Arlauskas (nercury) @@ -2113,7 +2138,6 @@ The Symfony Connect username in parenthesis allows to get more information - Jonathan Hedstrom - Peter Smeets (darkspartan) - Julien Bianchi (jubianchi) - - Tamás Nagy (t-bond) - Robert Meijers - Tijs Verkoyen - James Sansbury @@ -2177,6 +2201,7 @@ The Symfony Connect username in parenthesis allows to get more information - Oxan van Leeuwen - pkowalczyk - Soner Sayakci + - Andreas Hennings - Max Voloshin (maxvoloshin) - Nicolas Fabre (nfabre) - Raul Rodriguez (raul782) @@ -2184,11 +2209,11 @@ The Symfony Connect username in parenthesis allows to get more information - MightyBranch - Kacper Gunia (cakper) - Derek Lambert (dlambert) + - Mark Pedron (markpedron) - Peter Thompson (petert82) - error56 - Felicitus - alexpozzi - - Marvin Feldmann (breyndotechse) - Krzysztof Przybyszewski (kprzybyszewski) - Boullé William (williamboulle) - Frederic Godfrin @@ -2226,7 +2251,9 @@ The Symfony Connect username in parenthesis allows to get more information - Jelte Steijaert (jelte) - David Négrier (moufmouf) - Quique Porta (quiqueporta) + - Tobias Feijten (tobias93) - Andrea Quintino (dirk39) + - Andreas Heigl (heiglandreas) - Tomasz Szymczyk (karion) - Peter Dietrich (xosofox) - Alex Vasilchenko @@ -2249,7 +2276,9 @@ The Symfony Connect username in parenthesis allows to get more information - Ross Tuck - omniError - Zander Baldwin + - László GÖRÖG - Kévin Gomez (kevin) + - Kevin van Sonsbeek (kevin_van_sonsbeek) - Mihai Nica (redecs) - Andrei Igna - azine @@ -2299,8 +2328,8 @@ The Symfony Connect username in parenthesis allows to get more information - Steve Frécinaux - Constantine Shtompel - Jules Lamur - - zenas1210 - Renato Mendes Figueiredo + - Raphaël Droz - Eric Stern - ShiraNai7 - Antal Áron (antalaron) @@ -2318,6 +2347,7 @@ The Symfony Connect username in parenthesis allows to get more information - Jason Desrosiers - m.chwedziak - Andreas Frömer + - Bikal Basnet - Philip Frank - Lance McNearney - Illia Antypenko (aivus) @@ -2339,10 +2369,10 @@ The Symfony Connect username in parenthesis allows to get more information - Martin Pärtel - Frédéric Bouchery (fbouchery) - Patrick Daley (padrig) + - Phillip Look (plook) - Max Summe - Ema Panz - Chihiro Adachi (chihiro-adachi) - - Benjamin Georgeault (wedgesama) - Raphaëll Roussel - Tadcka - Abudarham Yuval @@ -2400,6 +2430,7 @@ The Symfony Connect username in parenthesis allows to get more information - Michael Lively (mlivelyjr) - Abderrahim (phydev) - Attila Bukor (r1pp3rj4ck) + - Thomas Boileau (tboileau) - Thomas Chmielowiec (chmielot) - Jānis Lukss - rkerner @@ -2410,6 +2441,7 @@ The Symfony Connect username in parenthesis allows to get more information - AnrDaemon - Charly Terrier (charlypoppins) - Emre Akinci (emre) + - Rustam Bakeev (nommyde) - psampaz (psampaz) - Maxwell Vandervelde - kaywalker @@ -2441,7 +2473,6 @@ The Symfony Connect username in parenthesis allows to get more information - Ciaran McNulty (ciaranmcnulty) - Andrew (drew) - j4nr6n (j4nr6n) - - kor3k kor3k (kor3k) - Stelian Mocanita (stelian) - Gautier Deuette - Kirk Madera @@ -2469,6 +2500,7 @@ The Symfony Connect username in parenthesis allows to get more information - georaldc - wusuopu - Wouter de Wild + - Peter Potrowl - povilas - Gavin Staniforth - Alessandro Tagliapietra (alex88) @@ -2495,6 +2527,7 @@ The Symfony Connect username in parenthesis allows to get more information - Martin Schophaus (m_schophaus_adcada) - Martynas Sudintas (martiis) - Anton Sukhachev (mrsuh) + - Marcel Siegert - ryunosuke - Francisco Facioni (fran6co) - Iwan van Staveren (istaveren) @@ -2513,12 +2546,14 @@ The Symfony Connect username in parenthesis allows to get more information - Matt Farmer - catch - Alexandre Segura + - Asier Etxebeste - Josef Cech - Andrii Boiko - Harold Iedema - Ikhsan Agustian - Benoit Lévêque (benoit_leveque) - Simon Bouland (bouland) + - Jakub Janata (janatjak) - Jibé Barth (jibbarth) - Matthew Foster (mfoster) - Reyo Stallenberg (reyostallenberg) @@ -2552,6 +2587,7 @@ The Symfony Connect username in parenthesis allows to get more information - Houziaux mike - Phobetor - Markus + - Janusz Mocek - Thomas Chmielowiec - shdev - Andrey Ryaguzov @@ -2569,6 +2605,7 @@ The Symfony Connect username in parenthesis allows to get more information - František Bereň - Jeremiah VALERIE - Mike Francis + - Nil Borodulia - Almog Baku (almogbaku) - Gerd Christian Kunze (derdu) - Ionel Scutelnicu (ionelscutelnicu) @@ -2578,6 +2615,7 @@ The Symfony Connect username in parenthesis allows to get more information - Nick Stemerdink - David Stone - Grayson Koonce + - Wissame MEKHILEF - Romain Dorgueil - Christopher Parotat - Dennis Haarbrink @@ -2598,6 +2636,7 @@ The Symfony Connect username in parenthesis allows to get more information - Felix Marezki - Normunds - Thomas Rothe + - Troy Crawford - nietonfir - alefranz - David Barratt @@ -2623,6 +2662,7 @@ The Symfony Connect username in parenthesis allows to get more information - efeen - Nicolas Pion - Muhammed Akbulut + - Xesau - Aaron Somi - Michał Dąbrowski (defrag) - Simone Fumagalli (hpatoio) @@ -2644,10 +2684,12 @@ The Symfony Connect username in parenthesis allows to get more information - Gijs Kunze - Artyom Protaskin - Nathanael d. Noblet + - Yurun - helmer - ged15 - Simon Asika - Daan van Renterghem + - Boudry Julien - amcastror - Bram Van der Sype (brammm) - Guile (guile) @@ -2723,6 +2765,7 @@ The Symfony Connect username in parenthesis allows to get more information - Rémi Blaise - Nicolas Séverin - Joel Marcey + - zolikonta - David Christmann - root - pf @@ -2764,8 +2807,10 @@ The Symfony Connect username in parenthesis allows to get more information - Jelle Kapitein - Jochen Mandl - Marin Nicolae + - Albert Prat - Alessandro Loffredo - Ian Phillips + - Remi Collet - Haritz - Matthieu Prat - Brieuc Thomas @@ -2791,6 +2836,7 @@ The Symfony Connect username in parenthesis allows to get more information - Erik van Wingerden - Valouleloup - Alexis MARQUIS + - Matheus Gontijo - Gerrit Drost - Linnaea Von Lavia - Simon Mönch @@ -2810,6 +2856,8 @@ The Symfony Connect username in parenthesis allows to get more information - Rafał - Adria Lopez (adlpz) - Aaron Scherer (aequasi) + - Alexandre Jardin (alexandre.jardin) + - Bart Brouwer (bartbrouwer) - Rosio (ben-rosio) - Simon Paarlberg (blamh) - Masao Maeda (brtriver) @@ -2836,6 +2884,7 @@ The Symfony Connect username in parenthesis allows to get more information - Kevin Verschaeve (keversc) - Kevin Herrera (kherge) - Luis Ramón López López (lrlopez) + - Matheo Daninos (mathdns) - Mehdi Mabrouk (mehdidev) - Bart Reunes (metalarend) - Muriel (metalmumu) @@ -2847,6 +2896,7 @@ The Symfony Connect username in parenthesis allows to get more information - Olivier Laviale (olvlvl) - Pablo Monterde Perez (plebs) - Jimmy Leger (redpanda) + - Mokhtar Tlili (sf-djuba) - Marcin Szepczynski (szepczynski) - Simone Di Maulo (toretto460) - Cyrille Jouineau (tuxosaurus) @@ -2861,6 +2911,7 @@ The Symfony Connect username in parenthesis allows to get more information - Taylan Kasap - Michael Orlitzky - Nicolas A. Bérard-Nault + - Francois Martin - Saem Ghani - Stefan Oderbolz - Gabriel Moreira @@ -2901,6 +2952,7 @@ The Symfony Connect username in parenthesis allows to get more information - temperatur - Paul Andrieux - Cas + - Gwendolen Lynch - ghazy ben ahmed - Karolis - Myke79 @@ -2941,6 +2993,7 @@ The Symfony Connect username in parenthesis allows to get more information - Jack Wright - MrNicodemuz - Anonymous User + - demeritcowboy - Paweł Tomulik - Eric J. Duran - Blackfelix @@ -3025,7 +3078,9 @@ The Symfony Connect username in parenthesis allows to get more information - Yurii K - Richard Trebichavský - g123456789l + - Mark Ogilvie - Jonathan Vollebregt + - Vladimir Vasilev - oscartv - DanSync - Peter Zwosta @@ -3053,6 +3108,7 @@ The Symfony Connect username in parenthesis allows to get more information - sualko - ADmad - Nicolas Roudaire + - Abdouni Karim (abdounikarim) - Andreas Forsblom (aforsblo) - Alex Olmos (alexolmos) - Cedric BERTOLINI (alsciende) @@ -3150,6 +3206,7 @@ The Symfony Connect username in parenthesis allows to get more information - Jesper Søndergaard Pedersen (zerrvox) - Florent Cailhol - szymek + - Konrad - Kovacs Nicolas - craigmarvelley - Stano Turza diff --git a/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php b/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php index 472330a859c6e..c4d3971edcaff 100644 --- a/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php +++ b/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php @@ -11,6 +11,7 @@ namespace Symfony\Bridge\Twig; +use Composer\InstalledVersions; use Symfony\Bundle\FullStack; use Twig\Error\SyntaxError; use Twig\TwigFilter; @@ -98,6 +99,12 @@ private static function onUndefined(string $name, string $type, string $componen return sprintf('Did you forget to %s? Unknown %s "%s".', self::FULL_STACK_ENABLE[$component], $type, $name); } - return sprintf('Did you forget to run "composer require symfony/%s"? Unknown %s "%s".', $component, $type, $name); + $missingPackage = 'symfony/'.$component; + + if (class_exists(InstalledVersions::class) && InstalledVersions::isInstalled($missingPackage)) { + $missingPackage = 'symfony/twig-bundle'; + } + + return sprintf('Did you forget to run "composer require %s"? Unknown %s "%s".', $missingPackage, $type, $name); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/CacheWarmupCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/CacheWarmupCommand.php index 36c2f76e7e3cf..5e7dc36f206d8 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/CacheWarmupCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/CacheWarmupCommand.php @@ -53,11 +53,6 @@ protected function configure() Before running this command, the cache must be empty. -This command does not generate the classes cache (as when executing this -command, too many classes that should be part of the cache are already loaded -in memory). Use curl or any other similar tool to warm up -the classes cache if you want. - EOF ) ; diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 2313aea5d2d49..1335e0a80f7c2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -113,6 +113,7 @@ use Symfony\Component\Messenger\MessageBus; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\Middleware\RouterContextMiddleware; +use Symfony\Component\Messenger\Stamp\SerializedMessageStamp; use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; use Symfony\Component\Messenger\Transport\TransportFactoryInterface; use Symfony\Component\Messenger\Transport\TransportInterface; @@ -167,8 +168,10 @@ use Symfony\Component\Notifier\Bridge\Vonage\VonageTransportFactory; use Symfony\Component\Notifier\Bridge\Yunpian\YunpianTransportFactory; use Symfony\Component\Notifier\Bridge\Zulip\ZulipTransportFactory; +use Symfony\Component\Notifier\ChatterInterface; use Symfony\Component\Notifier\Notifier; use Symfony\Component\Notifier\Recipient\Recipient; +use Symfony\Component\Notifier\TexterInterface; use Symfony\Component\Notifier\Transport\TransportFactoryInterface as NotifierTransportFactoryInterface; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; @@ -649,18 +652,30 @@ public function load(array $configs, ContainerBuilder $container) $container->registerAttributeForAutoconfiguration(AsController::class, static function (ChildDefinition $definition, AsController $attribute): void { $definition->addTag('controller.service_arguments'); }); - $container->registerAttributeForAutoconfiguration(AsMessageHandler::class, static function (ChildDefinition $definition, AsMessageHandler $attribute, \ReflectionClass|\ReflectionMethod $reflector): void { - $tagAttributes = get_object_vars($attribute); - $tagAttributes['from_transport'] = $tagAttributes['fromTransport']; - unset($tagAttributes['fromTransport']); - if ($reflector instanceof \ReflectionMethod) { - if (isset($tagAttributes['method'])) { - throw new LogicException(sprintf('AsMessageHandler attribute cannot declare a method on "%s::%s()".', $reflector->class, $reflector->name)); + + if (class_exists(SerializedMessageStamp::class)) { + // symfony/messenger >= 6.1 + $container->registerAttributeForAutoconfiguration(AsMessageHandler::class, static function (ChildDefinition $definition, AsMessageHandler $attribute, \ReflectionClass|\ReflectionMethod $reflector): void { + $tagAttributes = get_object_vars($attribute); + $tagAttributes['from_transport'] = $tagAttributes['fromTransport']; + unset($tagAttributes['fromTransport']); + if ($reflector instanceof \ReflectionMethod) { + if (isset($tagAttributes['method'])) { + throw new LogicException(sprintf('AsMessageHandler attribute cannot declare a method on "%s::%s()".', $reflector->class, $reflector->name)); + } + $tagAttributes['method'] = $reflector->getName(); } - $tagAttributes['method'] = $reflector->getName(); - } - $definition->addTag('messenger.message_handler', $tagAttributes); - }); + $definition->addTag('messenger.message_handler', $tagAttributes); + }); + } else { + // symfony/messenger < 6.1 + $container->registerAttributeForAutoconfiguration(AsMessageHandler::class, static function (ChildDefinition $definition, AsMessageHandler $attribute): void { + $tagAttributes = get_object_vars($attribute); + $tagAttributes['from_transport'] = $tagAttributes['fromTransport']; + unset($tagAttributes['fromTransport']); + $definition->addTag('messenger.message_handler', $tagAttributes); + }); + } if (!$container->getParameter('kernel.debug')) { // remove tagged iterator argument for resource checkers @@ -2482,11 +2497,13 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ $container->getDefinition('chatter.transports')->setArgument(0, $config['chatter_transports']); } else { $container->removeDefinition('chatter'); + $container->removeAlias(ChatterInterface::class); } if ($config['texter_transports']) { $container->getDefinition('texter.transports')->setArgument(0, $config['texter_transports']); } else { $container->removeDefinition('texter'); + $container->removeAlias(TexterInterface::class); } if ($this->mailerConfigEnabled) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Test/KernelTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Test/KernelTestCase.php index 165797504bcb8..d189b88db5799 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Test/KernelTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Test/KernelTestCase.php @@ -70,7 +70,7 @@ protected static function bootKernel(array $options = []): KernelInterface $kernel = static::createKernel($options); $kernel->boot(); - self::$kernel = $kernel; + static::$kernel = $kernel; static::$booted = true; return static::$kernel; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index a00b5b47bf674..9a480af2ac816 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -56,6 +56,8 @@ use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass; use Symfony\Component\HttpKernel\Fragment\FragmentUriGeneratorInterface; use Symfony\Component\Messenger\Transport\TransportFactory; +use Symfony\Component\Notifier\ChatterInterface; +use Symfony\Component\Notifier\TexterInterface; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\Security\Core\Security; use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; @@ -2018,7 +2020,9 @@ public function testNotifierWithoutTransports() $this->assertTrue($container->hasDefinition('notifier')); $this->assertFalse($container->hasDefinition('chatter')); + $this->assertFalse($container->hasAlias(ChatterInterface::class)); $this->assertFalse($container->hasDefinition('texter')); + $this->assertFalse($container->hasAlias(TexterInterface::class)); } public function testIfNotifierTransportsAreKnownByFrameworkExtension() diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 1a301287d043d..dc1e717e28595 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -48,7 +48,7 @@ "symfony/http-client": "^5.4|^6.0", "symfony/lock": "^5.4|^6.0", "symfony/mailer": "^5.4|^6.0", - "symfony/messenger": "^6.1", + "symfony/messenger": "^5.4|^6.0", "symfony/mime": "^5.4|^6.0", "symfony/notifier": "^5.4|^6.0", "symfony/process": "^5.4|^6.0", diff --git a/src/Symfony/Component/Console/Application.php b/src/Symfony/Component/Console/Application.php index 64068fcc23b48..c88bf2c842105 100644 --- a/src/Symfony/Component/Console/Application.php +++ b/src/Symfony/Component/Console/Application.php @@ -957,22 +957,30 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI } } - if ($command instanceof SignalableCommandInterface && ($this->signalsToDispatchEvent || $command->getSubscribedSignals())) { - if (!$this->signalRegistry) { - throw new RuntimeException('Unable to subscribe to signal events. Make sure that the `pcntl` extension is installed and that "pcntl_*" functions are not disabled by your php.ini\'s "disable_functions" directive.'); - } + if ($this->signalsToDispatchEvent) { + $commandSignals = $command instanceof SignalableCommandInterface ? $command->getSubscribedSignals() : []; - if (Terminal::hasSttyAvailable()) { - $sttyMode = shell_exec('stty -g'); + if ($commandSignals || null !== $this->dispatcher) { + if (!$this->signalRegistry) { + throw new RuntimeException('Unable to subscribe to signal events. Make sure that the `pcntl` extension is installed and that "pcntl_*" functions are not disabled by your php.ini\'s "disable_functions" directive.'); + } - foreach ([\SIGINT, \SIGTERM] as $signal) { - $this->signalRegistry->register($signal, static function () use ($sttyMode) { - shell_exec('stty '.$sttyMode); - }); + if (Terminal::hasSttyAvailable()) { + $sttyMode = shell_exec('stty -g'); + + foreach ([\SIGINT, \SIGTERM] as $signal) { + $this->signalRegistry->register($signal, static function () use ($sttyMode) { + shell_exec('stty '.$sttyMode); + }); + } + } + + foreach ($commandSignals as $signal) { + $this->signalRegistry->register($signal, [$command, 'handleSignal']); } } - if ($this->dispatcher) { + if (null !== $this->dispatcher) { foreach ($this->signalsToDispatchEvent as $signal) { $event = new ConsoleSignalEvent($command, $input, $output, $signal); @@ -988,10 +996,6 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI }); } } - - foreach ($command->getSubscribedSignals() as $signal) { - $this->signalRegistry->register($signal, [$command, 'handleSignal']); - } } if (null === $this->dispatcher) { diff --git a/src/Symfony/Component/Console/Descriptor/ApplicationDescription.php b/src/Symfony/Component/Console/Descriptor/ApplicationDescription.php index 5f32173ae5a48..2158339ec4181 100644 --- a/src/Symfony/Component/Console/Descriptor/ApplicationDescription.php +++ b/src/Symfony/Component/Console/Descriptor/ApplicationDescription.php @@ -127,7 +127,7 @@ private function sortCommands(array $commands): array } if ($namespacedCommands) { - ksort($namespacedCommands); + ksort($namespacedCommands, \SORT_STRING); foreach ($namespacedCommands as $key => $commandsSet) { ksort($commandsSet); $sortedCommands[$key] = $commandsSet; diff --git a/src/Symfony/Component/Console/Formatter/OutputFormatterStyleStack.php b/src/Symfony/Component/Console/Formatter/OutputFormatterStyleStack.php index e72b641bae6f9..ee541dcd7765c 100644 --- a/src/Symfony/Component/Console/Formatter/OutputFormatterStyleStack.php +++ b/src/Symfony/Component/Console/Formatter/OutputFormatterStyleStack.php @@ -77,7 +77,7 @@ public function pop(OutputFormatterStyleInterface $style = null): OutputFormatte /** * Computes current style with stacks top codes. */ - public function getCurrent(): OutputFormatterStyle + public function getCurrent(): OutputFormatterStyleInterface { if (empty($this->styles)) { return $this->emptyStyle; diff --git a/src/Symfony/Component/Console/Tests/ApplicationTest.php b/src/Symfony/Component/Console/Tests/ApplicationTest.php index 9d6c619ad82ff..6295de3ecaf18 100644 --- a/src/Symfony/Component/Console/Tests/ApplicationTest.php +++ b/src/Symfony/Component/Console/Tests/ApplicationTest.php @@ -22,6 +22,7 @@ use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass; use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\Console\Event\ConsoleErrorEvent; +use Symfony\Component\Console\Event\ConsoleSignalEvent; use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Exception\NamespaceNotFoundException; @@ -43,6 +44,8 @@ use Symfony\Component\Console\Tester\ApplicationTester; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Process\Process; class ApplicationTest extends TestCase @@ -1862,9 +1865,9 @@ public function testCommandNameMismatchWithCommandLoaderKeyThrows() /** * @requires extension pcntl */ - public function testSignal() + public function testSignalListenerNotCalledByDefault() { - $command = new SignableCommand(); + $command = new SignableCommand(false); $dispatcherCalled = false; $dispatcher = new EventDispatcher(); @@ -1872,29 +1875,97 @@ public function testSignal() $dispatcherCalled = true; }); - $application = new Application(); - $application->setAutoExit(false); - $application->setDispatcher($dispatcher); - $application->setSignalsToDispatchEvent(\SIGALRM); - $application->add(new LazyCommand('signal', [], '', false, function () use ($command) { return $command; }, true)); - - $this->assertFalse($command->signaled); - $this->assertFalse($dispatcherCalled); + $application = $this->createSignalableApplication($command, $dispatcher); $this->assertSame(0, $application->run(new ArrayInput(['signal']))); $this->assertFalse($command->signaled); $this->assertFalse($dispatcherCalled); + } + + /** + * @requires extension pcntl + */ + public function testSignalListener() + { + $command = new SignableCommand(); + + $dispatcherCalled = false; + $dispatcher = new EventDispatcher(); + $dispatcher->addListener('console.signal', function () use (&$dispatcherCalled) { + $dispatcherCalled = true; + }); + + $application = $this->createSignalableApplication($command, $dispatcher); - $command->loop = 100000; - pcntl_alarm(1); $this->assertSame(1, $application->run(new ArrayInput(['signal']))); - $this->assertTrue($command->signaled); $this->assertTrue($dispatcherCalled); + $this->assertTrue($command->signaled); + } + + /** + * @requires extension pcntl + */ + public function testSignalSubscriberNotCalledByDefault() + { + $command = new BaseSignableCommand(false); + + $subscriber = new SignalEventSubscriber(); + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber($subscriber); + + $application = $this->createSignalableApplication($command, $dispatcher); + + $this->assertSame(0, $application->run(new ArrayInput(['signal']))); + $this->assertFalse($subscriber->signaled); + } + + /** + * @requires extension pcntl + */ + public function testSignalSubscriber() + { + $command = new BaseSignableCommand(); + + $subscriber1 = new SignalEventSubscriber(); + $subscriber2 = new SignalEventSubscriber(); + + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber($subscriber1); + $dispatcher->addSubscriber($subscriber2); + + $application = $this->createSignalableApplication($command, $dispatcher); + + $this->assertSame(1, $application->run(new ArrayInput(['signal']))); + $this->assertTrue($subscriber1->signaled); + $this->assertTrue($subscriber2->signaled); + } + + /** + * @requires extension pcntl + */ + public function testSetSignalsToDispatchEvent() + { + $command = new BaseSignableCommand(); + + $subscriber = new SignalEventSubscriber(); + + $dispatcher = new EventDispatcher(); + $dispatcher->addSubscriber($subscriber); + + $application = $this->createSignalableApplication($command, $dispatcher); + $application->setSignalsToDispatchEvent(\SIGUSR2); + $this->assertSame(0, $application->run(new ArrayInput(['signal']))); + $this->assertFalse($subscriber->signaled); + + $application = $this->createSignalableApplication($command, $dispatcher); + $application->setSignalsToDispatchEvent(\SIGUSR1); + $this->assertSame(1, $application->run(new ArrayInput(['signal']))); + $this->assertTrue($subscriber->signaled); } public function testSignalableCommandInterfaceWithoutSignals() { - $command = new SignableCommand(); + $command = new SignableCommand(false); $dispatcher = new EventDispatcher(); $application = new Application(); @@ -1936,6 +2007,18 @@ public function testSignalableRestoresStty() $this->assertSame($previousSttyMode, $sttyMode); } + + private function createSignalableApplication(Command $command, ?EventDispatcherInterface $dispatcher): Application + { + $application = new Application(); + $application->setAutoExit(false); + if ($dispatcher) { + $application->setDispatcher($dispatcher); + } + $application->add(new LazyCommand('signal', [], '', false, function () use ($command) { return $command; }, true)); + + return $application; + } } class CustomApplication extends Application @@ -1988,23 +2071,24 @@ public function isEnabled(): bool } #[AsCommand(name: 'signal')] -class SignableCommand extends Command implements SignalableCommandInterface +class BaseSignableCommand extends Command { public $signaled = false; - public $loop = 100; - - public function getSubscribedSignals(): array - { - return SignalRegistry::isSupported() ? [\SIGALRM] : []; - } + public $loop = 1000; + private $emitsSignal; - public function handleSignal(int $signal): void + public function __construct(bool $emitsSignal = true) { - $this->signaled = true; + parent::__construct(); + $this->emitsSignal = $emitsSignal; } protected function execute(InputInterface $input, OutputInterface $output): int { + if ($this->emitsSignal) { + posix_kill(posix_getpid(), SIGUSR1); + } + for ($i = 0; $i < $this->loop; ++$i) { usleep(100); if ($this->signaled) { @@ -2015,3 +2099,33 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } } + +#[AsCommand(name: 'signal')] +class SignableCommand extends BaseSignableCommand implements SignalableCommandInterface +{ + public function getSubscribedSignals(): array + { + return SignalRegistry::isSupported() ? [\SIGUSR1] : []; + } + + public function handleSignal(int $signal): void + { + $this->signaled = true; + } +} + +class SignalEventSubscriber implements EventSubscriberInterface +{ + public $signaled = false; + + public function onSignal(ConsoleSignalEvent $event): void + { + $this->signaled = true; + $event->getCommand()->signaled = true; + } + + public static function getSubscribedEvents(): array + { + return ['console.signal' => 'onSignal']; + } +} diff --git a/src/Symfony/Component/Console/Tests/Descriptor/ApplicationDescriptionTest.php b/src/Symfony/Component/Console/Tests/Descriptor/ApplicationDescriptionTest.php index b3ba9d8482b0f..da64dca00b949 100644 --- a/src/Symfony/Component/Console/Tests/Descriptor/ApplicationDescriptionTest.php +++ b/src/Symfony/Component/Console/Tests/Descriptor/ApplicationDescriptionTest.php @@ -36,7 +36,7 @@ public function getNamespacesProvider() return [ [['_global'], ['foobar']], [['a', 'b'], ['b:foo', 'a:foo', 'b:bar']], - [['_global', 'b', 'z', 22, 33], ['z:foo', '1', '33:foo', 'b:foo', '22:foo:bar']], + [['_global', 22, 33, 'b', 'z'], ['z:foo', '1', '33:foo', 'b:foo', '22:foo:bar']], ]; } } diff --git a/src/Symfony/Component/DomCrawler/Crawler.php b/src/Symfony/Component/DomCrawler/Crawler.php index a6f850319897b..174095e13c8ef 100644 --- a/src/Symfony/Component/DomCrawler/Crawler.php +++ b/src/Symfony/Component/DomCrawler/Crawler.php @@ -555,7 +555,7 @@ public function text(string $default = null, bool $normalizeWhitespace = true): $text = $this->getNode(0)->nodeValue; if ($normalizeWhitespace) { - return trim(preg_replace('/(?:\s{2,}+|[^\S ])/', ' ', $text)); + return trim(preg_replace("/(?:[ \n\r\t\x0C]{2,}+|[\n\r\t\x0C])/", ' ', $text), " \n\r\t\x0C"); } return $text; diff --git a/src/Symfony/Component/Filesystem/Path.php b/src/Symfony/Component/Filesystem/Path.php index 17e73a018a623..37dce5c01bc34 100644 --- a/src/Symfony/Component/Filesystem/Path.php +++ b/src/Symfony/Component/Filesystem/Path.php @@ -81,7 +81,7 @@ public static function canonicalize(string $path): string // Replace "~" with user's home directory. if ('~' === $path[0]) { - $path = self::getHomeDirectory().mb_substr($path, 1); + $path = self::getHomeDirectory().substr($path, 1); } $path = self::normalize($path); @@ -151,14 +151,14 @@ public static function getDirectory(string $path): string $path = self::canonicalize($path); // Maintain scheme - if (false !== ($schemeSeparatorPosition = mb_strpos($path, '://'))) { - $scheme = mb_substr($path, 0, $schemeSeparatorPosition + 3); - $path = mb_substr($path, $schemeSeparatorPosition + 3); + if (false !== $schemeSeparatorPosition = strpos($path, '://')) { + $scheme = substr($path, 0, $schemeSeparatorPosition + 3); + $path = substr($path, $schemeSeparatorPosition + 3); } else { $scheme = ''; } - if (false === ($dirSeparatorPosition = strrpos($path, '/'))) { + if (false === $dirSeparatorPosition = strrpos($path, '/')) { return ''; } @@ -169,10 +169,10 @@ public static function getDirectory(string $path): string // Directory equals Windows root "C:/" if (2 === $dirSeparatorPosition && ctype_alpha($path[0]) && ':' === $path[1]) { - return $scheme.mb_substr($path, 0, 3); + return $scheme.substr($path, 0, 3); } - return $scheme.mb_substr($path, 0, $dirSeparatorPosition); + return $scheme.substr($path, 0, $dirSeparatorPosition); } /** @@ -219,7 +219,7 @@ public static function getRoot(string $path): string } // Maintain scheme - if (false !== ($schemeSeparatorPosition = strpos($path, '://'))) { + if (false !== $schemeSeparatorPosition = strpos($path, '://')) { $scheme = substr($path, 0, $schemeSeparatorPosition + 3); $path = substr($path, $schemeSeparatorPosition + 3); } else { @@ -233,7 +233,7 @@ public static function getRoot(string $path): string return $scheme.'/'; } - $length = mb_strlen($path); + $length = \strlen($path); // Windows root if ($length > 1 && ':' === $path[1] && ctype_alpha($firstCharacter)) { @@ -349,16 +349,16 @@ public static function changeExtension(string $path, string $extension): string $extension = ltrim($extension, '.'); // No extension for paths - if ('/' === mb_substr($path, -1)) { + if ('/' === substr($path, -1)) { return $path; } // No actual extension in path if (empty($actualExtension)) { - return $path.('.' === mb_substr($path, -1) ? '' : '.').$extension; + return $path.('.' === substr($path, -1) ? '' : '.').$extension; } - return mb_substr($path, 0, -mb_strlen($actualExtension)).$extension; + return substr($path, 0, -\strlen($actualExtension)).$extension; } public static function isAbsolute(string $path): bool @@ -368,8 +368,8 @@ public static function isAbsolute(string $path): bool } // Strip scheme - if (false !== ($schemeSeparatorPosition = mb_strpos($path, '://'))) { - $path = mb_substr($path, $schemeSeparatorPosition + 3); + if (false !== $schemeSeparatorPosition = strpos($path, '://')) { + $path = substr($path, $schemeSeparatorPosition + 3); } $firstCharacter = $path[0]; @@ -380,9 +380,9 @@ public static function isAbsolute(string $path): bool } // Windows root - if (mb_strlen($path) > 1 && ctype_alpha($firstCharacter) && ':' === $path[1]) { + if (\strlen($path) > 1 && ctype_alpha($firstCharacter) && ':' === $path[1]) { // Special case: "C:" - if (2 === mb_strlen($path)) { + if (2 === \strlen($path)) { return true; } @@ -451,9 +451,9 @@ public static function makeAbsolute(string $path, string $basePath): string return self::canonicalize($path); } - if (false !== ($schemeSeparatorPosition = mb_strpos($basePath, '://'))) { - $scheme = mb_substr($basePath, 0, $schemeSeparatorPosition + 3); - $basePath = mb_substr($basePath, $schemeSeparatorPosition + 3); + if (false !== $schemeSeparatorPosition = strpos($basePath, '://')) { + $scheme = substr($basePath, 0, $schemeSeparatorPosition + 3); + $basePath = substr($basePath, $schemeSeparatorPosition + 3); } else { $scheme = ''; } @@ -671,7 +671,7 @@ public static function join(string ...$paths): string } // Only add slash if previous part didn't end with '/' or '\' - if (!\in_array(mb_substr($finalPath, -1), ['/', '\\'])) { + if (!\in_array(substr($finalPath, -1), ['/', '\\'])) { $finalPath .= '/'; } @@ -776,19 +776,19 @@ private static function split(string $path): array } // Remember scheme as part of the root, if any - if (false !== ($schemeSeparatorPosition = mb_strpos($path, '://'))) { - $root = mb_substr($path, 0, $schemeSeparatorPosition + 3); - $path = mb_substr($path, $schemeSeparatorPosition + 3); + if (false !== $schemeSeparatorPosition = strpos($path, '://')) { + $root = substr($path, 0, $schemeSeparatorPosition + 3); + $path = substr($path, $schemeSeparatorPosition + 3); } else { $root = ''; } - $length = mb_strlen($path); + $length = \strlen($path); // Remove and remember root directory if (str_starts_with($path, '/')) { $root .= '/'; - $path = $length > 1 ? mb_substr($path, 1) : ''; + $path = $length > 1 ? substr($path, 1) : ''; } elseif ($length > 1 && ctype_alpha($path[0]) && ':' === $path[1]) { if (2 === $length) { // Windows special case: "C:" @@ -796,8 +796,8 @@ private static function split(string $path): array $path = ''; } elseif ('/' === $path[2]) { // Windows normal case: "C:/".. - $root .= mb_substr($path, 0, 3); - $path = $length > 3 ? mb_substr($path, 3) : ''; + $root .= substr($path, 0, 3); + $path = $length > 3 ? substr($path, 3) : ''; } } @@ -806,11 +806,11 @@ private static function split(string $path): array private static function toLower(string $string): string { - if (false !== $encoding = mb_detect_encoding($string)) { + if (false !== $encoding = mb_detect_encoding($string, null, true)) { return mb_strtolower($string, $encoding); } - return strtolower($string, $encoding); + return strtolower($string); } private function __construct() diff --git a/src/Symfony/Component/Filesystem/Tests/PathTest.php b/src/Symfony/Component/Filesystem/Tests/PathTest.php index 4fb2c013066f9..2f04c790c396a 100644 --- a/src/Symfony/Component/Filesystem/Tests/PathTest.php +++ b/src/Symfony/Component/Filesystem/Tests/PathTest.php @@ -223,6 +223,8 @@ public function provideGetDirectoryTests(): \Generator yield ['/..', '/']; yield ['C:webmozart', '']; + + yield ['D:/Folder/Aééé/Subfolder', 'D:/Folder/Aééé']; } /** diff --git a/src/Symfony/Component/Form/Extension/Core/DataAccessor/PropertyPathAccessor.php b/src/Symfony/Component/Form/Extension/Core/DataAccessor/PropertyPathAccessor.php index 9e1e0af5202de..57785a89679f2 100644 --- a/src/Symfony/Component/Form/Extension/Core/DataAccessor/PropertyPathAccessor.php +++ b/src/Symfony/Component/Form/Extension/Core/DataAccessor/PropertyPathAccessor.php @@ -15,6 +15,7 @@ use Symfony\Component\Form\Exception\AccessException; use Symfony\Component\Form\FormInterface; use Symfony\Component\PropertyAccess\Exception\AccessException as PropertyAccessException; +use Symfony\Component\PropertyAccess\Exception\NoSuchIndexException; use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -90,6 +91,10 @@ private function getPropertyValue(object|array $data, PropertyPathInterface $pro try { return $this->propertyAccessor->getValue($data, $propertyPath); } catch (PropertyAccessException $e) { + if (\is_array($data) && $e instanceof NoSuchIndexException) { + return null; + } + if (!$e instanceof UninitializedPropertyException // For versions without UninitializedPropertyException check the exception message && (class_exists(UninitializedPropertyException::class) || !str_contains($e->getMessage(), 'You should initialize it')) diff --git a/src/Symfony/Component/Form/Tests/CompoundFormTest.php b/src/Symfony/Component/Form/Tests/CompoundFormTest.php index 5244948a0cc17..e5a4aeec332aa 100644 --- a/src/Symfony/Component/Form/Tests/CompoundFormTest.php +++ b/src/Symfony/Component/Form/Tests/CompoundFormTest.php @@ -14,7 +14,9 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Form\Exception\AlreadySubmittedException; +use Symfony\Component\Form\Extension\Core\DataAccessor\PropertyPathAccessor; use Symfony\Component\Form\Extension\Core\DataMapper\DataMapper; +use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\HttpFoundation\HttpFoundationRequestHandler; @@ -36,6 +38,7 @@ use Symfony\Component\Form\Tests\Fixtures\Map; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\PropertyAccess\PropertyAccess; class CompoundFormTest extends TestCase { @@ -1076,6 +1079,30 @@ public function testFileUpload() $this->assertNull($this->form->get('bar')->getData()); } + public function testMapDateTimeObjectsWithEmptyArrayDataUsingDataMapper() + { + $propertyAccessor = PropertyAccess::createPropertyAccessorBuilder() + ->enableExceptionOnInvalidIndex() + ->getPropertyAccessor(); + $form = $this->factory->createBuilder() + ->setDataMapper(new DataMapper(new PropertyPathAccessor($propertyAccessor))) + ->add('date', DateType::class, [ + 'auto_initialize' => false, + 'format' => 'dd/MM/yyyy', + 'html5' => false, + 'model_timezone' => 'UTC', + 'view_timezone' => 'UTC', + 'widget' => 'single_text', + ]) + ->getForm(); + + $form->submit([ + 'date' => '04/08/2022', + ]); + + $this->assertEquals(['date' => new \DateTime('2022-08-04', new \DateTimeZone('UTC'))], $form->getData()); + } + private function createForm(string $name = 'name', bool $compound = true): FormInterface { $builder = $this->getBuilder($name); diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/DataMapper/DataMapperTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/DataMapper/DataMapperTest.php index b29cb3b5e8980..1b625dc139adc 100644 --- a/src/Symfony/Component/Form/Tests/Extension/Core/DataMapper/DataMapperTest.php +++ b/src/Symfony/Component/Form/Tests/Extension/Core/DataMapper/DataMapperTest.php @@ -14,10 +14,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Form\Extension\Core\DataAccessor\PropertyPathAccessor; use Symfony\Component\Form\Extension\Core\DataMapper\DataMapper; +use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Form; use Symfony\Component\Form\FormConfigBuilder; +use Symfony\Component\Form\FormFactoryBuilder; use Symfony\Component\Form\Tests\Fixtures\TypehintedPropertiesCar; +use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyPath; class DataMapperTest extends TestCase @@ -382,6 +386,33 @@ public function testMapFormsToDataUsingSetCallbackOption() self::assertSame('Jane Doe', $person->myName()); } + + public function testMapFormsToDataMapsDateTimeInstanceToArrayIfNotSetBefore() + { + $propertyAccessor = PropertyAccess::createPropertyAccessorBuilder() + ->enableExceptionOnInvalidIndex() + ->getPropertyAccessor(); + $propertyAccessor = PropertyAccess::createPropertyAccessorBuilder() + ->enableExceptionOnInvalidIndex() + ->getPropertyAccessor(); + $form = (new FormFactoryBuilder())->getFormFactory()->createBuilder() + ->setDataMapper(new DataMapper(new PropertyPathAccessor($propertyAccessor))) + ->add('date', DateType::class, [ + 'auto_initialize' => false, + 'format' => 'dd/MM/yyyy', + 'html5' => false, + 'model_timezone' => 'UTC', + 'view_timezone' => 'UTC', + 'widget' => 'single_text', + ]) + ->getForm(); + + $form->submit([ + 'date' => '04/08/2022', + ]); + + $this->assertEquals(['date' => new \DateTime('2022-08-04', new \DateTimeZone('UTC'))], $form->getData()); + } } class SubmittedForm extends Form diff --git a/src/Symfony/Component/HttpClient/Internal/CurlClientState.php b/src/Symfony/Component/HttpClient/Internal/CurlClientState.php index 1634243ade40f..fa7120f0a1eeb 100644 --- a/src/Symfony/Component/HttpClient/Internal/CurlClientState.php +++ b/src/Symfony/Component/HttpClient/Internal/CurlClientState.php @@ -96,7 +96,7 @@ public function reset() curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_DNS); curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_SSL_SESSION); - if (\defined('CURL_LOCK_DATA_CONNECT')) { + if (\defined('CURL_LOCK_DATA_CONNECT') && \PHP_VERSION_ID >= 80000) { curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_CONNECT); } } diff --git a/src/Symfony/Component/HttpClient/Response/StreamWrapper.php b/src/Symfony/Component/HttpClient/Response/StreamWrapper.php index e002a004816d6..749be2688d4d8 100644 --- a/src/Symfony/Component/HttpClient/Response/StreamWrapper.php +++ b/src/Symfony/Component/HttpClient/Response/StreamWrapper.php @@ -59,20 +59,18 @@ public static function createResource(ResponseInterface $response, HttpClientInt throw new \InvalidArgumentException(sprintf('Providing a client to "%s()" is required when the response doesn\'t have any "stream()" method.', __CLASS__)); } - if (false === stream_wrapper_register('symfony', __CLASS__)) { + static $registered = false; + + if (!$registered = $registered || stream_wrapper_register(strtr(__CLASS__, '\\', '-'), __CLASS__)) { throw new \RuntimeException(error_get_last()['message'] ?? 'Registering the "symfony" stream wrapper failed.'); } - try { - $context = [ - 'client' => $client ?? $response, - 'response' => $response, - ]; - - return fopen('symfony://'.$response->getInfo('url'), 'r', false, stream_context_create(['symfony' => $context])) ?: null; - } finally { - stream_wrapper_unregister('symfony'); - } + $context = [ + 'client' => $client ?? $response, + 'response' => $response, + ]; + + return fopen(strtr(__CLASS__, '\\', '-').'://'.$response->getInfo('url'), 'r', false, stream_context_create(['symfony' => $context])); } public function getResponse(): ResponseInterface diff --git a/src/Symfony/Component/HttpFoundation/RateLimiter/AbstractRequestRateLimiter.php b/src/Symfony/Component/HttpFoundation/RateLimiter/AbstractRequestRateLimiter.php index c91d614fe30bf..a6dd993b7315b 100644 --- a/src/Symfony/Component/HttpFoundation/RateLimiter/AbstractRequestRateLimiter.php +++ b/src/Symfony/Component/HttpFoundation/RateLimiter/AbstractRequestRateLimiter.php @@ -35,9 +35,7 @@ public function consume(Request $request): RateLimit foreach ($limiters as $limiter) { $rateLimit = $limiter->consume(1); - if (null === $minimalRateLimit || $rateLimit->getRemainingTokens() < $minimalRateLimit->getRemainingTokens()) { - $minimalRateLimit = $rateLimit; - } + $minimalRateLimit = $minimalRateLimit ? self::getMinimalRateLimit($minimalRateLimit, $rateLimit) : $rateLimit; } return $minimalRateLimit; @@ -54,4 +52,20 @@ public function reset(Request $request): void * @return LimiterInterface[] a set of limiters using keys extracted from the request */ abstract protected function getLimiters(Request $request): array; + + private static function getMinimalRateLimit(RateLimit $first, RateLimit $second): RateLimit + { + if ($first->isAccepted() !== $second->isAccepted()) { + return $first->isAccepted() ? $second : $first; + } + + $firstRemainingTokens = $first->getRemainingTokens(); + $secondRemainingTokens = $second->getRemainingTokens(); + + if ($firstRemainingTokens === $secondRemainingTokens) { + return $first->getRetryAfter() < $second->getRetryAfter() ? $second : $first; + } + + return $firstRemainingTokens > $secondRemainingTokens ? $second : $first; + } } diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/StrictSessionHandler.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/StrictSessionHandler.php index 730f1009138ab..f507d926935ae 100644 --- a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/StrictSessionHandler.php +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/StrictSessionHandler.php @@ -30,6 +30,16 @@ public function __construct(\SessionHandlerInterface $handler) $this->handler = $handler; } + /** + * Returns true if this handler wraps an internal PHP session save handler using \SessionHandler. + * + * @internal + */ + public function isWrapper(): bool + { + return $this->handler instanceof \SessionHandler; + } + public function open(string $savePath, string $sessionName): bool { parent::open($savePath, $sessionName); diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Proxy/SessionHandlerProxy.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Proxy/SessionHandlerProxy.php index 73376619ce73e..c292e58f05851 100644 --- a/src/Symfony/Component/HttpFoundation/Session/Storage/Proxy/SessionHandlerProxy.php +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Proxy/SessionHandlerProxy.php @@ -11,6 +11,8 @@ namespace Symfony\Component\HttpFoundation\Session\Storage\Proxy; +use Symfony\Component\HttpFoundation\Session\Storage\Handler\StrictSessionHandler; + /** * @author Drak */ @@ -22,7 +24,7 @@ public function __construct(\SessionHandlerInterface $handler) { $this->handler = $handler; $this->wrapper = $handler instanceof \SessionHandler; - $this->saveHandlerName = $this->wrapper ? \ini_get('session.save_handler') : 'user'; + $this->saveHandlerName = $this->wrapper || ($handler instanceof StrictSessionHandler && $handler->isWrapper()) ? \ini_get('session.save_handler') : 'user'; } public function getHandler(): \SessionHandlerInterface diff --git a/src/Symfony/Component/HttpFoundation/Tests/Fixtures/response-functional/deleted_cookie.expected b/src/Symfony/Component/HttpFoundation/Tests/Fixtures/response-functional/deleted_cookie.expected new file mode 100644 index 0000000000000..0afe8a6333d00 --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Tests/Fixtures/response-functional/deleted_cookie.expected @@ -0,0 +1,11 @@ + +Array +( + [0] => Content-Type: text/plain; charset=utf-8 + [1] => Cache-Control: max-age=0, private, must-revalidate + [2] => Cache-Control: max-age=0, must-revalidate, private + [3] => Date: Sat, 12 Nov 1955 20:04:00 GMT + [4] => Expires: %s, %d %s %d %d:%d:%d GMT + [5] => Set-Cookie: PHPSESSID=deleted; expires=%s, %d %s %d %d:%d:%d GMT; Max-Age=%d; %s +) +shutdown diff --git a/src/Symfony/Component/HttpFoundation/Tests/Fixtures/response-functional/deleted_cookie.php b/src/Symfony/Component/HttpFoundation/Tests/Fixtures/response-functional/deleted_cookie.php new file mode 100644 index 0000000000000..003b0c121f888 --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Tests/Fixtures/response-functional/deleted_cookie.php @@ -0,0 +1,60 @@ +cookies->set($sessionName, $sessionId); + +$requestStack = new RequestStack(); +$requestStack->push($request); + +$sessionFactory = new SessionFactory($requestStack, new NativeSessionStorageFactory()); + +$container = new Container(); +$container->set('request_stack', $requestStack); +$container->set('session_factory', $sessionFactory); + +$listener = new SessionListener($container); + +$kernel = new class($r) implements HttpKernelInterface { + /** + * @var Response + */ + private $response; + + public function __construct(Response $response) + { + $this->response = $response; + } + + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return $this->response; + } +}; + +$listener->onKernelRequest(new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST)); +$session = $request->getSession(); +$session->set('foo', 'bar'); +$session->invalidate(); + +$listener->onKernelResponse(new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $r)); + +$r->sendHeaders(); diff --git a/src/Symfony/Component/HttpFoundation/Tests/RateLimiter/AbstractRequestRateLimiterTest.php b/src/Symfony/Component/HttpFoundation/Tests/RateLimiter/AbstractRequestRateLimiterTest.php new file mode 100644 index 0000000000000..4790eae183802 --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Tests/RateLimiter/AbstractRequestRateLimiterTest.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Tests\RateLimiter; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\RateLimiter\LimiterInterface; +use Symfony\Component\RateLimiter\RateLimit; + +class AbstractRequestRateLimiterTest extends TestCase +{ + /** + * @dataProvider provideRateLimits + */ + public function testConsume(array $rateLimits, ?RateLimit $expected) + { + $rateLimiter = new MockAbstractRequestRateLimiter(array_map(function (RateLimit $rateLimit) { + $limiter = $this->createStub(LimiterInterface::class); + $limiter->method('consume')->willReturn($rateLimit); + + return $limiter; + }, $rateLimits)); + + $this->assertSame($expected, $rateLimiter->consume(new Request())); + } + + public function provideRateLimits() + { + $now = new \DateTimeImmutable(); + + yield 'Both accepted with different count of remaining tokens' => [ + [ + $expected = new RateLimit(0, $now, true, 1), // less remaining tokens + new RateLimit(1, $now, true, 1), + ], + $expected, + ]; + + yield 'Both accepted with same count of remaining tokens' => [ + [ + $expected = new RateLimit(0, $now->add(new \DateInterval('P1D')), true, 1), // longest wait time + new RateLimit(0, $now, true, 1), + ], + $expected, + ]; + + yield 'Accepted and denied' => [ + [ + new RateLimit(0, $now, true, 1), + $expected = new RateLimit(0, $now, false, 1), // denied + ], + $expected, + ]; + } +} diff --git a/src/Symfony/Component/HttpFoundation/Tests/RateLimiter/MockAbstractRequestRateLimiter.php b/src/Symfony/Component/HttpFoundation/Tests/RateLimiter/MockAbstractRequestRateLimiter.php new file mode 100644 index 0000000000000..0acc918bf4d5c --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Tests/RateLimiter/MockAbstractRequestRateLimiter.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Tests\RateLimiter; + +use Symfony\Component\HttpFoundation\RateLimiter\AbstractRequestRateLimiter; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\RateLimiter\LimiterInterface; + +class MockAbstractRequestRateLimiter extends AbstractRequestRateLimiter +{ + /** + * @var LimiterInterface[] + */ + private $limiters; + + public function __construct(array $limiters) + { + $this->limiters = $limiters; + } + + protected function getLimiters(Request $request): array + { + return $this->limiters; + } +} diff --git a/src/Symfony/Component/HttpFoundation/Tests/ResponseFunctionalTest.php b/src/Symfony/Component/HttpFoundation/Tests/ResponseFunctionalTest.php index 5d1e81dcfc3aa..aca283af0023d 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/ResponseFunctionalTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/ResponseFunctionalTest.php @@ -44,6 +44,7 @@ public static function tearDownAfterClass(): void public function testCookie($fixture) { $result = file_get_contents(sprintf('http://localhost:8054/%s.php', $fixture)); + $result = preg_replace_callback('/expires=[^;]++/', function ($m) { return str_replace('-', ' ', $m[0]); }, $result); $this->assertStringMatchesFormatFile(__DIR__.sprintf('/Fixtures/response-functional/%s.expected', $fixture), $result); } diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/AbstractSessionHandlerTest.php b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/AbstractSessionHandlerTest.php index f6417720d27aa..aca2bfd882b20 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/AbstractSessionHandlerTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/AbstractSessionHandlerTest.php @@ -46,6 +46,7 @@ public function testSession($fixture) $context = ['http' => ['header' => "Cookie: sid=123abc\r\n"]]; $context = stream_context_create($context); $result = file_get_contents(sprintf('http://localhost:8053/%s.php', $fixture), false, $context); + $result = preg_replace_callback('/expires=[^;]++/', function ($m) { return str_replace('-', ' ', $m[0]); }, $result); $this->assertStringEqualsFile(__DIR__.sprintf('/Fixtures/%s.expected', $fixture), $result); } diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/Fixtures/empty_destroys.expected b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/Fixtures/empty_destroys.expected index 8203714740752..06a118888aba9 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/Fixtures/empty_destroys.expected +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/Fixtures/empty_destroys.expected @@ -12,6 +12,6 @@ Array ( [0] => Content-Type: text/plain; charset=utf-8 [1] => Cache-Control: max-age=10800, private, must-revalidate - [2] => Set-Cookie: sid=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; Max-Age=0; path=/; secure; HttpOnly + [2] => Set-Cookie: sid=deleted; expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/; secure; HttpOnly ) shutdown diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/Fixtures/storage.expected b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/Fixtures/storage.expected index 05a5d5d0b090f..549c6847f11da 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/Fixtures/storage.expected +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/Fixtures/storage.expected @@ -16,6 +16,6 @@ Array ( [0] => Content-Type: text/plain; charset=utf-8 [1] => Cache-Control: max-age=0, private, must-revalidate - [2] => Set-Cookie: sid=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; Max-Age=0; path=/; secure; HttpOnly + [2] => Set-Cookie: sid=deleted; expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/; secure; HttpOnly ) shutdown diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/Fixtures/with_cookie_and_session.expected b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/Fixtures/with_cookie_and_session.expected index 63078228df139..ac8ec061f0310 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/Fixtures/with_cookie_and_session.expected +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/Fixtures/with_cookie_and_session.expected @@ -20,6 +20,6 @@ Array [0] => Content-Type: text/plain; charset=utf-8 [1] => Cache-Control: max-age=10800, private, must-revalidate [2] => Set-Cookie: abc=def - [3] => Set-Cookie: sid=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; Max-Age=0; path=/; secure; HttpOnly + [3] => Set-Cookie: sid=deleted; expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/; secure; HttpOnly ) shutdown diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Proxy/SessionHandlerProxyTest.php b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Proxy/SessionHandlerProxyTest.php index 972a2745132e1..a4f45fec68708 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Proxy/SessionHandlerProxyTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Proxy/SessionHandlerProxyTest.php @@ -12,6 +12,8 @@ namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Proxy; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Session\Storage\Handler\StrictSessionHandler; +use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage; use Symfony\Component\HttpFoundation\Session\Storage\Proxy\SessionHandlerProxy; /** @@ -159,6 +161,23 @@ public function testUpdateTimestamp() $this->proxy->updateTimestamp('id', 'data'); } + + /** + * @dataProvider provideNativeSessionStorageHandler + */ + public function testNativeSessionStorageSaveHandlerName($handler) + { + $this->assertSame('files', (new NativeSessionStorage([], $handler))->getSaveHandler()->getSaveHandlerName()); + } + + public function provideNativeSessionStorageHandler() + { + return [ + [new \SessionHandler()], + [new StrictSessionHandler(new \SessionHandler())], + [new SessionHandlerProxy(new StrictSessionHandler(new \SessionHandler()))], + ]; + } } abstract class TestSessionHandler implements \SessionHandlerInterface, \SessionUpdateTimestampHandlerInterface diff --git a/src/Symfony/Component/HttpFoundation/composer.json b/src/Symfony/Component/HttpFoundation/composer.json index 8df36aa4d26e4..1b232eb5450cb 100644 --- a/src/Symfony/Component/HttpFoundation/composer.json +++ b/src/Symfony/Component/HttpFoundation/composer.json @@ -23,8 +23,11 @@ "require-dev": { "predis/predis": "~1.0", "symfony/cache": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4", "symfony/mime": "^5.4|^6.0", - "symfony/expression-language": "^5.4|^6.0" + "symfony/expression-language": "^5.4|^6.0", + "symfony/rate-limiter": "^5.2|^6.0" }, "suggest" : { "symfony/mime": "To use the file extension guesser" diff --git a/src/Symfony/Component/HttpKernel/DataCollector/LoggerDataCollector.php b/src/Symfony/Component/HttpKernel/DataCollector/LoggerDataCollector.php index 1460ec13dd068..bbc9321320ae8 100644 --- a/src/Symfony/Component/HttpKernel/DataCollector/LoggerDataCollector.php +++ b/src/Symfony/Component/HttpKernel/DataCollector/LoggerDataCollector.php @@ -144,7 +144,7 @@ public function getFilters() $allChannels = []; foreach ($this->getProcessedLogs() as $log) { - if ('' === trim($log['channel'])) { + if ('' === trim($log['channel'] ?? '')) { continue; } diff --git a/src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php b/src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php index 4573acab8eaac..05be006e1722a 100644 --- a/src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php +++ b/src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php @@ -153,6 +153,11 @@ public function onKernelResponse(ResponseEvent $event) $isSessionEmpty = ($session instanceof Session ? $session->isEmpty() : empty($session->all())) && empty($_SESSION); // checking $_SESSION to keep compatibility with native sessions if ($requestSessionCookieId && $isSessionEmpty) { + // PHP internally sets the session cookie value to "deleted" when setcookie() is called with empty string $value argument + // which happens in \Symfony\Component\HttpFoundation\Session\Storage\Handler\AbstractSessionHandler::destroy + // when the session gets invalidated (for example on logout) so we must handle this case here too + // otherwise we would send two Set-Cookie headers back with the response + SessionUtils::popSessionCookie($sessionName, 'deleted'); $response->headers->clearCookie( $sessionName, $sessionCookiePath, diff --git a/src/Symfony/Component/HttpKernel/HttpKernel.php b/src/Symfony/Component/HttpKernel/HttpKernel.php index a5177f18fb5a6..f84a39e5a2af4 100644 --- a/src/Symfony/Component/HttpKernel/HttpKernel.php +++ b/src/Symfony/Component/HttpKernel/HttpKernel.php @@ -70,6 +70,7 @@ public function handle(Request $request, int $type = HttpKernelInterface::MAIN_R { $request->headers->set('X-Php-Ob-Level', (string) ob_get_level()); + $this->requestStack->push($request); try { return $this->handleRaw($request, $type); } catch (\Exception $e) { @@ -83,6 +84,8 @@ public function handle(Request $request, int $type = HttpKernelInterface::MAIN_R } return $this->handleThrowable($e, $request, $type); + } finally { + $this->requestStack->pop(); } } @@ -121,8 +124,6 @@ public function terminateWithException(\Throwable $exception, Request $request = */ private function handleRaw(Request $request, int $type = self::MAIN_REQUEST): Response { - $this->requestStack->push($request); - // request $event = new RequestEvent($this, $request, $type); $this->dispatcher->dispatch($event, KernelEvents::REQUEST); @@ -199,7 +200,6 @@ private function filterResponse(Response $response, Request $request, int $type) private function finishRequest(Request $request, int $type) { $this->dispatcher->dispatch(new FinishRequestEvent($this, $request, $type), KernelEvents::FINISH_REQUEST); - $this->requestStack->pop(); } /** diff --git a/src/Symfony/Component/HttpKernel/Kernel.php b/src/Symfony/Component/HttpKernel/Kernel.php index 29c7aac0811ae..ec49d1a018616 100644 --- a/src/Symfony/Component/HttpKernel/Kernel.php +++ b/src/Symfony/Component/HttpKernel/Kernel.php @@ -78,11 +78,11 @@ abstract class Kernel implements KernelInterface, RebootableInterface, Terminabl */ private static array $freshCache = []; - public const VERSION = '6.1.3'; - public const VERSION_ID = 60103; + public const VERSION = '6.1.4'; + public const VERSION_ID = 60104; public const MAJOR_VERSION = 6; public const MINOR_VERSION = 1; - public const RELEASE_VERSION = 3; + public const RELEASE_VERSION = 4; public const EXTRA_VERSION = ''; public const END_OF_MAINTENANCE = '01/2023'; diff --git a/src/Symfony/Component/HttpKernel/Tests/HttpKernelTest.php b/src/Symfony/Component/HttpKernel/Tests/HttpKernelTest.php index 91636ba86ca6a..182b524e98bcf 100644 --- a/src/Symfony/Component/HttpKernel/Tests/HttpKernelTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/HttpKernelTest.php @@ -40,6 +40,45 @@ public function testHandleWhenControllerThrowsAnExceptionAndCatchIsTrue() $kernel->handle(new Request(), HttpKernelInterface::MAIN_REQUEST, true); } + public function testRequestStackIsNotBrokenWhenControllerThrowsAnExceptionAndCatchIsTrue() + { + $requestStack = new RequestStack(); + $kernel = $this->getHttpKernel(new EventDispatcher(), function () { throw new \RuntimeException(); }, $requestStack); + + try { + $kernel->handle(new Request(), HttpKernelInterface::MASTER_REQUEST, true); + } catch (\Throwable $exception) { + } + + self::assertNull($requestStack->getCurrentRequest()); + } + + public function testRequestStackIsNotBrokenWhenControllerThrowsAnExceptionAndCatchIsFalse() + { + $requestStack = new RequestStack(); + $kernel = $this->getHttpKernel(new EventDispatcher(), function () { throw new \RuntimeException(); }, $requestStack); + + try { + $kernel->handle(new Request(), HttpKernelInterface::MASTER_REQUEST, false); + } catch (\Throwable $exception) { + } + + self::assertNull($requestStack->getCurrentRequest()); + } + + public function testRequestStackIsNotBrokenWhenControllerThrowsAnThrowable() + { + $requestStack = new RequestStack(); + $kernel = $this->getHttpKernel(new EventDispatcher(), function () { throw new \Error(); }, $requestStack); + + try { + $kernel->handle(new Request(), HttpKernelInterface::MASTER_REQUEST, true); + } catch (\Throwable $exception) { + } + + self::assertNull($requestStack->getCurrentRequest()); + } + public function testHandleWhenControllerThrowsAnExceptionAndCatchIsFalseAndNoListenerIsRegistered() { $this->expectException(\RuntimeException::class); diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php b/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php index 9a5b214590047..61b3a0e157b17 100644 --- a/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php +++ b/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php @@ -114,8 +114,16 @@ private function doEhloCommand(): string { try { $response = $this->executeCommand(sprintf("EHLO %s\r\n", $this->getLocalDomain()), [250]); - } catch (TransportExceptionInterface) { - return parent::executeCommand(sprintf("HELO %s\r\n", $this->getLocalDomain()), [250]); + } catch (TransportExceptionInterface $e) { + try { + return parent::executeCommand(sprintf("HELO %s\r\n", $this->getLocalDomain()), [250]); + } catch (TransportExceptionInterface $ex) { + if (!$ex->getCode()) { + throw $e; + } + + throw $ex; + } } $this->capabilities = $this->parseCapabilities($response); @@ -132,12 +140,8 @@ private function doEhloCommand(): string throw new TransportException('Unable to connect with STARTTLS.'); } - try { - $response = $this->executeCommand(sprintf("EHLO %s\r\n", $this->getLocalDomain()), [250]); - $this->capabilities = $this->parseCapabilities($response); - } catch (TransportExceptionInterface) { - return parent::executeCommand(sprintf("HELO %s\r\n", $this->getLocalDomain()), [250]); - } + $response = $this->executeCommand(sprintf("EHLO %s\r\n", $this->getLocalDomain()), [250]); + $this->capabilities = $this->parseCapabilities($response); } if (\array_key_exists('AUTH', $this->capabilities)) { diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php b/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php index 9432be7f3645f..605bddbf83e07 100644 --- a/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php +++ b/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php @@ -306,15 +306,13 @@ private function assertResponseCode(string $response, array $codes): void throw new LogicException('You must set the expected response code.'); } - if (!$response) { - throw new TransportException(sprintf('Expected response code "%s" but got an empty response.', implode('/', $codes))); - } - [$code] = sscanf($response, '%3d'); $valid = \in_array($code, $codes); - if (!$valid) { - throw new TransportException(sprintf('Expected response code "%s" but got code "%s", with message "%s".', implode('/', $codes), $code, trim($response)), $code); + if (!$valid || !$response) { + $codeStr = $code ? sprintf('code "%s"', $code) : 'empty code'; + $responseStr = $response ? sprintf(', with message "%s"', trim($response)) : ''; + throw new TransportException(sprintf('Expected response code "%s" but got ', implode('/', $codes), $codeStr).$codeStr.$responseStr.'.', $code); } } diff --git a/src/Symfony/Component/Messenger/Bridge/Doctrine/Tests/Transport/PostgreSqlConnectionTest.php b/src/Symfony/Component/Messenger/Bridge/Doctrine/Tests/Transport/PostgreSqlConnectionTest.php index f1ffffbb5687a..e8e00d97b3876 100644 --- a/src/Symfony/Component/Messenger/Bridge/Doctrine/Tests/Transport/PostgreSqlConnectionTest.php +++ b/src/Symfony/Component/Messenger/Bridge/Doctrine/Tests/Transport/PostgreSqlConnectionTest.php @@ -11,6 +11,11 @@ namespace Symfony\Component\Messenger\Bridge\Doctrine\Tests\Transport; +use Doctrine\DBAL\Cache\ArrayResult; +use Doctrine\DBAL\Cache\ArrayStatement; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Query\QueryBuilder; +use Doctrine\DBAL\Result; use Doctrine\DBAL\Schema\Table; use PHPUnit\Framework\TestCase; use Symfony\Component\Messenger\Bridge\Doctrine\Transport\PostgreSqlConnection; @@ -42,6 +47,68 @@ public function testUnserialize() $connection->__wakeup(); } + public function testListenOnConnection() + { + $driverConnection = $this->createMock(\Doctrine\DBAL\Connection::class); + + $driverConnection + ->expects(self::any()) + ->method('getDatabasePlatform') + ->willReturn(new PostgreSQLPlatform()); + + $driverConnection + ->expects(self::any()) + ->method('createQueryBuilder') + ->willReturn(new QueryBuilder($driverConnection)); + + $wrappedConnection = new class() { + private $notifyCalls = 0; + + public function pgsqlGetNotify() + { + ++$this->notifyCalls; + + return false; + } + + public function countNotifyCalls() + { + return $this->notifyCalls; + } + }; + + // dbal 2.x + if (interface_exists(Result::class)) { + $driverConnection + ->expects(self::exactly(2)) + ->method('getWrappedConnection') + ->willReturn($wrappedConnection); + + $driverConnection + ->expects(self::any()) + ->method('executeQuery') + ->willReturn(new ArrayStatement([])); + } else { + // dbal 3.x + $driverConnection + ->expects(self::exactly(2)) + ->method('getNativeConnection') + ->willReturn($wrappedConnection); + + $driverConnection + ->expects(self::any()) + ->method('executeQuery') + ->willReturn(new Result(new ArrayResult([]), $driverConnection)); + } + $connection = new PostgreSqlConnection(['table_name' => 'queue_table'], $driverConnection); + + $connection->get(); // first time we have queueEmptiedAt === null, fallback on the parent implementation + $connection->get(); + $connection->get(); + + $this->assertSame(2, $wrappedConnection->countNotifyCalls()); + } + public function testGetExtraSetupSql() { $driverConnection = $this->createMock(\Doctrine\DBAL\Connection::class); diff --git a/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/Connection.php b/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/Connection.php index 8592d112c639a..e7bf744d3100e 100644 --- a/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/Connection.php +++ b/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/Connection.php @@ -158,7 +158,7 @@ public function get(): ?array { if ($this->driverConnection->getDatabasePlatform() instanceof MySQLPlatform) { try { - $this->driverConnection->delete($this->configuration['table_name'], ['delivered_at' => '9999-12-31']); + $this->driverConnection->delete($this->configuration['table_name'], ['delivered_at' => '9999-12-31 23:59:59']); } catch (DriverException $e) { // Ignore the exception } @@ -252,7 +252,7 @@ public function ack(string $id): bool { try { if ($this->driverConnection->getDatabasePlatform() instanceof MySQLPlatform) { - return $this->driverConnection->update($this->configuration['table_name'], ['delivered_at' => '9999-12-31'], ['id' => $id]) > 0; + return $this->driverConnection->update($this->configuration['table_name'], ['delivered_at' => '9999-12-31 23:59:59'], ['id' => $id]) > 0; } return $this->driverConnection->delete($this->configuration['table_name'], ['id' => $id]) > 0; @@ -265,7 +265,7 @@ public function reject(string $id): bool { try { if ($this->driverConnection->getDatabasePlatform() instanceof MySQLPlatform) { - return $this->driverConnection->update($this->configuration['table_name'], ['delivered_at' => '9999-12-31'], ['id' => $id]) > 0; + return $this->driverConnection->update($this->configuration['table_name'], ['delivered_at' => '9999-12-31 23:59:59'], ['id' => $id]) > 0; } return $this->driverConnection->delete($this->configuration['table_name'], ['id' => $id]) > 0; diff --git a/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/PostgreSqlConnection.php b/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/PostgreSqlConnection.php index fbfe3ef618653..3691a9383f293 100644 --- a/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/PostgreSqlConnection.php +++ b/src/Symfony/Component/Messenger/Bridge/Doctrine/Transport/PostgreSqlConnection.php @@ -33,8 +33,6 @@ final class PostgreSqlConnection extends Connection 'get_notify_timeout' => 0, ]; - private bool $listening = false; - public function __sleep(): array { throw new \BadMethodCallException('Cannot serialize '.__CLASS__); @@ -62,12 +60,9 @@ public function get(): ?array return parent::get(); } - if (!$this->listening) { - // This is secure because the table name must be a valid identifier: - // https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS - $this->executeStatement(sprintf('LISTEN "%s"', $this->configuration['table_name'])); - $this->listening = true; - } + // This is secure because the table name must be a valid identifier: + // https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS + $this->executeStatement(sprintf('LISTEN "%s"', $this->configuration['table_name'])); if (method_exists($this->driverConnection, 'getNativeConnection')) { $wrappedConnection = $this->driverConnection->getNativeConnection(); @@ -150,11 +145,6 @@ private function createTriggerFunctionName(): string private function unlisten() { - if (!$this->listening) { - return; - } - $this->executeStatement(sprintf('UNLISTEN "%s"', $this->configuration['table_name'])); - $this->listening = false; } } diff --git a/src/Symfony/Component/Mime/Email.php b/src/Symfony/Component/Mime/Email.php index 2aa63f7d01ab8..15f1918ffb24b 100644 --- a/src/Symfony/Component/Mime/Email.php +++ b/src/Symfony/Component/Mime/Email.php @@ -490,8 +490,8 @@ private function prepareParts(): ?array $html = $htmlPart->getBody(); $regexes = [ - ']*src\s*=\s*(?:([\'"])cid:([^"]+)\\1|cid:([^>\s]+))', - '<\w+\s+[^>]*background\s*=\s*(?:([\'"])cid:([^"]+)\\1|cid:([^>\s]+))', + ']*src\s*=\s*(?:([\'"])cid:(.+?)\\1|cid:([^>\s]+))', + '<\w+\s+[^>]*background\s*=\s*(?:([\'"])cid:(.+?)\\1|cid:([^>\s]+))', ]; $tmpMatches = []; foreach ($regexes as $regex) { diff --git a/src/Symfony/Component/Notifier/Bridge/FakeChat/Tests/FakeChatEmailTransportTest.php b/src/Symfony/Component/Notifier/Bridge/FakeChat/Tests/FakeChatEmailTransportTest.php index b102927fb8db2..74d9c167f94b8 100644 --- a/src/Symfony/Component/Notifier/Bridge/FakeChat/Tests/FakeChatEmailTransportTest.php +++ b/src/Symfony/Component/Notifier/Bridge/FakeChat/Tests/FakeChatEmailTransportTest.php @@ -119,7 +119,7 @@ public function testSendWithCustomTransportAndWithRecipient() $this->assertSame(sprintf('New Chat message for recipient: %s', $recipient), $sentEmail->getSubject()); $this->assertSame($subject, $sentEmail->getTextBody()); $this->assertTrue($sentEmail->getHeaders()->has('X-Transport')); - $this->assertSame($transportName, $sentEmail->getHeaders()->get('X-Transport')->getBodyAsString()); + $this->assertSame($transportName, $sentEmail->getHeaders()->get('X-Transport')->getBody()); } public function testSendWithCustomTransportAndWithoutRecipient() @@ -143,6 +143,6 @@ public function testSendWithCustomTransportAndWithoutRecipient() $this->assertSame('New Chat message without specified recipient!', $sentEmail->getSubject()); $this->assertSame($subject, $sentEmail->getTextBody()); $this->assertTrue($sentEmail->getHeaders()->has('X-Transport')); - $this->assertSame($transportName, $sentEmail->getHeaders()->get('X-Transport')->getBodyAsString()); + $this->assertSame($transportName, $sentEmail->getHeaders()->get('X-Transport')->getBody()); } } diff --git a/src/Symfony/Component/Notifier/Bridge/FakeSms/Tests/FakeSmsEmailTransportTest.php b/src/Symfony/Component/Notifier/Bridge/FakeSms/Tests/FakeSmsEmailTransportTest.php index 3b0e86e3ef613..f5efc9573dac0 100644 --- a/src/Symfony/Component/Notifier/Bridge/FakeSms/Tests/FakeSmsEmailTransportTest.php +++ b/src/Symfony/Component/Notifier/Bridge/FakeSms/Tests/FakeSmsEmailTransportTest.php @@ -96,6 +96,6 @@ public function testSendWithCustomTransport() $this->assertSame(sprintf('New SMS on phone number: %s', $phone), $sentEmail->getSubject()); $this->assertSame($subject, $sentEmail->getTextBody()); $this->assertTrue($sentEmail->getHeaders()->has('X-Transport')); - $this->assertSame($transportName, $sentEmail->getHeaders()->get('X-Transport')->getBodyAsString()); + $this->assertSame($transportName, $sentEmail->getHeaders()->get('X-Transport')->getBody()); } } diff --git a/src/Symfony/Component/Security/Core/Authentication/Token/AbstractToken.php b/src/Symfony/Component/Security/Core/Authentication/Token/AbstractToken.php index 92df675f98c3f..6b0a737824a67 100644 --- a/src/Symfony/Component/Security/Core/Authentication/Token/AbstractToken.php +++ b/src/Symfony/Component/Security/Core/Authentication/Token/AbstractToken.php @@ -48,7 +48,7 @@ public function getRoleNames(): array public function getUserIdentifier(): string { - return $this->user->getUserIdentifier(); + return $this->user ? $this->user->getUserIdentifier() : ''; } /** diff --git a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php index 1594c1b38cab9..b2b131d08a63a 100644 --- a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php +++ b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php @@ -40,8 +40,6 @@ public function __construct(TokenStorageInterface $tokenStorage, AccessDecisionM /** * {@inheritdoc} - * - * @throws AuthenticationCredentialsNotFoundException when the token storage has no authentication token and $exceptionOnNoToken is set to true */ final public function isGranted(mixed $attribute, mixed $subject = null): bool { diff --git a/src/Symfony/Component/Security/Http/RememberMe/RememberMeDetails.php b/src/Symfony/Component/Security/Http/RememberMe/RememberMeDetails.php index c17e9e4adc6c9..7945b417fdf49 100644 --- a/src/Symfony/Component/Security/Http/RememberMe/RememberMeDetails.php +++ b/src/Symfony/Component/Security/Http/RememberMe/RememberMeDetails.php @@ -37,12 +37,12 @@ public function __construct(string $userFqcn, string $userIdentifier, int $expir public static function fromRawCookie(string $rawCookie): self { $cookieParts = explode(self::COOKIE_DELIMITER, base64_decode($rawCookie), 4); - if (false === $cookieParts[1] = base64_decode($cookieParts[1], true)) { - throw new AuthenticationException('The user identifier contains a character from outside the base64 alphabet.'); - } if (4 !== \count($cookieParts)) { throw new AuthenticationException('The cookie contains invalid data.'); } + if (false === $cookieParts[1] = base64_decode($cookieParts[1], true)) { + throw new AuthenticationException('The user identifier contains a character from outside the base64 alphabet.'); + } return new static(...$cookieParts); } diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/RememberMeAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/RememberMeAuthenticatorTest.php index 406d48c164add..c7492a95a464f 100644 --- a/src/Symfony/Component/Security/Http/Tests/Authenticator/RememberMeAuthenticatorTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/RememberMeAuthenticatorTest.php @@ -89,4 +89,12 @@ public function testAuthenticateWithoutOldToken() $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => base64_encode('foo:bar')]); $this->authenticator->authenticate($request); } + + public function testAuthenticateWithTokenWithoutDelimiter() + { + $this->expectException(AuthenticationException::class); + + $request = Request::create('/', 'GET', [], ['_remember_me_cookie' => 'invalid']); + $this->authenticator->authenticate($request); + } } diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index c2b3ebfb3c6c2..0a475a0eafb9c 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -9,8 +9,6 @@ CHANGELOG * Set `Context` annotation as not final * Deprecate `ContextAwareNormalizerInterface`, use `NormalizerInterface` instead * Deprecate `ContextAwareDenormalizerInterface`, use `DenormalizerInterface` instead - * Deprecate `ContextAwareEncoderInterface`, use `EncoderInterface` instead - * Deprecate `ContextAwareDecoderInterface`, use `DecoderInterface` instead * Deprecate supporting denormalization for `AbstractUid` in `UidNormalizer`, use one of `AbstractUid` child class instead * Deprecate denormalizing to an abstract class in `UidNormalizer` * Add support for `can*()` methods to `ObjectNormalizer` diff --git a/src/Symfony/Component/Serializer/Encoder/ChainDecoder.php b/src/Symfony/Component/Serializer/Encoder/ChainDecoder.php index 0f7f94fad8842..7a01938ab4d52 100644 --- a/src/Symfony/Component/Serializer/Encoder/ChainDecoder.php +++ b/src/Symfony/Component/Serializer/Encoder/ChainDecoder.php @@ -67,9 +67,13 @@ private function getDecoder(string $format, array $context): DecoderInterface return $this->decoders[$this->decoderByFormat[$format]]; } + $cache = true; foreach ($this->decoders as $i => $decoder) { + $cache = $cache && !$decoder instanceof ContextAwareDecoderInterface; if ($decoder->supportsDecoding($format, $context)) { - $this->decoderByFormat[$format] = $i; + if ($cache) { + $this->decoderByFormat[$format] = $i; + } return $decoder; } diff --git a/src/Symfony/Component/Serializer/Encoder/ChainEncoder.php b/src/Symfony/Component/Serializer/Encoder/ChainEncoder.php index 70d7c9fd10645..061a9cf748bd8 100644 --- a/src/Symfony/Component/Serializer/Encoder/ChainEncoder.php +++ b/src/Symfony/Component/Serializer/Encoder/ChainEncoder.php @@ -90,9 +90,13 @@ private function getEncoder(string $format, array $context): EncoderInterface return $this->encoders[$this->encoderByFormat[$format]]; } + $cache = true; foreach ($this->encoders as $i => $encoder) { + $cache = $cache && !$encoder instanceof ContextAwareEncoderInterface; if ($encoder->supportsEncoding($format, $context)) { - $this->encoderByFormat[$format] = $i; + if ($cache) { + $this->encoderByFormat[$format] = $i; + } return $encoder; } diff --git a/src/Symfony/Component/Serializer/Encoder/ContextAwareDecoderInterface.php b/src/Symfony/Component/Serializer/Encoder/ContextAwareDecoderInterface.php index 910b26bac1fc8..6ac2e38cc4657 100644 --- a/src/Symfony/Component/Serializer/Encoder/ContextAwareDecoderInterface.php +++ b/src/Symfony/Component/Serializer/Encoder/ContextAwareDecoderInterface.php @@ -15,8 +15,6 @@ * Adds the support of an extra $context parameter for the supportsDecoding method. * * @author Kévin Dunglas - * - * @deprecated since symfony/serializer 6.1, use DecoderInterface instead */ interface ContextAwareDecoderInterface extends DecoderInterface { diff --git a/src/Symfony/Component/Serializer/Encoder/ContextAwareEncoderInterface.php b/src/Symfony/Component/Serializer/Encoder/ContextAwareEncoderInterface.php index f828f87a4f82f..832b600eeca57 100644 --- a/src/Symfony/Component/Serializer/Encoder/ContextAwareEncoderInterface.php +++ b/src/Symfony/Component/Serializer/Encoder/ContextAwareEncoderInterface.php @@ -15,8 +15,6 @@ * Adds the support of an extra $context parameter for the supportsEncoding method. * * @author Kévin Dunglas - * - * @deprecated since symfony/serializer 6.1, use EncoderInterface instead */ interface ContextAwareEncoderInterface extends EncoderInterface { diff --git a/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php b/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php index b2c6fcd81d4ae..a3733a53dee24 100644 --- a/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php +++ b/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php @@ -124,10 +124,8 @@ public function encode(mixed $data, string $format, array $context = []): string /** * {@inheritdoc} - * - * @param array $context */ - public function supportsEncoding(string $format /* , array $context = [] */): bool + public function supportsEncoding(string $format): bool { return self::FORMAT === $format; } @@ -212,10 +210,8 @@ public function decode(string $data, string $format, array $context = []): mixed /** * {@inheritdoc} - * - * @param array $context */ - public function supportsDecoding(string $format /* , array $context = [] */): bool + public function supportsDecoding(string $format): bool { return self::FORMAT === $format; } diff --git a/src/Symfony/Component/Serializer/Encoder/DecoderInterface.php b/src/Symfony/Component/Serializer/Encoder/DecoderInterface.php index 5014b9bd514ab..84a84ad1f3e69 100644 --- a/src/Symfony/Component/Serializer/Encoder/DecoderInterface.php +++ b/src/Symfony/Component/Serializer/Encoder/DecoderInterface.php @@ -39,10 +39,9 @@ public function decode(string $data, string $format, array $context = []); /** * Checks whether the deserializer can decode from given format. * - * @param string $format Format name - * @param array $context Options that decoders have access to + * @param string $format Format name * * @return bool */ - public function supportsDecoding(string $format /* , array $context = [] */); + public function supportsDecoding(string $format); } diff --git a/src/Symfony/Component/Serializer/Encoder/EncoderInterface.php b/src/Symfony/Component/Serializer/Encoder/EncoderInterface.php index c913ac3fb14ad..e0f303b1e3dcd 100644 --- a/src/Symfony/Component/Serializer/Encoder/EncoderInterface.php +++ b/src/Symfony/Component/Serializer/Encoder/EncoderInterface.php @@ -32,8 +32,7 @@ public function encode(mixed $data, string $format, array $context = []): string /** * Checks whether the serializer can encode to given format. * - * @param string $format Format name - * @param array $context Options that normalizers/encoders have access to + * @param string $format Format name */ - public function supportsEncoding(string $format /* , array $context = [] */): bool; + public function supportsEncoding(string $format): bool; } diff --git a/src/Symfony/Component/Serializer/Encoder/JsonDecode.php b/src/Symfony/Component/Serializer/Encoder/JsonDecode.php index 50d2d2e3f266f..ad094afaca161 100644 --- a/src/Symfony/Component/Serializer/Encoder/JsonDecode.php +++ b/src/Symfony/Component/Serializer/Encoder/JsonDecode.php @@ -95,10 +95,8 @@ public function decode(string $data, string $format, array $context = []): mixed /** * {@inheritdoc} - * - * @param array $context */ - public function supportsDecoding(string $format /* , array $context = [] */): bool + public function supportsDecoding(string $format): bool { return JsonEncoder::FORMAT === $format; } diff --git a/src/Symfony/Component/Serializer/Encoder/JsonEncode.php b/src/Symfony/Component/Serializer/Encoder/JsonEncode.php index 86baf99994eb6..23d0fdd960e3e 100644 --- a/src/Symfony/Component/Serializer/Encoder/JsonEncode.php +++ b/src/Symfony/Component/Serializer/Encoder/JsonEncode.php @@ -57,10 +57,8 @@ public function encode(mixed $data, string $format, array $context = []): string /** * {@inheritdoc} - * - * @param array $context */ - public function supportsEncoding(string $format /* , array $context = [] */): bool + public function supportsEncoding(string $format): bool { return JsonEncoder::FORMAT === $format; } diff --git a/src/Symfony/Component/Serializer/Encoder/JsonEncoder.php b/src/Symfony/Component/Serializer/Encoder/JsonEncoder.php index e6ccbfba50b2b..d17ef049285ef 100644 --- a/src/Symfony/Component/Serializer/Encoder/JsonEncoder.php +++ b/src/Symfony/Component/Serializer/Encoder/JsonEncoder.php @@ -47,20 +47,16 @@ public function decode(string $data, string $format, array $context = []): mixed /** * {@inheritdoc} - * - * @param array $context */ - public function supportsEncoding(string $format /* , array $context = [] */): bool + public function supportsEncoding(string $format): bool { return self::FORMAT === $format; } /** * {@inheritdoc} - * - * @param array $context */ - public function supportsDecoding(string $format /* , array $context = [] */): bool + public function supportsDecoding(string $format): bool { return self::FORMAT === $format; } diff --git a/src/Symfony/Component/Serializer/Encoder/XmlEncoder.php b/src/Symfony/Component/Serializer/Encoder/XmlEncoder.php index a47e6d69583f3..2474f4439a443 100644 --- a/src/Symfony/Component/Serializer/Encoder/XmlEncoder.php +++ b/src/Symfony/Component/Serializer/Encoder/XmlEncoder.php @@ -166,20 +166,16 @@ public function decode(string $data, string $format, array $context = []): mixed /** * {@inheritdoc} - * - * @param array $context */ - public function supportsEncoding(string $format /* , array $context = [] */): bool + public function supportsEncoding(string $format): bool { return self::FORMAT === $format; } /** * {@inheritdoc} - * - * @param array $context */ - public function supportsDecoding(string $format /* , array $context = [] */): bool + public function supportsDecoding(string $format): bool { return self::FORMAT === $format; } diff --git a/src/Symfony/Component/Serializer/Encoder/YamlEncoder.php b/src/Symfony/Component/Serializer/Encoder/YamlEncoder.php index ecb9815eee553..51f600786aa3b 100644 --- a/src/Symfony/Component/Serializer/Encoder/YamlEncoder.php +++ b/src/Symfony/Component/Serializer/Encoder/YamlEncoder.php @@ -67,10 +67,8 @@ public function encode(mixed $data, string $format, array $context = []): string /** * {@inheritdoc} - * - * @param array $context */ - public function supportsEncoding(string $format /* , array $context = [] */): bool + public function supportsEncoding(string $format): bool { return self::FORMAT === $format || self::ALTERNATIVE_FORMAT === $format; } @@ -87,10 +85,8 @@ public function decode(string $data, string $format, array $context = []): mixed /** * {@inheritdoc} - * - * @param array $context */ - public function supportsDecoding(string $format /* , array $context = [] */): bool + public function supportsDecoding(string $format): bool { return self::FORMAT === $format || self::ALTERNATIVE_FORMAT === $format; } diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php index f6c196f385717..0e534a4809f76 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php +++ b/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php @@ -100,8 +100,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool continue; } - $getAccessor = preg_match('/^(get|)(.+)$/i', $method->name); - if ($getAccessor && 0 !== $method->getNumberOfRequiredParameters()) { + if (0 === stripos($method->name, 'get') && $method->getNumberOfRequiredParameters()) { continue; /* matches the BC behavior in `Symfony\Component\Serializer\Normalizer\ObjectNormalizer::extractAttributes` */ } diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php index 44ba45f581a19..aa5e808179b3a 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php @@ -387,7 +387,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex } $exception = NotNormalizableValueException::createForUnexpectedDataType( - sprintf('Failed to create object because the object miss the "%s" property.', $constructorParameter->name), + sprintf('Failed to create object because the class misses the "%s" property.', $constructorParameter->name), $data, ['unknown'], $context['deserialization_path'] ?? null, diff --git a/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php b/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php index a212704b7c596..8fa797487a456 100644 --- a/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Serializer\Normalizer; +use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\BadMethodCallException; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -37,7 +38,7 @@ public function denormalize(mixed $data, string $type, string $format = null, ar throw new BadMethodCallException('Please set a denormalizer before calling denormalize()!'); } if (!\is_array($data)) { - throw new InvalidArgumentException('Data expected to be an array, '.get_debug_type($data).' given.'); + throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Data expected to be "%s", "%s" given.', $type, get_debug_type($data)), $data, [Type::BUILTIN_TYPE_ARRAY], $context['deserialization_path'] ?? null); } if (!str_ends_with($type, '[]')) { throw new InvalidArgumentException('Unsupported class: '.$type); diff --git a/src/Symfony/Component/Serializer/Normalizer/BackedEnumNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/BackedEnumNormalizer.php index 859a09362d3f0..8aa242d67d5df 100644 --- a/src/Symfony/Component/Serializer/Normalizer/BackedEnumNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/BackedEnumNormalizer.php @@ -25,7 +25,7 @@ final class BackedEnumNormalizer implements NormalizerInterface, DenormalizerInt /** * {@inheritdoc} */ - public function normalize($object, $format = null, array $context = []): int|string + public function normalize(mixed $object, string $format = null, array $context = []): int|string { if (!$object instanceof \BackedEnum) { throw new InvalidArgumentException('The data must belong to a backed enumeration.'); @@ -37,7 +37,7 @@ public function normalize($object, $format = null, array $context = []): int|str /** * {@inheritdoc} */ - public function supportsNormalization($data, $format = null, array $context = []): bool + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { return $data instanceof \BackedEnum; } @@ -47,7 +47,7 @@ public function supportsNormalization($data, $format = null, array $context = [] * * @throws NotNormalizableValueException */ - public function denormalize($data, $type, $format = null, array $context = []): mixed + public function denormalize(mixed $data, string $type, string $format = null, array $context = []): mixed { if (!is_subclass_of($type, \BackedEnum::class)) { throw new InvalidArgumentException('The data must belong to a backed enumeration.'); @@ -60,14 +60,14 @@ public function denormalize($data, $type, $format = null, array $context = []): try { return $type::from($data); } catch (\ValueError $e) { - throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); + throw new InvalidArgumentException('The data must belong to a backed enumeration of type '.$type); } } /** * {@inheritdoc} */ - public function supportsDenormalization($data, $type, $format = null, array $context = []): bool + public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool { return is_subclass_of($type, \BackedEnum::class); } diff --git a/src/Symfony/Component/Serializer/Tests/Encoder/ChainDecoderTest.php b/src/Symfony/Component/Serializer/Tests/Encoder/ChainDecoderTest.php index 5cac8d99a5270..8f433ce0fa15a 100644 --- a/src/Symfony/Component/Serializer/Tests/Encoder/ChainDecoderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Encoder/ChainDecoderTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Encoder\ChainDecoder; +use Symfony\Component\Serializer\Encoder\ContextAwareDecoderInterface; use Symfony\Component\Serializer\Encoder\DecoderInterface; use Symfony\Component\Serializer\Exception\RuntimeException; @@ -28,7 +29,7 @@ class ChainDecoderTest extends TestCase protected function setUp(): void { - $this->decoder1 = $this->createMock(DecoderInterface::class); + $this->decoder1 = $this->createMock(ContextAwareDecoderInterface::class); $this->decoder1 ->method('supportsDecoding') ->willReturnMap([ @@ -36,6 +37,7 @@ protected function setUp(): void [self::FORMAT_2, [], false], [self::FORMAT_3, [], false], [self::FORMAT_3, ['foo' => 'bar'], true], + [self::FORMAT_3, ['foo' => 'bar2'], false], ]); $this->decoder2 = $this->createMock(DecoderInterface::class); @@ -45,6 +47,8 @@ protected function setUp(): void [self::FORMAT_1, [], false], [self::FORMAT_2, [], true], [self::FORMAT_3, [], false], + [self::FORMAT_3, ['foo' => 'bar'], false], + [self::FORMAT_3, ['foo' => 'bar2'], true], ]); $this->chainDecoder = new ChainDecoder([$this->decoder1, $this->decoder2]); @@ -52,10 +56,26 @@ protected function setUp(): void public function testSupportsDecoding() { + $this->decoder1 + ->method('decode') + ->willReturn('result1'); + $this->decoder2 + ->method('decode') + ->willReturn('result2'); + $this->assertTrue($this->chainDecoder->supportsDecoding(self::FORMAT_1)); + $this->assertEquals('result1', $this->chainDecoder->decode('', self::FORMAT_1, [])); + $this->assertTrue($this->chainDecoder->supportsDecoding(self::FORMAT_2)); + $this->assertEquals('result2', $this->chainDecoder->decode('', self::FORMAT_2, [])); + $this->assertFalse($this->chainDecoder->supportsDecoding(self::FORMAT_3)); + $this->assertTrue($this->chainDecoder->supportsDecoding(self::FORMAT_3, ['foo' => 'bar'])); + $this->assertEquals('result1', $this->chainDecoder->decode('', self::FORMAT_3, ['foo' => 'bar'])); + + $this->assertTrue($this->chainDecoder->supportsDecoding(self::FORMAT_3, ['foo' => 'bar2'])); + $this->assertEquals('result2', $this->chainDecoder->decode('', self::FORMAT_3, ['foo' => 'bar2'])); } public function testDecode() diff --git a/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php b/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php index 848087145bafe..0afd67813435b 100644 --- a/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Debug\TraceableEncoder; use Symfony\Component\Serializer\Encoder\ChainEncoder; +use Symfony\Component\Serializer\Encoder\ContextAwareEncoderInterface; use Symfony\Component\Serializer\Encoder\EncoderInterface; use Symfony\Component\Serializer\Encoder\NormalizationAwareInterface; use Symfony\Component\Serializer\Exception\RuntimeException; @@ -30,7 +31,7 @@ class ChainEncoderTest extends TestCase protected function setUp(): void { - $this->encoder1 = $this->createMock(EncoderInterface::class); + $this->encoder1 = $this->createMock(ContextAwareEncoderInterface::class); $this->encoder1 ->method('supportsEncoding') ->willReturnMap([ @@ -38,6 +39,7 @@ protected function setUp(): void [self::FORMAT_2, [], false], [self::FORMAT_3, [], false], [self::FORMAT_3, ['foo' => 'bar'], true], + [self::FORMAT_3, ['foo' => 'bar2'], false], ]); $this->encoder2 = $this->createMock(EncoderInterface::class); @@ -47,6 +49,8 @@ protected function setUp(): void [self::FORMAT_1, [], false], [self::FORMAT_2, [], true], [self::FORMAT_3, [], false], + [self::FORMAT_3, ['foo' => 'bar'], false], + [self::FORMAT_3, ['foo' => 'bar2'], true], ]); $this->chainEncoder = new ChainEncoder([$this->encoder1, $this->encoder2]); @@ -54,10 +58,26 @@ protected function setUp(): void public function testSupportsEncoding() { + $this->encoder1 + ->method('encode') + ->willReturn('result1'); + $this->encoder2 + ->method('encode') + ->willReturn('result2'); + $this->assertTrue($this->chainEncoder->supportsEncoding(self::FORMAT_1)); + $this->assertEquals('result1', $this->chainEncoder->encode('', self::FORMAT_1, [])); + $this->assertTrue($this->chainEncoder->supportsEncoding(self::FORMAT_2)); + $this->assertEquals('result2', $this->chainEncoder->encode('', self::FORMAT_2, [])); + $this->assertFalse($this->chainEncoder->supportsEncoding(self::FORMAT_3)); + $this->assertTrue($this->chainEncoder->supportsEncoding(self::FORMAT_3, ['foo' => 'bar'])); + $this->assertEquals('result1', $this->chainEncoder->encode('', self::FORMAT_3, ['foo' => 'bar'])); + + $this->assertTrue($this->chainEncoder->supportsEncoding(self::FORMAT_3, ['foo' => 'bar2'])); + $this->assertEquals('result2', $this->chainEncoder->encode('', self::FORMAT_3, ['foo' => 'bar2'])); } public function testEncode() @@ -106,7 +126,7 @@ public function testNeedsNormalizationTraceableEncoder() class NormalizationAwareEncoder implements EncoderInterface, NormalizationAwareInterface { - public function supportsEncoding(string $format, array $context = []): bool + public function supportsEncoding(string $format): bool { return true; } diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetter.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetter.php index a2fe769e36b8c..326a9cd07589e 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetter.php +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetter.php @@ -20,4 +20,8 @@ public function getExtraValue(string $parameter) { return $parameter; } + + public function setExtraValue2(string $parameter) + { + } } diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php index 11094dad012e6..2b717c93a9752 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php @@ -15,4 +15,8 @@ public function getExtraValue(string $parameter) { return $parameter; } + + public function setExtraValue2(string $parameter) + { + } } diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetter.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetter.php index cec21db4be663..274479e63b5b3 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetter.php +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetter.php @@ -18,4 +18,8 @@ public function getExtraValue(string $parameter) { return $parameter; } + + public function setExtraValue2(string $parameter) + { + } } diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php index 6f0f6da1bb883..21abb870be477 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php @@ -15,4 +15,8 @@ public function getExtraValue(string $parameter) { return $parameter; } + + public function setExtraValue2(string $parameter) + { + } } diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/DummyObjectWithEnumConstructor.php b/src/Symfony/Component/Serializer/Tests/Fixtures/DummyObjectWithEnumConstructor.php new file mode 100644 index 0000000000000..be5ea3cff0ece --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/DummyObjectWithEnumConstructor.php @@ -0,0 +1,12 @@ +getAttributesMetadata(); self::assertArrayNotHasKey('extraValue', $attributes); + self::assertArrayHasKey('extraValue2', $attributes); } public function testIgnoreGetterWirhRequiredParameterIfIgnoreAnnotationIsNotUsed() @@ -163,6 +164,7 @@ public function testIgnoreGetterWirhRequiredParameterIfIgnoreAnnotationIsNotUsed $attributes = $classMetadata->getAttributesMetadata(); self::assertArrayNotHasKey('extraValue', $attributes); + self::assertArrayHasKey('extraValue2', $attributes); } abstract protected function createLoader(): AnnotationLoader; diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/BackedEnumNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/BackedEnumNormalizerTest.php index 8ea2ac2b0f935..b0063da5fe4e7 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/BackedEnumNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/BackedEnumNormalizerTest.php @@ -88,8 +88,9 @@ public function testDenormalizeObjectThrowsException() public function testDenormalizeBadBackingValueThrowsException() { - $this->expectException(NotNormalizableValueException::class); - $this->expectExceptionMessage('"POST" is not a valid backing value for enum "'.StringBackedEnumDummy::class.'"'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The data must belong to a backed enumeration of type '.StringBackedEnumDummy::class); + $this->normalizer->denormalize('POST', StringBackedEnumDummy::class); } diff --git a/src/Symfony/Component/Serializer/Tests/SerializerTest.php b/src/Symfony/Component/Serializer/Tests/SerializerTest.php index b76e7d250f16b..9fb1519b8bae7 100644 --- a/src/Symfony/Component/Serializer/Tests/SerializerTest.php +++ b/src/Symfony/Component/Serializer/Tests/SerializerTest.php @@ -36,6 +36,7 @@ use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer; use Symfony\Component\Serializer\Normalizer\CustomNormalizer; use Symfony\Component\Serializer\Normalizer\DataUriNormalizer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; @@ -58,6 +59,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\DummyMessageInterface; use Symfony\Component\Serializer\Tests\Fixtures\DummyMessageNumberOne; use Symfony\Component\Serializer\Tests\Fixtures\DummyMessageNumberTwo; +use Symfony\Component\Serializer\Tests\Fixtures\DummyObjectWithEnumConstructor; use Symfony\Component\Serializer\Tests\Fixtures\FalseBuiltInDummy; use Symfony\Component\Serializer\Tests\Fixtures\NormalizableTraversableDummy; use Symfony\Component\Serializer\Tests\Fixtures\Php74Full; @@ -852,7 +854,8 @@ public function testCollectDenormalizationErrors(?ClassMetadataFactory $classMet }, "nestedObject": { "int": "string" - } + }, + "anotherCollection": null }'; $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); @@ -998,7 +1001,7 @@ public function testCollectDenormalizationErrors(?ClassMetadataFactory $classMet ], 'path' => 'php74FullWithConstructor', 'useMessageForUser' => true, - 'message' => 'Failed to create object because the object miss the "constructorArgument" property.', + 'message' => 'Failed to create object because the class misses the "constructorArgument" property.', ], $classMetadataFactory ? [ @@ -1028,6 +1031,13 @@ public function testCollectDenormalizationErrors(?ClassMetadataFactory $classMet 'useMessageForUser' => true, 'message' => 'The type of the key "int" must be "int" ("string" given).', ], + [ + 'currentType' => 'null', + 'expectedTypes' => ['array'], + 'path' => 'anotherCollection', + 'useMessageForUser' => false, + 'message' => 'Data expected to be "Symfony\Component\Serializer\Tests\Fixtures\Php74Full[]", "null" given.', + ], ]; $this->assertSame($expected, $exceptionsAsArray); @@ -1159,6 +1169,69 @@ public function testCollectDenormalizationErrorsWithConstructor(?ClassMetadataFa $this->assertSame($expected, $exceptionsAsArray); } + /** + * @requires PHP 8.1 + */ + public function testCollectDenormalizationErrorsWithEnumConstructor() + { + $serializer = new Serializer( + [ + new BackedEnumNormalizer(), + new ObjectNormalizer(), + ], + ['json' => new JsonEncoder()] + ); + + try { + $serializer->deserialize('{"invalid": "GET"}', DummyObjectWithEnumConstructor::class, 'json', [ + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, + ]); + } catch (\Throwable $th) { + $this->assertInstanceOf(PartialDenormalizationException::class, $th); + } + + $exceptionsAsArray = array_map(function (NotNormalizableValueException $e): array { + return [ + 'currentType' => $e->getCurrentType(), + 'useMessageForUser' => $e->canUseMessageForUser(), + 'message' => $e->getMessage(), + ]; + }, $th->getErrors()); + + $expected = [ + [ + 'currentType' => 'array', + 'useMessageForUser' => true, + 'message' => 'Failed to create object because the class misses the "get" property.', + ], + ]; + + $this->assertSame($expected, $exceptionsAsArray); + } + + /** + * @requires PHP 8.1 + */ + public function testNoCollectDenormalizationErrorsWithWrongEnum() + { + $serializer = new Serializer( + [ + new BackedEnumNormalizer(), + new ObjectNormalizer(), + ], + ['json' => new JsonEncoder()] + ); + + try { + $serializer->deserialize('{"get": "invalid"}', DummyObjectWithEnumConstructor::class, 'json', [ + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, + ]); + } catch (\Throwable $th) { + $this->assertNotInstanceOf(PartialDenormalizationException::class, $th); + $this->assertInstanceOf(InvalidArgumentException::class, $th); + } + } + public function provideCollectDenormalizationErrors() { return [ diff --git a/src/Symfony/Component/String/AbstractString.php b/src/Symfony/Component/String/AbstractString.php index fcab13af8947e..2932212751ce6 100644 --- a/src/Symfony/Component/String/AbstractString.php +++ b/src/Symfony/Component/String/AbstractString.php @@ -244,7 +244,7 @@ abstract public function chunk(int $length = 1): array; public function collapseWhitespace(): static { $str = clone $this; - $str->string = trim(preg_replace('/(?:\s{2,}+|[^\S ])/', ' ', $str->string)); + $str->string = trim(preg_replace("/(?:[ \n\r\t\x0C]{2,}+|[\n\r\t\x0C])/", ' ', $str->string), " \n\r\t\x0C"); return $str; } diff --git a/src/Symfony/Component/String/AbstractUnicodeString.php b/src/Symfony/Component/String/AbstractUnicodeString.php index 5f8859fef2ae1..5152fd366c1cb 100644 --- a/src/Symfony/Component/String/AbstractUnicodeString.php +++ b/src/Symfony/Component/String/AbstractUnicodeString.php @@ -356,7 +356,7 @@ public function reverse(): static public function snake(): static { - $str = $this->camel()->title(); + $str = $this->camel(); $str->string = mb_strtolower(preg_replace(['/(\p{Lu}+)(\p{Lu}\p{Ll})/u', '/([\p{Ll}0-9])(\p{Lu})/u'], '\1_\2', $str->string), 'UTF-8'); return $str; diff --git a/src/Symfony/Component/String/ByteString.php b/src/Symfony/Component/String/ByteString.php index 21bbf4a670471..ca4cf95b8887e 100644 --- a/src/Symfony/Component/String/ByteString.php +++ b/src/Symfony/Component/String/ByteString.php @@ -347,7 +347,7 @@ public function slice(int $start = 0, int $length = null): static public function snake(): static { - $str = $this->camel()->title(); + $str = $this->camel(); $str->string = strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], '\1_\2', $str->string)); return $str; diff --git a/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php b/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php index d28cfb6f53d78..b3c3d9086e1e6 100644 --- a/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php +++ b/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php @@ -1041,6 +1041,7 @@ public static function provideCamel() { return [ ['', ''], + ['xY', 'x_y'], ['symfonyIsGreat', 'symfony_is_great'], ['symfony5IsGreat', 'symfony_5_is_great'], ['symfonyIsGreat', 'Symfony is great'], @@ -1063,6 +1064,8 @@ public static function provideSnake() { return [ ['', ''], + ['x_y', 'x_y'], + ['x_y', 'X_Y'], ['symfony_is_great', 'symfonyIsGreat'], ['symfony5_is_great', 'symfony5IsGreat'], ['symfony5is_great', 'symfony5isGreat'], diff --git a/src/Symfony/Component/String/Tests/Slugger/AsciiSluggerTest.php b/src/Symfony/Component/String/Tests/Slugger/AsciiSluggerTest.php new file mode 100644 index 0000000000000..d58c002c40d99 --- /dev/null +++ b/src/Symfony/Component/String/Tests/Slugger/AsciiSluggerTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\String; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\String\Slugger\AsciiSlugger; + +class AsciiSluggerTest extends TestCase +{ + public function provideSlugTests(): iterable + { + yield ['', '']; + yield ['foo', ' foo ']; + yield ['foo-bar', 'foo bar']; + + yield ['foo-bar', 'foo@bar', '-']; + yield ['foo-at-bar', 'foo@bar', '-', 'en']; + + yield ['e-a', 'é$!à']; + yield ['e_a', 'é$!à', '_']; + + yield ['a', 'ä']; + yield ['a', 'ä', '-', 'fr']; + yield ['ae', 'ä', '-', 'de']; + yield ['ae', 'ä', '-', 'de_fr']; // Ensure we get the parent locale + yield [\function_exists('transliterator_transliterate') ? 'g' : '', 'ғ', '-']; + yield [\function_exists('transliterator_transliterate') ? 'gh' : '', 'ғ', '-', 'uz']; + yield [\function_exists('transliterator_transliterate') ? 'gh' : '', 'ғ', '-', 'uz_fr']; // Ensure we get the parent locale + } + + /** @dataProvider provideSlugTests */ + public function testSlug(string $expected, string $string, string $separator = '-', string $locale = null) + { + $slugger = new AsciiSlugger(); + + $this->assertSame($expected, (string) $slugger->slug($string, $separator, $locale)); + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php index d7c11dd990d9e..35220d7385e7e 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php @@ -95,8 +95,12 @@ public function write(TranslatorBagInterface $translatorBag): void } foreach ($responses as $response) { - if (200 !== $response->getStatusCode()) { + if (200 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to upload translations to Crowdin: "%s".', $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException('Unable to upload translations to Crowdin.', $response); + } } } } @@ -135,9 +139,13 @@ public function read(array $domains, array $locales): TranslatorBag continue; } - if (200 !== $response->getStatusCode()) { + if (200 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to export file: "%s".', $response->getContent(false))); + if (500 <= $statusCode) { + throw new ProviderException('Unable to export file.', $response); + } + continue; } @@ -146,9 +154,13 @@ public function read(array $domains, array $locales): TranslatorBag } foreach ($downloads as [$response, $locale, $domain]) { - if (200 !== $response->getStatusCode()) { + if (200 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to download file content: "%s".', $response->getContent(false))); + if (500 <= $statusCode) { + throw new ProviderException('Unable to download file content.', $response); + } + continue; } @@ -192,8 +204,12 @@ public function delete(TranslatorBagInterface $translatorBag): void continue; } - if (204 !== $response->getStatusCode()) { + if (204 !== $statusCode = $response->getStatusCode()) { $this->logger->warning(sprintf('Unable to delete string: "%s".', $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException('Unable to delete string.', $response); + } } } } @@ -228,8 +244,8 @@ private function addFile(string $domain, string $content): ?array $storageId = $this->addStorage($domain, $content); /** - * @see https://support.crowdin.com/api/v2/#operation/api.projects.files.getMany (Crowdin API) - * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.files.getMany (Crowdin Enterprise API) + * @see https://developer.crowdin.com/api/v2/#operation/api.projects.files.getMany (Crowdin API) + * @see https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.files.getMany (Crowdin Enterprise API) */ $response = $this->client->request('POST', 'files', [ 'json' => [ @@ -238,9 +254,13 @@ private function addFile(string $domain, string $content): ?array ], ]); - if (201 !== $response->getStatusCode()) { + if (201 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to create a File in Crowdin for domain "%s": "%s".', $domain, $response->getContent(false))); + if (500 <= $statusCode) { + throw new ProviderException(sprintf('Unable to create a File in Crowdin for domain "%s".', $domain), $response); + } + return null; } @@ -252,8 +272,8 @@ private function updateFile(int $fileId, string $domain, string $content): ?arra $storageId = $this->addStorage($domain, $content); /** - * @see https://support.crowdin.com/api/v2/#operation/api.projects.files.put (Crowdin API) - * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.files.put (Crowdin Enterprise API) + * @see https://developer.crowdin.com/api/v2/#operation/api.projects.files.put (Crowdin API) + * @see https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.files.put (Crowdin Enterprise API) */ $response = $this->client->request('PUT', 'files/'.$fileId, [ 'json' => [ @@ -261,9 +281,13 @@ private function updateFile(int $fileId, string $domain, string $content): ?arra ], ]); - if (200 !== $response->getStatusCode()) { + if (200 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to update file in Crowdin for file ID "%d" and domain "%s": "%s".', $fileId, $domain, $response->getContent(false))); + if (500 <= $statusCode) { + throw new ProviderException(sprintf('Unable to update file in Crowdin for file ID "%d" and domain "%s".', $fileId, $domain), $response); + } + return null; } @@ -275,8 +299,8 @@ private function uploadTranslations(int $fileId, string $domain, string $content $storageId = $this->addStorage($domain, $content); /* - * @see https://support.crowdin.com/api/v2/#operation/api.projects.translations.postOnLanguage (Crowdin API) - * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.translations.postOnLanguage (Crowdin Enterprise API) + * @see https://developer.crowdin.com/api/v2/#operation/api.projects.translations.postOnLanguage (Crowdin API) + * @see https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.translations.postOnLanguage (Crowdin Enterprise API) */ return $this->client->request('POST', 'translations/'.str_replace('_', '-', $locale), [ 'json' => [ @@ -289,8 +313,8 @@ private function uploadTranslations(int $fileId, string $domain, string $content private function exportProjectTranslations(string $languageId, int $fileId): ResponseInterface { /* - * @see https://support.crowdin.com/api/v2/#operation/api.projects.translations.exports.post (Crowdin API) - * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.translations.exports.post (Crowdin Enterprise API) + * @see https://developer.crowdin.com/api/v2/#operation/api.projects.translations.exports.post (Crowdin API) + * @see https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.translations.exports.post (Crowdin Enterprise API) */ return $this->client->request('POST', 'translations/exports', [ 'json' => [ @@ -303,8 +327,8 @@ private function exportProjectTranslations(string $languageId, int $fileId): Res private function downloadSourceFile(int $fileId): ResponseInterface { /* - * @see https://support.crowdin.com/api/v2/#operation/api.projects.files.download.get (Crowdin API) - * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.files.download.get (Crowdin Enterprise API) + * @see https://developer.crowdin.com/api/v2/#operation/api.projects.files.download.get (Crowdin API) + * @see https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.files.download.get (Crowdin Enterprise API) */ return $this->client->request('GET', sprintf('files/%d/download', $fileId)); } @@ -312,8 +336,8 @@ private function downloadSourceFile(int $fileId): ResponseInterface private function listStrings(int $fileId, int $limit, int $offset): array { /** - * @see https://support.crowdin.com/api/v2/#operation/api.projects.strings.getMany (Crowdin API) - * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.strings.getMany (Crowdin Enterprise API) + * @see https://developer.crowdin.com/api/v2/#operation/api.projects.strings.getMany (Crowdin API) + * @see https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.strings.getMany (Crowdin Enterprise API) */ $response = $this->client->request('GET', 'strings', [ 'query' => [ @@ -324,9 +348,7 @@ private function listStrings(int $fileId, int $limit, int $offset): array ]); if (200 !== $response->getStatusCode()) { - $this->logger->error(sprintf('Unable to list strings for file %d: "%s".', $fileId, $response->getContent())); - - return []; + throw new ProviderException(sprintf('Unable to list strings for file "%d".', $fileId), $response); } return $response->toArray()['data']; @@ -335,8 +357,8 @@ private function listStrings(int $fileId, int $limit, int $offset): array private function deleteString(int $stringId): ResponseInterface { /* - * @see https://support.crowdin.com/api/v2/#operation/api.projects.strings.delete (Crowdin API) - * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.strings.delete (Crowdin Enterprise API) + * @see https://developer.crowdin.com/api/v2/#operation/api.projects.strings.delete (Crowdin API) + * @see https://developer.crowdin.com/enterprise/api/v2#operation/api.projects.strings.delete (Crowdin Enterprise API) */ return $this->client->request('DELETE', 'strings/'.$stringId); } @@ -344,8 +366,8 @@ private function deleteString(int $stringId): ResponseInterface private function addStorage(string $domain, string $content): int { /** - * @see https://support.crowdin.com/api/v2/#operation/api.storages.post (Crowdin API) - * @see https://support.crowdin.com/enterprise/api/#operation/api.storages.post (Crowdin Enterprise API) + * @see https://developer.crowdin.com/api/v2/#operation/api.storages.post (Crowdin API) + * @see https://developer.crowdin.com/enterprise/api/v2/#operation/api.storages.post (Crowdin Enterprise API) */ $response = $this->client->request('POST', '../../storages', [ 'headers' => [ @@ -367,8 +389,8 @@ private function getFileList(): array $result = []; /** - * @see https://support.crowdin.com/api/v2/#operation/api.projects.files.getMany (Crowdin API) - * @see https://support.crowdin.com/enterprise/api/#operation/api.projects.files.getMany (Crowdin Enterprise API) + * @see https://developer.crowdin.com/api/v2/#operation/api.projects.files.getMany (Crowdin API) + * @see https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.files.getMany (Crowdin Enterprise API) */ $response = $this->client->request('GET', 'files'); diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php b/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php index c21820829834e..8ecee4d1bfe95 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php @@ -16,6 +16,7 @@ use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProvider; use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\MessageCatalogue; @@ -155,6 +156,250 @@ public function testCompleteWriteProcessAddFiles() $provider->write($translatorBag); } + public function testWriteAddFileServerError() + { + $this->xliffFileDumper = new XliffFileDumper(); + + $expectedMessagesFileContent = <<<'XLIFF' + + + +
+ +
+ + + a + trans_en_a + + +
+
+ +XLIFF; + + $responses = [ + 'listFiles' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + $this->assertSame('Authorization: Bearer API_TOKEN', $options['normalized_headers']['authorization'][0]); + + return new MockResponse(json_encode(['data' => []])); + }, + 'addStorage' => function (string $method, string $url, array $options = []) use ($expectedMessagesFileContent): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + $this->assertSame('Content-Type: application/octet-stream', $options['normalized_headers']['content-type'][0]); + $this->assertSame('Crowdin-API-FileName: messages.xlf', $options['normalized_headers']['crowdin-api-filename'][0]); + $this->assertSame($expectedMessagesFileContent, $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 19]]), ['http_code' => 201]); + }, + 'addFile' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + $this->assertSame('{"storageId":19,"name":"messages.xlf"}', $options['body']); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + 'validators' => ['post.num_comments' => '{count, plural, one {# comment} other {# comments}}'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/projects/1/', + 'auth_bearer' => 'API_TOKEN', + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2/projects/1/'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to create a File in Crowdin for domain "messages".'); + + $provider->write($translatorBag); + } + + public function testWriteUpdateFileServerError() + { + $this->xliffFileDumper = new XliffFileDumper(); + + $expectedMessagesFileContent = <<<'XLIFF' + + + +
+ +
+ + + a + trans_en_a + + +
+
+ +XLIFF; + + $responses = [ + 'listFiles' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + $this->assertSame('Authorization: Bearer API_TOKEN', $options['normalized_headers']['authorization'][0]); + + return new MockResponse(json_encode([ + 'data' => [ + ['data' => [ + 'id' => 12, + 'name' => 'messages.xlf', + ]], + ], + ])); + }, + 'addStorage' => function (string $method, string $url, array $options = []) use ($expectedMessagesFileContent): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + $this->assertSame('Content-Type: application/octet-stream', $options['normalized_headers']['content-type'][0]); + $this->assertSame('Crowdin-API-FileName: messages.xlf', $options['normalized_headers']['crowdin-api-filename'][0]); + $this->assertSame($expectedMessagesFileContent, $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 19]]), ['http_code' => 201]); + }, + 'UpdateFile' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('PUT', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files/12', $url); + $this->assertSame('{"storageId":19}', $options['body']); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + 'validators' => ['post.num_comments' => '{count, plural, one {# comment} other {# comments}}'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/projects/1/', + 'auth_bearer' => 'API_TOKEN', + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2/projects/1/'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to update file in Crowdin for file ID "12" and domain "messages".'); + + $provider->write($translatorBag); + } + + public function testWriteUploadTranslationsServerError() + { + $this->xliffFileDumper = new XliffFileDumper(); + + $expectedMessagesTranslationsContent = <<<'XLIFF' + + + +
+ +
+ + + a + trans_fr_a + + +
+
+ +XLIFF; + + $expectedMessagesFileContent = <<<'XLIFF' + + + +
+ +
+ + + a + trans_en_a + + +
+
+ +XLIFF; + + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new MockResponse(json_encode([ + 'data' => [ + ['data' => [ + 'id' => 12, + 'name' => 'messages.xlf', + ]], + ], + ])); + }, + 'addStorage' => function (string $method, string $url, array $options = []) use ($expectedMessagesFileContent): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + $this->assertSame('Content-Type: application/octet-stream', $options['normalized_headers']['content-type'][0]); + $this->assertSame('Crowdin-API-FileName: messages.xlf', $options['normalized_headers']['crowdin-api-filename'][0]); + $this->assertSame($expectedMessagesFileContent, $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 19]]), ['http_code' => 201]); + }, + 'updateFile' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('PUT', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files/12', $url); + $this->assertSame('{"storageId":19}', $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 12, 'name' => 'messages.xlf']])); + }, + 'addStorage2' => function (string $method, string $url, array $options = []) use ($expectedMessagesTranslationsContent): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + $this->assertSame('Content-Type: application/octet-stream', $options['normalized_headers']['content-type'][0]); + $this->assertSame('Crowdin-API-FileName: messages.xlf', $options['normalized_headers']['crowdin-api-filename'][0]); + $this->assertSame($expectedMessagesTranslationsContent, $options['body']); + + return new MockResponse(json_encode(['data' => ['id' => 19]]), ['http_code' => 201]); + }, + 'UploadTranslations' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame(sprintf('https://api.crowdin.com/api/v2/projects/1/translations/%s', 'fr'), $url); + $this->assertSame('{"storageId":19,"fileId":12}', $options['body']); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + ])); + $translatorBag->addCatalogue(new MessageCatalogue('fr', [ + 'messages' => ['a' => 'trans_fr_a'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/projects/1/', + 'auth_bearer' => 'API_TOKEN', + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2/projects/1/'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to upload translations to Crowdin.'); + + $provider->write($translatorBag); + } + public function testCompleteWriteProcessUpdateFiles() { $this->xliffFileDumper = new XliffFileDumper(); @@ -563,6 +808,82 @@ public function getResponsesForDefaultLocaleAndOneDomain(): \Generator ]; } + public function testReadServerException() + { + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new MockResponse(json_encode([ + 'data' => [ + ['data' => [ + 'id' => 12, + 'name' => 'messages.xlf', + ]], + ], + ])); + }, + 'exportProjectTranslations' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/translations/exports', $url); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $crowdinProvider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/projects/1/', + 'auth_bearer' => 'API_TOKEN', + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to export file.'); + + $crowdinProvider->read(['messages'], ['fr']); + } + + public function testReadDownloadServerException() + { + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new MockResponse(json_encode([ + 'data' => [ + ['data' => [ + 'id' => 12, + 'name' => 'messages.xlf', + ]], + ], + ])); + }, + 'exportProjectTranslations' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/translations/exports', $url); + + return new MockResponse(json_encode(['data' => ['url' => 'https://file.url']])); + }, + 'downloadFile' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://file.url/', $url); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $crowdinProvider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/projects/1/', + 'auth_bearer' => 'API_TOKEN', + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to download file content.'); + + $crowdinProvider->read(['messages'], ['fr']); + } + public function testDelete() { $responses = [ @@ -631,4 +952,111 @@ public function testDelete() $provider->delete($translatorBag); } + + public function testDeleteListStringServerException() + { + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new MockResponse(json_encode([ + 'data' => [ + ['data' => [ + 'id' => 12, + 'name' => 'messages.xlf', + ]], + ], + ])); + }, + 'listStrings' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/strings?fileId=12&limit=500&offset=0', $url); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => [ + 'en a' => 'en a', + ], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/projects/1/', + 'auth_bearer' => 'API_TOKEN', + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2/projects/1/'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to list strings for file "12".'); + + $provider->delete($translatorBag); + } + + public function testDeleteDeleteStringServerException() + { + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new MockResponse(json_encode([ + 'data' => [ + ['data' => [ + 'id' => 12, + 'name' => 'messages.xlf', + ]], + ], + ])); + }, + 'listStrings' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/strings?fileId=12&limit=500&offset=0', $url); + + return new MockResponse(json_encode([ + 'data' => [ + ['data' => ['id' => 1, 'text' => 'en a']], + ['data' => ['id' => 2, 'text' => 'en b']], + ], + ])); + }, + 'listStrings2' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/strings?fileId=12&limit=500&offset=500', $url); + + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->any()) + ->method('getContent') + ->with(false) + ->willReturn(json_encode(['data' => []])); + + return $response; + }, + 'deleteString1' => function (string $method, string $url): ResponseInterface { + $this->assertSame('DELETE', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/strings/1', $url); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => [ + 'en a' => 'en a', + ], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/projects/1/', + 'auth_bearer' => 'API_TOKEN', + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com/api/v2/projects/1/'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to delete string.'); + + $provider->delete($translatorBag); + } } diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php index ca2dad34168f1..466584a3363a7 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php @@ -123,8 +123,12 @@ public function read(array $domains, array $locales): TranslatorBag $this->logger->info(sprintf('No modifications found for locale "%s" and domain "%s" in Loco.', $locale, $domain)); $catalogue = new MessageCatalogue($locale); + $previousMessages = $previousCatalogue->all($domain); - foreach ($previousCatalogue->all($domain) as $key => $message) { + if (!str_ends_with($domain, $catalogue::INTL_DOMAIN_SUFFIX)) { + $previousMessages = array_diff_key($previousMessages, $previousCatalogue->all($domain.$catalogue::INTL_DOMAIN_SUFFIX)); + } + foreach ($previousMessages as $key => $message) { $catalogue->set($this->retrieveKeyFromId($key, $domain), $message, $domain); } @@ -184,12 +188,16 @@ public function delete(TranslatorBagInterface $translatorBag): void } foreach ($responses as $key => $response) { - if (403 === $response->getStatusCode()) { + if (403 === $statusCode = $response->getStatusCode()) { $this->logger->error('The API key used does not have sufficient permissions to delete assets.'); } - if (200 !== $response->getStatusCode() && 404 !== $response->getStatusCode()) { + if (200 !== $statusCode && 404 !== $statusCode) { $this->logger->error(sprintf('Unable to delete translation key "%s" to Loco: "%s".', $key, $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException(sprintf('Unable to delete translation key "%s" to Loco.', $key), $response); + } } } } @@ -201,8 +209,12 @@ private function getAssetsIds(string $domain): array { $response = $this->client->request('GET', 'assets', ['query' => ['filter' => $domain]]); - if (200 !== $response->getStatusCode()) { + if (200 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to get assets from Loco: "%s".', $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException('Unable to get assets from Loco.', $response); + } } return array_map(function ($asset) { @@ -226,8 +238,12 @@ private function createAssets(array $keys, string $domain): array } foreach ($responses as $key => $response) { - if (201 !== $response->getStatusCode()) { - $this->logger->error(sprintf('Unable to add new translation key "%s" to Loco: (status code: "%s") "%s".', $key, $response->getStatusCode(), $response->getContent(false))); + if (201 !== $statusCode = $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to add new translation key "%s" to Loco: (status code: "%s") "%s".', $key, $statusCode, $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException(sprintf('Unable to add new translation key "%s" to Loco: (status code: "%s").', $key, $statusCode), $response); + } } else { $createdIds[] = $response->toArray(false)['id']; } @@ -248,8 +264,12 @@ private function translateAssets(array $translations, string $locale): void } foreach ($responses as $id => $response) { - if (200 !== $response->getStatusCode()) { + if (200 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to add translation for key "%s" in locale "%s" to Loco: "%s".', $id, $locale, $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException(sprintf('Unable to add translation for key "%s" in locale "%s" to Loco.', $id, $locale), $response); + } } } } @@ -270,13 +290,19 @@ private function tagsAssets(array $ids, string $tag): void } } - // Set tags for all ids without comma. - $response = $this->client->request('POST', sprintf('tags/%s.json', rawurlencode($tag)), [ - 'body' => implode(',', $idsWithoutComma), - ]); + if ([] !== $idsWithoutComma) { + // Set tags for all ids without comma. + $response = $this->client->request('POST', sprintf('tags/%s.json', rawurlencode($tag)), [ + 'body' => implode(',', $idsWithoutComma), + ]); - if (200 !== $response->getStatusCode()) { - $this->logger->error(sprintf('Unable to tag assets with "%s" on Loco: "%s".', $tag, $response->getContent(false))); + if (200 !== $statusCode = $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to tag assets with "%s" on Loco: "%s".', $tag, $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException(sprintf('Unable to tag assets with "%s" on Loco.', $tag), $response); + } + } } // Set tags for each id with comma one by one. @@ -285,8 +311,12 @@ private function tagsAssets(array $ids, string $tag): void 'body' => ['name' => $tag], ]); - if (200 !== $response->getStatusCode()) { + if (200 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to tag asset "%s" with "%s" on Loco: "%s".', $id, $tag, $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException(sprintf('Unable to tag asset "%s" with "%s" on Loco.', $id, $tag), $response); + } } } } @@ -299,8 +329,12 @@ private function createTag(string $tag): void ], ]); - if (201 !== $response->getStatusCode()) { + if (201 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to create tag "%s" on Loco: "%s".', $tag, $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException(sprintf('Unable to create tag "%s" on Loco.', $tag), $response); + } } } @@ -324,8 +358,12 @@ private function createLocale(string $locale): void ], ]); - if (201 !== $response->getStatusCode()) { + if (201 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to create locale "%s" on Loco: "%s".', $locale, $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException(sprintf('Unable to create locale "%s" on Loco.', $locale), $response); + } } } diff --git a/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php b/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php index af6342d339d8a..5f4ab42b346f5 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php @@ -15,6 +15,7 @@ use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\Translation\Bridge\Loco\LocoProvider; +use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\Loader\XliffFileLoader; @@ -250,6 +251,451 @@ public function testCompleteWriteProcess() $provider->write($translatorBag); } + public function testWriteCreateAssetServerError() + { + $expectedAuthHeader = 'Authorization: Loco API_KEY'; + + $responses = [ + 'createAsset' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $expectedBody = http_build_query([ + 'id' => 'messages__a', + 'text' => 'a', + 'type' => 'text', + 'default' => 'untranslated', + ]); + + $this->assertSame('POST', $method); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame($expectedBody, $options['body']); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://localise.biz/api/', + 'headers' => ['Authorization' => 'Loco API_KEY'], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to add new translation key "a" to Loco: (status code: "500").'); + + $provider->write($translatorBag); + } + + public function testWriteCreateTagServerError() + { + $expectedAuthHeader = 'Authorization: Loco API_KEY'; + + $responses = [ + 'createAsset' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $expectedBody = http_build_query([ + 'id' => 'messages__a', + 'text' => 'a', + 'type' => 'text', + 'default' => 'untranslated', + ]); + + $this->assertSame('POST', $method); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame($expectedBody, $options['body']); + + return new MockResponse('{"id": "messages__a"}', ['http_code' => 201]); + }, + 'getTags' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://localise.biz/api/tags.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return new MockResponse('[]'); + }, + 'createTag' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://localise.biz/api/tags.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame(http_build_query(['name' => 'messages']), $options['body']); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://localise.biz/api/', + 'headers' => ['Authorization' => 'Loco API_KEY'], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to create tag "messages" on Loco.'); + + $provider->write($translatorBag); + } + + public function testWriteTagAssetsServerError() + { + $expectedAuthHeader = 'Authorization: Loco API_KEY'; + + $responses = [ + 'createAsset' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $expectedBody = http_build_query([ + 'id' => 'messages__a', + 'text' => 'a', + 'type' => 'text', + 'default' => 'untranslated', + ]); + + $this->assertSame('POST', $method); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame($expectedBody, $options['body']); + + return new MockResponse('{"id": "messages__a"}', ['http_code' => 201]); + }, + 'getTags' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://localise.biz/api/tags.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return new MockResponse('[]'); + }, + 'createTag' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://localise.biz/api/tags.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame(http_build_query(['name' => 'messages']), $options['body']); + + return new MockResponse('', ['http_code' => 201]); + }, + 'tagAsset' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://localise.biz/api/tags/messages.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame('messages__a', $options['body']); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://localise.biz/api/', + 'headers' => ['Authorization' => 'Loco API_KEY'], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to tag assets with "messages" on Loco.'); + + $provider->write($translatorBag); + } + + public function testWriteTagAssetsServerErrorWithComma() + { + $expectedAuthHeader = 'Authorization: Loco API_KEY'; + + $responses = [ + 'createAsset' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $expectedBody = http_build_query([ + 'id' => 'messages__a', + 'text' => 'a', + 'type' => 'text', + 'default' => 'untranslated', + ]); + + $this->assertSame('POST', $method); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame($expectedBody, $options['body']); + + return new MockResponse('{"id": "messages__a,messages__b"}', ['http_code' => 201]); + }, + 'getTags' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://localise.biz/api/tags.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return new MockResponse('[]'); + }, + 'createTag' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://localise.biz/api/tags.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame(http_build_query(['name' => 'messages']), $options['body']); + + return new MockResponse('', ['http_code' => 201]); + }, + 'tagAssetWithComma' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://localise.biz/api/assets/messages__a%2Cmessages__b/tags', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame('name=messages', $options['body']); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://localise.biz/api/', + 'headers' => ['Authorization' => 'Loco API_KEY'], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to tag asset "messages__a,messages__b" with "messages" on Loco.'); + + $provider->write($translatorBag); + } + + public function testWriteCreateLocaleServerError() + { + $expectedAuthHeader = 'Authorization: Loco API_KEY'; + + $responses = [ + 'createAsset' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $expectedBody = http_build_query([ + 'id' => 'messages__a', + 'text' => 'a', + 'type' => 'text', + 'default' => 'untranslated', + ]); + + $this->assertSame('POST', $method); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame($expectedBody, $options['body']); + + return new MockResponse('{"id": "messages__a"}', ['http_code' => 201]); + }, + 'getTags' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://localise.biz/api/tags.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return new MockResponse('[]'); + }, + 'createTag' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://localise.biz/api/tags.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame(http_build_query(['name' => 'messages']), $options['body']); + + return new MockResponse('', ['http_code' => 201]); + }, + 'tagAsset' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://localise.biz/api/tags/messages.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame('messages__a', $options['body']); + + return new MockResponse(); + }, + 'getLocales' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://localise.biz/api/locales', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return new MockResponse('[{"code":"fr"}]'); + }, + 'createLocale' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://localise.biz/api/locales', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://localise.biz/api/', + 'headers' => ['Authorization' => 'Loco API_KEY'], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to create locale "en" on Loco.'); + + $provider->write($translatorBag); + } + + public function testWriteGetAssetsIdsServerError() + { + $expectedAuthHeader = 'Authorization: Loco API_KEY'; + + $responses = [ + 'createAsset' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $expectedBody = http_build_query([ + 'id' => 'messages__a', + 'text' => 'a', + 'type' => 'text', + 'default' => 'untranslated', + ]); + + $this->assertSame('POST', $method); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame($expectedBody, $options['body']); + + return new MockResponse('{"id": "messages__a"}', ['http_code' => 201]); + }, + 'getTags' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://localise.biz/api/tags.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return new MockResponse('[]'); + }, + 'createTag' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://localise.biz/api/tags.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame(http_build_query(['name' => 'messages']), $options['body']); + + return new MockResponse('', ['http_code' => 201]); + }, + 'tagAsset' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://localise.biz/api/tags/messages.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame('messages__a', $options['body']); + + return new MockResponse(); + }, + 'getLocales' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://localise.biz/api/locales', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return new MockResponse('[{"code":"en"}]'); + }, + 'getAssetsIds' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://localise.biz/api/assets?filter=messages', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + ])); + $translatorBag->addCatalogue(new MessageCatalogue('fr', [ + 'messages' => ['a' => 'trans_fr_a'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://localise.biz/api/', + 'headers' => ['Authorization' => 'Loco API_KEY'], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to get assets from Loco.'); + + $provider->write($translatorBag); + } + + public function testWriteTranslateAssetsServerError() + { + $expectedAuthHeader = 'Authorization: Loco API_KEY'; + + $responses = [ + 'createAsset' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $expectedBody = http_build_query([ + 'id' => 'messages__a', + 'text' => 'a', + 'type' => 'text', + 'default' => 'untranslated', + ]); + + $this->assertSame('POST', $method); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame($expectedBody, $options['body']); + + return new MockResponse('{"id": "messages__a"}', ['http_code' => 201]); + }, + 'getTags' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://localise.biz/api/tags.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return new MockResponse('[]'); + }, + 'createTag' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://localise.biz/api/tags.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame(http_build_query(['name' => 'messages']), $options['body']); + + return new MockResponse('', ['http_code' => 201]); + }, + 'tagAsset' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://localise.biz/api/tags/messages.json', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame('messages__a', $options['body']); + + return new MockResponse(); + }, + 'getLocales' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://localise.biz/api/locales', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return new MockResponse('[{"code":"en"}]'); + }, + 'getAssetsIds' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://localise.biz/api/assets?filter=messages', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return new MockResponse('[{"id":"messages__foo.existing_key"},{"id":"messages__a"}]'); + }, + 'translateAsset' => function (string $method, string $url, array $options = []) use ($expectedAuthHeader): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://localise.biz/api/translations/messages__a/en', $url); + $this->assertSame($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertSame('trans_en_a', $options['body']); + + return new MockResponse('', ['http_code' => 500]); + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + ])); + $translatorBag->addCatalogue(new MessageCatalogue('fr', [ + 'messages' => ['a' => 'trans_fr_a'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://localise.biz/api/', + 'headers' => ['Authorization' => 'Loco API_KEY'], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to add translation for key "messages__a" in locale "en" to Loco.'); + + $provider->write($translatorBag); + } + /** * @dataProvider getResponsesForOneLocaleAndOneDomain */ @@ -450,6 +896,41 @@ function (string $method, string $url): MockResponse { $provider->delete($translatorBag); } + public function testDeleteServerError() + { + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + ])); + + $provider = $this->createProvider( + new MockHttpClient([ + function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://localise.biz/api/assets?filter=messages', $url); + $this->assertSame(['filter' => 'messages'], $options['query']); + + return new MockResponse('[{"id":"messages__a"}]'); + }, + function (string $method, string $url): MockResponse { + $this->assertSame('DELETE', $method); + $this->assertSame('https://localise.biz/api/assets/messages__a.json', $url); + + return new MockResponse('', ['http_code' => 500]); + }, + ], 'https://localise.biz/api/'), + $this->getLoader(), + $this->getLogger(), + $this->getDefaultLocale(), + 'localise.biz/api/' + ); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to delete translation key "messages__a" to Loco.'); + + $provider->delete($translatorBag); + } + public function getResponsesForOneLocaleAndOneDomain(): \Generator { $arrayLoader = new ArrayLoader(); @@ -458,9 +939,9 @@ public function getResponsesForOneLocaleAndOneDomain(): \Generator $expectedTranslatorBagEn->addCatalogue($arrayLoader->load([ 'index.hello' => 'Hello', 'index.greetings' => 'Welcome, {firstname}!', - ], 'en')); + ], 'en', 'messages+intl-icu')); - yield ['en', 'messages', <<<'XLIFF' + yield ['en', 'messages+intl-icu', <<<'XLIFF' @@ -468,7 +949,7 @@ public function getResponsesForOneLocaleAndOneDomain(): \Generator - + index.hello Hello @@ -488,9 +969,9 @@ public function getResponsesForOneLocaleAndOneDomain(): \Generator $expectedTranslatorBagFr->addCatalogue($arrayLoader->load([ 'index.hello' => 'Bonjour', 'index.greetings' => 'Bienvenue, {firstname} !', - ], 'fr')); + ], 'fr', 'messages+intl-icu')); - yield ['fr', 'messages', <<<'XLIFF' + yield ['fr', 'messages+intl-icu', <<<'XLIFF' @@ -498,7 +979,7 @@ public function getResponsesForOneLocaleAndOneDomain(): \Generator - + index.hello Bonjour diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php b/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php index 167430f1848d8..c28b42b289c68 100644 --- a/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php @@ -120,10 +120,12 @@ public function delete(TranslatorBagInterface $translatorBag): void $keysIds = []; foreach ($catalogue->getDomains() as $domain) { - $keysToDelete = []; - foreach (array_keys($catalogue->all($domain)) as $key) { - $keysToDelete[] = $key; + $keysToDelete = array_keys($catalogue->all($domain)); + + if (!$keysToDelete) { + continue; } + $keysIds += $this->getKeysIds($keysToDelete, $domain); } @@ -198,9 +200,13 @@ private function createKeys(array $keys, string $domain): array $createdKeys = []; foreach ($responses as $response) { - if (200 !== $response->getStatusCode()) { + if (200 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to create keys to Lokalise: "%s".', $response->getContent(false))); + if (500 <= $statusCode) { + throw new ProviderException('Unable to create keys to Lokalise.', $response); + } + continue; } @@ -254,8 +260,12 @@ private function updateTranslations(array $keysByDomain, TranslatorBagInterface 'json' => ['keys' => $keysToUpdate], ]); - if (200 !== $response->getStatusCode()) { + if (200 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to create/update translations to Lokalise: "%s".', $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException('Unable to create/update translations to Lokalise.', $response); + } } } @@ -270,8 +280,12 @@ private function getKeysIds(array $keys, string $domain, int $page = 1): array ], ]); - if (200 !== $response->getStatusCode()) { + if (200 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to get keys ids from Lokalise: "%s".', $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException('Unable to get keys ids from Lokalise.', $response); + } } $result = []; @@ -320,9 +334,13 @@ private function getLanguages(): array { $response = $this->client->request('GET', 'languages'); - if (200 !== $response->getStatusCode()) { + if (200 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to get languages from Lokalise: "%s".', $response->getContent(false))); + if (500 <= $statusCode) { + throw new ProviderException('Unable to get languages from Lokalise.', $response); + } + return []; } @@ -345,8 +363,12 @@ private function createLanguages(array $languages): void ], ]); - if (200 !== $response->getStatusCode()) { + if (200 !== $statusCode = $response->getStatusCode()) { $this->logger->error(sprintf('Unable to create languages on Lokalise: "%s".', $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException('Unable to create languages on Lokalise.', $response); + } } } diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php b/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php index 7ce9b8e067ada..5df996e94327b 100644 --- a/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php @@ -15,6 +15,7 @@ use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\Translation\Bridge\Lokalise\LokaliseProvider; +use Symfony\Component\Translation\Exception\ProviderException; use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\Loader\LoaderInterface; use Symfony\Component\Translation\Loader\XliffFileLoader; @@ -247,6 +248,307 @@ public function testCompleteWriteProcess() $this->assertTrue($updateProcessed, 'Translations update was not called.'); } + public function testWriteGetLanguageServerError() + { + $getLanguagesResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/languages', $url); + + return new MockResponse('', ['http_code' => 500]); + }; + + $provider = $this->createProvider((new MockHttpClient([ + $getLanguagesResponse, + ]))->withOptions([ + 'base_uri' => 'https://api.lokalise.com/api2/projects/PROJECT_ID/', + 'headers' => ['X-Api-Token' => 'API_KEY'], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com'); + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['young_dog' => 'puppy'], + ])); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to get languages from Lokalise.'); + + $provider->write($translatorBag); + } + + public function testWriteCreateLanguageServerError() + { + $getLanguagesResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/languages', $url); + + return new MockResponse(json_encode(['languages' => []])); + }; + + $createLanguagesResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $expectedBody = json_encode([ + 'languages' => [ + ['lang_iso' => 'en'], + ], + ]); + + $this->assertSame('POST', $method); + $this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/languages', $url); + $this->assertJsonStringEqualsJsonString($expectedBody, $options['body']); + + return new MockResponse('', ['http_code' => 500]); + }; + + $provider = $this->createProvider((new MockHttpClient([ + $getLanguagesResponse, + $createLanguagesResponse, + ]))->withOptions([ + 'base_uri' => 'https://api.lokalise.com/api2/projects/PROJECT_ID/', + 'headers' => ['X-Api-Token' => 'API_KEY'], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com'); + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['young_dog' => 'puppy'], + ])); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to create languages on Lokalise.'); + + $provider->write($translatorBag); + } + + public function testWriteGetKeysIdsServerError() + { + $getLanguagesResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/languages', $url); + + return new MockResponse(json_encode(['languages' => []])); + }; + + $createLanguagesResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $expectedBody = json_encode([ + 'languages' => [ + ['lang_iso' => 'en'], + ], + ]); + + $this->assertSame('POST', $method); + $this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/languages', $url); + $this->assertJsonStringEqualsJsonString($expectedBody, $options['body']); + + return new MockResponse(json_encode(['keys' => []])); + }; + + $getKeysIdsForMessagesDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $expectedQuery = [ + 'filter_keys' => '', + 'filter_filenames' => 'messages.xliff', + 'limit' => 5000, + 'page' => 1, + ]; + + $this->assertSame('GET', $method); + $this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/keys?'.http_build_query($expectedQuery), $url); + $this->assertSame($expectedQuery, $options['query']); + + return new MockResponse('', ['http_code' => 500]); + }; + + $provider = $this->createProvider((new MockHttpClient([ + $getLanguagesResponse, + $createLanguagesResponse, + $getKeysIdsForMessagesDomainResponse, + ]))->withOptions([ + 'base_uri' => 'https://api.lokalise.com/api2/projects/PROJECT_ID/', + 'headers' => ['X-Api-Token' => 'API_KEY'], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com'); + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['young_dog' => 'puppy'], + ])); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to get keys ids from Lokalise.'); + + $provider->write($translatorBag); + } + + public function testWriteCreateKeysServerError() + { + $getLanguagesResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/languages', $url); + + return new MockResponse(json_encode(['languages' => []])); + }; + + $createLanguagesResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $expectedBody = json_encode([ + 'languages' => [ + ['lang_iso' => 'en'], + ], + ]); + + $this->assertSame('POST', $method); + $this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/languages', $url); + $this->assertJsonStringEqualsJsonString($expectedBody, $options['body']); + + return new MockResponse(json_encode(['keys' => []])); + }; + + $getKeysIdsForMessagesDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $expectedQuery = [ + 'filter_keys' => '', + 'filter_filenames' => 'messages.xliff', + 'limit' => 5000, + 'page' => 1, + ]; + + $this->assertSame('GET', $method); + $this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/keys?'.http_build_query($expectedQuery), $url); + $this->assertSame($expectedQuery, $options['query']); + + return new MockResponse(json_encode(['keys' => []])); + }; + + $createKeysForMessagesDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $expectedBody = json_encode([ + 'keys' => [ + [ + 'key_name' => 'young_dog', + 'platforms' => ['web'], + 'filenames' => [ + 'web' => 'messages.xliff', + 'ios' => null, + 'android' => null, + 'other' => null, + ], + ], + ], + ]); + + $this->assertSame('POST', $method); + $this->assertJsonStringEqualsJsonString($expectedBody, $options['body']); + + return new MockResponse('', ['http_code' => 500]); + }; + + $provider = $this->createProvider((new MockHttpClient([ + $getLanguagesResponse, + $createLanguagesResponse, + $getKeysIdsForMessagesDomainResponse, + $createKeysForMessagesDomainResponse, + ]))->withOptions([ + 'base_uri' => 'https://api.lokalise.com/api2/projects/PROJECT_ID/', + 'headers' => ['X-Api-Token' => 'API_KEY'], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com'); + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['young_dog' => 'puppy'], + ])); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to create keys to Lokalise.'); + + $provider->write($translatorBag); + } + + public function testWriteUploadTranslationsServerError() + { + $getLanguagesResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/languages', $url); + + return new MockResponse(json_encode(['languages' => []])); + }; + + $createLanguagesResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $expectedBody = json_encode([ + 'languages' => [ + ['lang_iso' => 'en'], + ], + ]); + + $this->assertSame('POST', $method); + $this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/languages', $url); + $this->assertJsonStringEqualsJsonString($expectedBody, $options['body']); + + return new MockResponse(json_encode(['keys' => []])); + }; + + $getKeysIdsForMessagesDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $expectedQuery = [ + 'filter_keys' => '', + 'filter_filenames' => 'messages.xliff', + 'limit' => 5000, + 'page' => 1, + ]; + + $this->assertSame('GET', $method); + $this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/keys?'.http_build_query($expectedQuery), $url); + $this->assertSame($expectedQuery, $options['query']); + + return new MockResponse(json_encode(['keys' => []])); + }; + + $createKeysForMessagesDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface { + $expectedBody = json_encode([ + 'keys' => [ + [ + 'key_name' => 'young_dog', + 'platforms' => ['web'], + 'filenames' => [ + 'web' => 'messages.xliff', + 'ios' => null, + 'android' => null, + 'other' => null, + ], + ], + ], + ]); + + $this->assertSame('POST', $method); + $this->assertJsonStringEqualsJsonString($expectedBody, $options['body']); + + return new MockResponse(json_encode(['keys' => [ + [ + 'key_name' => ['web' => 'young_dog'], + 'key_id' => 29, + ], + ]])); + }; + + $updateTranslationsResponse = function (string $method, string $url, array $options = []) use (&$updateProcessed): ResponseInterface { + $this->assertSame('PUT', $method); + + return new MockResponse('', ['http_code' => 500]); + }; + + $provider = $this->createProvider((new MockHttpClient([ + $getLanguagesResponse, + $createLanguagesResponse, + $getKeysIdsForMessagesDomainResponse, + $createKeysForMessagesDomainResponse, + $updateTranslationsResponse, + ]))->withOptions([ + 'base_uri' => 'https://api.lokalise.com/api2/projects/PROJECT_ID/', + 'headers' => ['X-Api-Token' => 'API_KEY'], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com'); + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['young_dog' => 'puppy'], + ])); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to create/update translations to Lokalise.'); + + $provider->write($translatorBag); + } + /** * @dataProvider getResponsesForOneLocaleAndOneDomain */ @@ -397,10 +699,12 @@ public function testDeleteProcess() $translatorBag->addCatalogue(new MessageCatalogue('en', [ 'messages' => ['a' => 'trans_en_a'], 'validators' => ['post.num_comments' => '{count, plural, one {# comment} other {# comments}}'], + 'domain_without_missing_messages' => [], ])); $translatorBag->addCatalogue(new MessageCatalogue('fr', [ 'messages' => ['a' => 'trans_fr_a'], 'validators' => ['post.num_comments' => '{count, plural, one {# commentaire} other {# commentaires}}'], + 'domain_without_missing_messages' => [], ])); $provider = $this->createProvider( diff --git a/src/Symfony/Component/Translation/MessageCatalogue.php b/src/Symfony/Component/Translation/MessageCatalogue.php index 09153d12c4b44..4b8fc58d51343 100644 --- a/src/Symfony/Component/Translation/MessageCatalogue.php +++ b/src/Symfony/Component/Translation/MessageCatalogue.php @@ -156,19 +156,14 @@ public function replace(array $messages, string $domain = 'messages') */ public function add(array $messages, string $domain = 'messages') { - if (!isset($this->messages[$domain])) { - $this->messages[$domain] = []; - } - $intlDomain = $domain; - if (!str_ends_with($domain, self::INTL_DOMAIN_SUFFIX)) { - $intlDomain .= self::INTL_DOMAIN_SUFFIX; - } + $altDomain = str_ends_with($domain, self::INTL_DOMAIN_SUFFIX) ? substr($domain, 0, -\strlen(self::INTL_DOMAIN_SUFFIX)) : $domain.self::INTL_DOMAIN_SUFFIX; foreach ($messages as $id => $message) { - if (isset($this->messages[$intlDomain]) && \array_key_exists($id, $this->messages[$intlDomain])) { - $this->messages[$intlDomain][$id] = $message; - } else { - $this->messages[$domain][$id] = $message; - } + unset($this->messages[$altDomain][$id]); + $this->messages[$domain][$id] = $message; + } + + if ([] === ($this->messages[$altDomain] ?? null)) { + unset($this->messages[$altDomain]); } } diff --git a/src/Symfony/Component/Translation/Tests/Catalogue/MergeOperationTest.php b/src/Symfony/Component/Translation/Tests/Catalogue/MergeOperationTest.php index 3f21abac9dd52..1c9bcbc265571 100644 --- a/src/Symfony/Component/Translation/Tests/Catalogue/MergeOperationTest.php +++ b/src/Symfony/Component/Translation/Tests/Catalogue/MergeOperationTest.php @@ -57,7 +57,7 @@ public function testGetResultFromIntlDomain() { $this->assertEquals( new MessageCatalogue('en', [ - 'messages' => ['a' => 'old_a', 'b' => 'old_b'], + 'messages' => ['b' => 'old_b'], 'messages+intl-icu' => ['d' => 'old_d', 'c' => 'new_c', 'a' => 'new_a'], ]), $this->createOperation( diff --git a/src/Symfony/Component/Translation/Tests/Catalogue/TargetOperationTest.php b/src/Symfony/Component/Translation/Tests/Catalogue/TargetOperationTest.php index 2b63cd4166464..6f4de858870dc 100644 --- a/src/Symfony/Component/Translation/Tests/Catalogue/TargetOperationTest.php +++ b/src/Symfony/Component/Translation/Tests/Catalogue/TargetOperationTest.php @@ -71,7 +71,6 @@ public function testGetResultWithMixedDomains() { $this->assertEquals( new MessageCatalogue('en', [ - 'messages' => ['a' => 'old_a'], 'messages+intl-icu' => ['a' => 'new_a'], ]), $this->createOperation( @@ -103,7 +102,6 @@ public function testGetResultWithMixedDomains() $this->assertEquals( new MessageCatalogue('en', [ - 'messages' => ['a' => 'old_a'], 'messages+intl-icu' => ['b' => 'new_b', 'a' => 'new_a'], ]), $this->createOperation( diff --git a/src/Symfony/Component/Translation/Tests/TranslatorTest.php b/src/Symfony/Component/Translation/Tests/TranslatorTest.php index 57f5456d950cd..b5c332dffd8ac 100644 --- a/src/Symfony/Component/Translation/Tests/TranslatorTest.php +++ b/src/Symfony/Component/Translation/Tests/TranslatorTest.php @@ -15,6 +15,10 @@ use Symfony\Component\Translation\Exception\InvalidArgumentException; use Symfony\Component\Translation\Exception\NotFoundResourceException; use Symfony\Component\Translation\Exception\RuntimeException; +use Symfony\Component\Translation\Formatter\IntlFormatter; +use Symfony\Component\Translation\Formatter\IntlFormatterInterface; +use Symfony\Component\Translation\Formatter\MessageFormatter; +use Symfony\Component\Translation\Formatter\MessageFormatterInterface; use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\MessageCatalogue; use Symfony\Component\Translation\TranslatableMessage; @@ -563,6 +567,26 @@ public function testIntlFormattedDomain() $this->assertSame('Hi Bob', $translator->trans('some_message', ['%name%' => 'Bob'])); } + public function testIntlDomainOverlapseWithIntlResourceBefore() + { + $intlFormatterMock = $this->createMock(IntlFormatterInterface::class); + $intlFormatterMock->expects($this->once())->method('formatIntl')->with('hello intl', 'en', [])->willReturn('hello intl'); + + $messageFormatter = new MessageFormatter(null, $intlFormatterMock); + + $translator = new Translator('en', $messageFormatter); + $translator->addLoader('array', new ArrayLoader()); + + $translator->addResource('array', ['some_message' => 'hello intl'], 'en', 'messages+intl-icu'); + $translator->addResource('array', ['some_message' => 'hello'], 'en', 'messages'); + + $this->assertSame('hello', $translator->trans('some_message', [], 'messages')); + + $translator->addResource('array', ['some_message' => 'hello intl'], 'en', 'messages+intl-icu'); + + $this->assertSame('hello intl', $translator->trans('some_message', [], 'messages')); + } + public function testMissingLoaderForResourceError() { $this->expectException(RuntimeException::class); diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 8e06d5873506a..0116a138322aa 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -70,6 +70,7 @@ CHANGELOG 5.1.0 ----- + * Add `AtLeastOneOf` constraint that is considered to be valid if at least one of the nested constraints is valid * added the `Hostname` constraint and validator * added the `alpha3` option to the `Country` and `Language` constraints * allow to define a reusable set of constraints by extending the `Compound` constraint diff --git a/src/Symfony/Component/Validator/Constraints/AtLeastOneOfValidator.php b/src/Symfony/Component/Validator/Constraints/AtLeastOneOfValidator.php index 8189219655017..9ef05753cc0a5 100644 --- a/src/Symfony/Component/Validator/Constraints/AtLeastOneOfValidator.php +++ b/src/Symfony/Component/Validator/Constraints/AtLeastOneOfValidator.php @@ -34,6 +34,10 @@ public function validate(mixed $value, Constraint $constraint) $messages = [$constraint->message]; foreach ($constraint->constraints as $key => $item) { + if (!\in_array($this->context->getGroup(), $item->groups, true)) { + continue; + } + $executionContext = clone $this->context; $executionContext->setNode($value, $this->context->getObject(), $this->context->getMetadata(), $this->context->getPropertyPath()); $violations = $validator->inContext($executionContext)->validate($value, $item, $this->context->getGroup())->getViolations(); diff --git a/src/Symfony/Component/Validator/Constraints/EmailValidator.php b/src/Symfony/Component/Validator/Constraints/EmailValidator.php index 3cc4d4e69edbe..744f619fa7762 100644 --- a/src/Symfony/Component/Validator/Constraints/EmailValidator.php +++ b/src/Symfony/Component/Validator/Constraints/EmailValidator.php @@ -70,6 +70,10 @@ public function validate(mixed $value, Constraint $constraint) } if (null === $constraint->mode) { + if (Email::VALIDATION_MODE_STRICT === $this->defaultMode && !class_exists(EguliasEmailValidator::class)) { + throw new LogicException(sprintf('The "egulias/email-validator" component is required to make the "%s" constraint default to strict mode.', EguliasEmailValidator::class)); + } + $constraint->mode = $this->defaultMode; } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/AtLeastOneOfValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/AtLeastOneOfValidatorTest.php index 6be6a5d6f702c..0fb735a84cdb2 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/AtLeastOneOfValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/AtLeastOneOfValidatorTest.php @@ -19,6 +19,7 @@ use Symfony\Component\Validator\Constraints\DivisibleBy; use Symfony\Component\Validator\Constraints\EqualTo; use Symfony\Component\Validator\Constraints\Expression; +use Symfony\Component\Validator\Constraints\GreaterThan; use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; use Symfony\Component\Validator\Constraints\IdenticalTo; use Symfony\Component\Validator\Constraints\Language; @@ -235,6 +236,28 @@ public function hasMetadataFor($classOrObject): bool $this->assertSame('custom message foo', $violations->get(0)->getMessage()); $this->assertSame('This value should satisfy at least one of the following constraints: [1] custom message bar', $violations->get(1)->getMessage()); } + + public function testNestedConstraintsAreNotExecutedWhenGroupDoesNotMatch() + { + $validator = Validation::createValidator(); + + $violations = $validator->validate(50, new AtLeastOneOf([ + 'constraints' => [ + new Range([ + 'groups' => 'adult', + 'min' => 18, + 'max' => 55, + ]), + new GreaterThan([ + 'groups' => 'senior', + 'value' => 55, + ]), + ], + 'groups' => ['adult', 'senior'], + ]), 'senior'); + + $this->assertCount(1, $violations); + } } class ExpressionConstraintNested diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SequentiallyValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SequentiallyValidatorTest.php index be11448ad28e4..1dca3ccd1c186 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SequentiallyValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SequentiallyValidatorTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Validator\Tests\Constraints; +use Symfony\Component\Validator\Constraints\GreaterThan; use Symfony\Component\Validator\Constraints\NotEqualTo; use Symfony\Component\Validator\Constraints\Range; use Symfony\Component\Validator\Constraints\Regex; @@ -19,6 +20,7 @@ use Symfony\Component\Validator\Constraints\Type; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; +use Symfony\Component\Validator\Validation; class SequentiallyValidatorTest extends ConstraintValidatorTestCase { @@ -61,4 +63,26 @@ public function testStopsAtFirstConstraintWithViolations() $this->assertCount(1, $this->context->getViolations()); } + + public function testNestedConstraintsAreNotExecutedWhenGroupDoesNotMatch() + { + $validator = Validation::createValidator(); + + $violations = $validator->validate(50, new Sequentially([ + 'constraints' => [ + new GreaterThan([ + 'groups' => 'senior', + 'value' => 55, + ]), + new Range([ + 'groups' => 'adult', + 'min' => 18, + 'max' => 55, + ]), + ], + 'groups' => ['adult', 'senior'], + ]), 'adult'); + + $this->assertCount(0, $violations); + } } diff --git a/src/Symfony/Component/Yaml/Tests/DumperTest.php b/src/Symfony/Component/Yaml/Tests/DumperTest.php index 693db4ee0a684..5ac9b81661e65 100644 --- a/src/Symfony/Component/Yaml/Tests/DumperTest.php +++ b/src/Symfony/Component/Yaml/Tests/DumperTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Yaml\Dumper; use Symfony\Component\Yaml\Exception\DumpException; +use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Parser; use Symfony\Component\Yaml\Tag\TaggedValue; use Symfony\Component\Yaml\Yaml; @@ -78,7 +79,8 @@ public function testIndentationInConstructor() - foo EOF; - $this->assertEquals($expected, $dumper->dump($this->array, 4, 0)); + $this->assertSame($expected, $dumper->dump($this->array, 4, 0)); + $this->assertSameData($this->array, $this->parser->parse($expected)); } public function testSpecifications() @@ -94,14 +96,17 @@ public function testSpecifications() } $test = $this->parser->parse($yaml); - if (isset($test['dump_skip']) && $test['dump_skip']) { + if ($test['dump_skip'] ?? false) { continue; - } elseif (isset($test['todo']) && $test['todo']) { + } + + if ($test['todo'] ?? false) { // TODO - } else { - eval('$expected = '.trim($test['php']).';'); - $this->assertSame($expected, $this->parser->parse($this->dumper->dump($expected, 10)), $test['test']); + continue; } + + $expected = eval('return '.trim($test['php']).';'); + $this->assertSame($expected, $this->parser->parse($this->dumper->dump($expected, 10)), $test['test']); } } } @@ -111,8 +116,9 @@ public function testInlineLevel() $expected = <<<'EOF' { '': bar, foo: '#bar', "foo'bar": { }, bar: [1, foo, { a: A }], foobar: { foo: bar, bar: [1, foo], foobar: { foo: bar, bar: [1, foo] } } } EOF; - $this->assertEquals($expected, $this->dumper->dump($this->array, -10), '->dump() takes an inline level argument'); - $this->assertEquals($expected, $this->dumper->dump($this->array, 0), '->dump() takes an inline level argument'); + $this->assertSame($expected, $this->dumper->dump($this->array, -10), '->dump() takes an inline level argument'); + $this->assertSame($expected, $this->dumper->dump($this->array, 0), '->dump() takes an inline level argument'); + $this->assertSameData($this->array, $this->parser->parse($expected)); $expected = <<<'EOF' '': bar @@ -122,7 +128,8 @@ public function testInlineLevel() foobar: { foo: bar, bar: [1, foo], foobar: { foo: bar, bar: [1, foo] } } EOF; - $this->assertEquals($expected, $this->dumper->dump($this->array, 1), '->dump() takes an inline level argument'); + $this->assertSame($expected, $this->dumper->dump($this->array, 1), '->dump() takes an inline level argument'); + $this->assertSameData($this->array, $this->parser->parse($expected)); $expected = <<<'EOF' '': bar @@ -138,7 +145,8 @@ public function testInlineLevel() foobar: { foo: bar, bar: [1, foo] } EOF; - $this->assertEquals($expected, $this->dumper->dump($this->array, 2), '->dump() takes an inline level argument'); + $this->assertSame($expected, $this->dumper->dump($this->array, 2), '->dump() takes an inline level argument'); + $this->assertSameData($this->array, $this->parser->parse($expected)); $expected = <<<'EOF' '': bar @@ -159,7 +167,8 @@ public function testInlineLevel() bar: [1, foo] EOF; - $this->assertEquals($expected, $this->dumper->dump($this->array, 3), '->dump() takes an inline level argument'); + $this->assertSame($expected, $this->dumper->dump($this->array, 3), '->dump() takes an inline level argument'); + $this->assertSameData($this->array, $this->parser->parse($expected)); $expected = <<<'EOF' '': bar @@ -182,22 +191,23 @@ public function testInlineLevel() - foo EOF; - $this->assertEquals($expected, $this->dumper->dump($this->array, 4), '->dump() takes an inline level argument'); - $this->assertEquals($expected, $this->dumper->dump($this->array, 10), '->dump() takes an inline level argument'); + $this->assertSame($expected, $this->dumper->dump($this->array, 4), '->dump() takes an inline level argument'); + $this->assertSame($expected, $this->dumper->dump($this->array, 10), '->dump() takes an inline level argument'); + $this->assertSameData($this->array, $this->parser->parse($expected)); } public function testObjectSupportEnabled() { $dump = $this->dumper->dump(['foo' => new A(), 'bar' => 1], 0, 0, Yaml::DUMP_OBJECT); - $this->assertEquals('{ foo: !php/object \'O:30:"Symfony\Component\Yaml\Tests\A":1:{s:1:"a";s:3:"foo";}\', bar: 1 }', $dump, '->dump() is able to dump objects'); + $this->assertSame('{ foo: !php/object \'O:30:"Symfony\Component\Yaml\Tests\A":1:{s:1:"a";s:3:"foo";}\', bar: 1 }', $dump, '->dump() is able to dump objects'); } public function testObjectSupportDisabledButNoExceptions() { $dump = $this->dumper->dump(['foo' => new A(), 'bar' => 1]); - $this->assertEquals('{ foo: null, bar: 1 }', $dump, '->dump() does not dump objects when disabled'); + $this->assertSame('{ foo: null, bar: 1 }', $dump, '->dump() does not dump objects when disabled'); } public function testObjectSupportDisabledWithExceptions() @@ -211,7 +221,8 @@ public function testObjectSupportDisabledWithExceptions() */ public function testEscapedEscapeSequencesInQuotedScalar($input, $expected) { - $this->assertEquals($expected, $this->dumper->dump($input)); + $this->assertSame($expected, $this->dumper->dump($input)); + $this->assertSameData($input, $this->parser->parse($expected)); } public function getEscapeSequences() @@ -261,7 +272,7 @@ public function testDumpObjectAsMap($object, $expected) { $yaml = $this->dumper->dump($object, 0, 0, Yaml::DUMP_OBJECT_AS_MAP); - $this->assertEquals($expected, Yaml::parse($yaml, Yaml::PARSE_OBJECT_FOR_MAP)); + $this->assertSameData($expected, $this->parser->parse($yaml, Yaml::PARSE_OBJECT_FOR_MAP)); } public function objectAsMapProvider() @@ -339,7 +350,7 @@ public function testDumpingArrayObjectInstancesWithNumericKeysRespectsInlineLeve 2: { 0: d, 1: e } YAML; - $this->assertEquals($expected, $yaml); + $this->assertSame($expected, $yaml); } public function testDumpEmptyArrayObjectInstanceAsMap() @@ -378,6 +389,7 @@ public function testDumpingStdClassInstancesRespectsInlineLevel() YAML; $this->assertSame($expected, $yaml); + $this->assertSameData($outer, $this->parser->parse($yaml, Yaml::PARSE_OBJECT_FOR_MAP)); } public function testDumpingTaggedValueSequenceRespectsInlineLevel() @@ -403,6 +415,40 @@ public function testDumpingTaggedValueSequenceRespectsInlineLevel() YAML; $this->assertSame($expected, $yaml); + $this->assertSameData($data, $this->parser->parse($expected, Yaml::PARSE_CUSTOM_TAGS)); + } + + public function testDumpingTaggedValueTopLevelScalar() + { + $data = new TaggedValue('user', 'jane'); + + $yaml = $this->dumper->dump($data); + + $expected = '!user jane'; + $this->assertSame($expected, $yaml); + $this->assertSameData($data, $this->parser->parse($yaml, Yaml::PARSE_CUSTOM_TAGS)); + } + + public function testDumpingTaggedValueTopLevelAssocInline() + { + $data = new TaggedValue('user', ['name' => 'jane']); + + $yaml = $this->dumper->dump($data); + + $expected = '!user { name: jane }'; + $this->assertSame($expected, $yaml); + $this->assertSameData($data, $this->parser->parse($yaml, Yaml::PARSE_CUSTOM_TAGS)); + } + + public function testDumpingTaggedValueSpecialCharsInTag() + { + // @todo Validate the tag name in the TaggedValue constructor. + $data = new TaggedValue('a b @ c', 5); + $expected = '!a b @ c 5'; + $this->assertSame($expected, $this->dumper->dump($data)); + // The data changes after a round trip, due to the illegal tag name. + $data = new TaggedValue('a', 'b @ c 5'); + $this->assertSameData($data, $this->parser->parse($expected, Yaml::PARSE_CUSTOM_TAGS)); } public function testDumpingTaggedValueSequenceWithInlinedTagValues() @@ -415,6 +461,7 @@ public function testDumpingTaggedValueSequenceWithInlinedTagValues() 'john', 'claire', ]), + new TaggedValue('number', 5), ]; $yaml = $this->dumper->dump($data, 1); @@ -422,9 +469,13 @@ public function testDumpingTaggedValueSequenceWithInlinedTagValues() $expected = <<assertSame($expected, $yaml); + // @todo Fix the parser, preserve numbers. + $data[2] = new TaggedValue('number', '5'); + $this->assertSameData($data, $this->parser->parse($expected, Yaml::PARSE_CUSTOM_TAGS)); } public function testDumpingTaggedValueMapRespectsInlineLevel() @@ -437,6 +488,7 @@ public function testDumpingTaggedValueMapRespectsInlineLevel() 'john', 'claire', ]), + 'count' => new TaggedValue('number', 5), ]; $yaml = $this->dumper->dump($data, 2); @@ -447,9 +499,13 @@ public function testDumpingTaggedValueMapRespectsInlineLevel() names1: !names - john - claire +count: !number 5 YAML; $this->assertSame($expected, $yaml); + // @todo Fix the parser, preserve numbers. + $data['count'] = new TaggedValue('number', '5'); + $this->assertSameData($data, $this->parser->parse($expected, Yaml::PARSE_CUSTOM_TAGS)); } public function testDumpingTaggedValueMapWithInlinedTagValues() @@ -472,6 +528,7 @@ public function testDumpingTaggedValueMapWithInlinedTagValues() YAML; $this->assertSame($expected, $yaml); + $this->assertSameData($data, $this->parser->parse($expected, Yaml::PARSE_CUSTOM_TAGS)); } public function testDumpingNotInlinedScalarTaggedValue() @@ -487,6 +544,7 @@ public function testDumpingNotInlinedScalarTaggedValue() YAML; $this->assertSame($expected, $this->dumper->dump($data, 2)); + $this->assertSameData($data, $this->parser->parse($expected, Yaml::PARSE_CUSTOM_TAGS)); } public function testDumpingNotInlinedNullTaggedValue() @@ -500,6 +558,10 @@ public function testDumpingNotInlinedNullTaggedValue() YAML; $this->assertSame($expected, $this->dumper->dump($data, 2)); + + // @todo Fix the parser, don't stringify null. + $data['foo'] = new TaggedValue('bar', 'null'); + $this->assertSameData($data, $this->parser->parse($expected, Yaml::PARSE_CUSTOM_TAGS | Yaml::PARSE_CONSTANT)); } public function testDumpingMultiLineStringAsScalarBlockTaggedValue() @@ -519,6 +581,53 @@ public function testDumpingMultiLineStringAsScalarBlockTaggedValue() ' baz'; $this->assertSame($expected, $this->dumper->dump($data, 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)); + $this->assertSameData($data, $this->parser->parse($expected, Yaml::PARSE_CUSTOM_TAGS)); + } + + public function testDumpingTaggedMultiLineInList() + { + $data = [ + new TaggedValue('bar', "a\nb"), + ]; + $expected = "- !bar |\n a\n b"; + $this->assertSame($expected, $this->dumper->dump($data, 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)); + + // @todo Fix the parser, eliminate these exceptions. + $this->expectException(ParseException::class); + $this->expectExceptionMessage('Unable to parse at line 3 (near "!bar |").'); + + $this->parser->parse($expected, Yaml::PARSE_CUSTOM_TAGS); + } + + public function testDumpingTaggedMultiLineTrailingNewlinesInMap() + { + $data = [ + 'foo' => new TaggedValue('bar', "a\nb\n\n\n"), + ]; + $expected = "foo: !bar |\n a\n b\n \n \n "; + $this->assertSame($expected, $this->dumper->dump($data, 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)); + + // @todo Fix the parser, the result should be identical to $data. + $this->assertSameData( + [ + 'foo' => new TaggedValue('bar', "a\nb\n"), + ], + $this->parser->parse($expected, Yaml::PARSE_CUSTOM_TAGS)); + } + + public function testDumpingTaggedMultiLineTrailingNewlinesInList() + { + $data = [ + new TaggedValue('bar', "a\nb\n\n\n"), + ]; + $expected = "- !bar |\n a\n b\n \n \n "; + $this->assertSame($expected, $this->dumper->dump($data, 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)); + + // @todo Fix the parser, eliminate these exceptions. + $this->expectException(ParseException::class); + $this->expectExceptionMessage('Unable to parse at line 6 (near "!bar |").'); + + $this->parser->parse($expected, Yaml::PARSE_CUSTOM_TAGS); } public function testDumpingInlinedMultiLineIfRnBreakLineInTaggedValue() @@ -528,8 +637,14 @@ public function testDumpingInlinedMultiLineIfRnBreakLineInTaggedValue() 'foo' => new TaggedValue('bar', "foo\r\nline with trailing spaces:\n \nbar\ninteger like line:\n123456789\nempty line:\n\nbaz"), ], ]; + $expected = <<<'YAML' +data: + foo: !bar "foo\r\nline with trailing spaces:\n \nbar\ninteger like line:\n123456789\nempty line:\n\nbaz" - $this->assertSame(file_get_contents(__DIR__.'/Fixtures/multiple_lines_as_literal_block_for_tagged_values.yml'), $this->dumper->dump($data, 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)); +YAML; + $yml = $this->dumper->dump($data, 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + $this->assertSame($expected, $yml); + $this->assertSameData($data, $this->parser->parse($expected, Yaml::PARSE_CUSTOM_TAGS)); } public function testDumpMultiLineStringAsScalarBlock() @@ -544,8 +659,27 @@ public function testDumpMultiLineStringAsScalarBlock() ], ], ]; - - $this->assertSame(file_get_contents(__DIR__.'/Fixtures/multiple_lines_as_literal_block.yml'), $this->dumper->dump($data, 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)); + $yml = $this->dumper->dump($data, 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + $expected = str_replace("@\n", "\n", <<<'YAML' +data: + single_line: 'foo bar baz' + multi_line: |- + foo + line with trailing spaces: + @ + bar + integer like line: + 123456789 + empty line: + + baz + multi_line_with_carriage_return: "foo\nbar\r\nbaz" + nested_inlined_multi_line_string: { inlined_multi_line: "foo\nbar\r\nempty line:\n\nbaz" } + +YAML +); + $this->assertSame($expected, $yml); + $this->assertSame($data, $this->parser->parse($yml)); } public function testDumpMultiLineStringAsScalarBlockWhenFirstLineHasLeadingSpace() @@ -558,27 +692,33 @@ public function testDumpMultiLineStringAsScalarBlockWhenFirstLineHasLeadingSpace $expected = "data:\n multi_line: |4-\n the first line has leading spaces\n The second line does not."; - $this->assertSame($expected, $this->dumper->dump($data, 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)); + $yml = $this->dumper->dump($data, 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + $this->assertSame($expected, $yml); + $this->assertSame($data, $this->parser->parse($yml)); } public function testCarriageReturnFollowedByNewlineIsMaintainedWhenDumpingAsMultiLineLiteralBlock() { - $this->assertSame("- \"a\\r\\nb\\nc\"\n", $this->dumper->dump(["a\r\nb\nc"], 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)); + $data = ["a\r\nb\nc"]; + $expected = "- \"a\\r\\nb\\nc\"\n"; + $this->assertSame($expected, $this->dumper->dump($data, 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)); + $this->assertSame($data, $this->parser->parse($expected)); } public function testCarriageReturnNotFollowedByNewlineIsPreservedWhenDumpingAsMultiLineLiteralBlock() { + $data = [ + 'parent' => [ + 'foo' => "bar\n\rbaz: qux", + ], + ]; $expected = <<<'YAML' parent: foo: "bar\n\rbaz: qux" YAML; - - $this->assertSame($expected, $this->dumper->dump([ - 'parent' => [ - 'foo' => "bar\n\rbaz: qux", - ], - ], 4, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)); + $this->assertSame($expected, $this->dumper->dump($data, 4, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)); + $this->assertSame($data, $this->parser->parse($expected)); } public function testNoExtraTrailingNewlineWhenDumpingAsMultiLineLiteralBlock() @@ -590,7 +730,15 @@ public function testNoExtraTrailingNewlineWhenDumpingAsMultiLineLiteralBlock() $yaml = $this->dumper->dump($data, 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); $this->assertSame("- |-\n a\n b\n- |-\n c\n d", $yaml); - $this->assertSame($data, Yaml::parse($yaml)); + $this->assertSame($data, $this->parser->parse($yaml)); + } + + public function testTopLevelMultiLineStringLiteral() + { + $data = "a\nb\n"; + $yaml = $this->dumper->dump($data, 2, 0, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + $this->assertSame('"a\nb\n"', $yaml); + $this->assertSame($data, $this->parser->parse($yaml)); } public function testDumpTrailingNewlineInMultiLineLiteralBlocks() @@ -600,6 +748,7 @@ public function testDumpTrailingNewlineInMultiLineLiteralBlocks() 'clip 2' => "one\ntwo\n", 'keep 1' => "one\ntwo\n", 'keep 2' => "one\ntwo\n\n", + 'keep 3' => "one\ntwo\n\n\n", 'strip 1' => "one\ntwo", 'strip 2' => "one\ntwo", ]; @@ -619,6 +768,11 @@ public function testDumpTrailingNewlineInMultiLineLiteralBlocks() one two +'keep 3': |+ + one + two + + 'strip 1': |- one two @@ -628,7 +782,7 @@ public function testDumpTrailingNewlineInMultiLineLiteralBlocks() YAML; $this->assertSame($expected, $yaml); - $this->assertSame($data, Yaml::parse($yaml)); + $this->assertSame($data, $this->parser->parse($yaml)); } public function testZeroIndentationThrowsException() @@ -664,6 +818,15 @@ public function testDumpIdeographicSpaces() 'regular_space' => 'a b', ], 2)); } + + private function assertSameData($expected, $actual) + { + $this->assertEquals($expected, $actual); + $this->assertSame( + var_export($expected, true), + var_export($actual, true) + ); + } } class A diff --git a/src/Symfony/Component/Yaml/Tests/Fixtures/multiple_lines_as_literal_block.yml b/src/Symfony/Component/Yaml/Tests/Fixtures/multiple_lines_as_literal_block.yml deleted file mode 100644 index 1f61eb1216a52..0000000000000 --- a/src/Symfony/Component/Yaml/Tests/Fixtures/multiple_lines_as_literal_block.yml +++ /dev/null @@ -1,14 +0,0 @@ -data: - single_line: 'foo bar baz' - multi_line: |- - foo - line with trailing spaces: - - bar - integer like line: - 123456789 - empty line: - - baz - multi_line_with_carriage_return: "foo\nbar\r\nbaz" - nested_inlined_multi_line_string: { inlined_multi_line: "foo\nbar\r\nempty line:\n\nbaz" } diff --git a/src/Symfony/Component/Yaml/Tests/Fixtures/multiple_lines_as_literal_block_for_tagged_values.yml b/src/Symfony/Component/Yaml/Tests/Fixtures/multiple_lines_as_literal_block_for_tagged_values.yml deleted file mode 100644 index f8c9112fd52a5..0000000000000 --- a/src/Symfony/Component/Yaml/Tests/Fixtures/multiple_lines_as_literal_block_for_tagged_values.yml +++ /dev/null @@ -1,2 +0,0 @@ -data: - foo: !bar "foo\r\nline with trailing spaces:\n \nbar\ninteger like line:\n123456789\nempty line:\n\nbaz" diff --git a/src/Symfony/Component/Yaml/Tests/ParserTest.php b/src/Symfony/Component/Yaml/Tests/ParserTest.php index 769af36eaa6dd..228c2f2ee9c69 100644 --- a/src/Symfony/Component/Yaml/Tests/ParserTest.php +++ b/src/Symfony/Component/Yaml/Tests/ParserTest.php @@ -34,6 +34,90 @@ protected function tearDown(): void chmod(__DIR__.'/Fixtures/not_readable.yml', 0644); } + public function testTopLevelNumber() + { + $yml = '5'; + $data = $this->parser->parse($yml); + $expected = 5; + $this->assertSameData($expected, $data); + } + + public function testTopLevelNull() + { + $yml = 'null'; + $data = $this->parser->parse($yml); + $expected = null; + $this->assertSameData($expected, $data); + } + + public function testTaggedValueTopLevelNumber() + { + $yml = '!number 5'; + $data = $this->parser->parse($yml, Yaml::PARSE_CUSTOM_TAGS); + // @todo Preserve the number, don't turn into string. + $expected = new TaggedValue('number', '5'); + $this->assertSameData($expected, $data); + } + + public function testTaggedValueTopLevelNull() + { + $yml = '!tag null'; + $data = $this->parser->parse($yml, Yaml::PARSE_CUSTOM_TAGS); + // @todo Preserve literal null, don't turn into string. + $expected = new TaggedValue('tag', 'null'); + $this->assertSameData($expected, $data); + } + + public function testTaggedValueTopLevelString() + { + $yml = '!user barbara'; + $data = $this->parser->parse($yml, Yaml::PARSE_CUSTOM_TAGS); + $expected = new TaggedValue('user', 'barbara'); + $this->assertSameData($expected, $data); + } + + public function testTaggedValueTopLevelAssocInline() + { + $yml = '!user { name: barbara }'; + $data = $this->parser->parse($yml, Yaml::PARSE_CUSTOM_TAGS); + $expected = new TaggedValue('user', ['name' => 'barbara']); + $this->assertSameData($expected, $data); + } + + public function testTaggedValueTopLevelAssoc() + { + $yml = <<<'YAML' +!user +name: barbara +YAML; + $data = $this->parser->parse($yml, Yaml::PARSE_CUSTOM_TAGS); + $expected = new TaggedValue('user', ['name' => 'barbara']); + $this->assertSameData($expected, $data); + } + + public function testTaggedValueTopLevelList() + { + $yml = <<<'YAML' +!users +- barbara +YAML; + $data = $this->parser->parse($yml, Yaml::PARSE_CUSTOM_TAGS); + $expected = new TaggedValue('users', ['barbara']); + $this->assertSameData($expected, $data); + } + + public function testTaggedTextAsListItem() + { + $yml = <<<'YAML' +- !text | + first line +YAML; + // @todo Fix the parser, eliminate this exception. + $this->expectException(ParseException::class); + $this->expectExceptionMessage('Unable to parse at line 2 (near "!text |").'); + $this->parser->parse($yml, Yaml::PARSE_CUSTOM_TAGS); + } + /** * @dataProvider getDataFormSpecifications */ @@ -104,7 +188,7 @@ public function testParserIsStateless() public function testValidTokenSeparation(string $given, array $expected) { $actual = $this->parser->parse($given); - $this->assertEquals($expected, $actual); + $this->assertSameData($expected, $actual); } public function validTokenSeparators(): array @@ -482,7 +566,7 @@ public function testObjectSupportEnabled() foo: !php/object O:30:"Symfony\Component\Yaml\Tests\B":1:{s:1:"b";s:3:"foo";} bar: 1 EOF; - $this->assertEquals(['foo' => new B(), 'bar' => 1], $this->parser->parse($input, Yaml::PARSE_OBJECT), '->parse() is able to parse objects'); + $this->assertSameData(['foo' => new B(), 'bar' => 1], $this->parser->parse($input, Yaml::PARSE_OBJECT), '->parse() is able to parse objects'); } public function testObjectSupportDisabledButNoExceptions() @@ -491,7 +575,7 @@ public function testObjectSupportDisabledButNoExceptions() foo: !php/object O:30:"Symfony\Tests\Component\Yaml\B":1:{s:1:"b";s:3:"foo";} bar: 1 EOF; - $this->assertEquals(['foo' => null, 'bar' => 1], $this->parser->parse($input), '->parse() does not parse objects'); + $this->assertSameData(['foo' => null, 'bar' => 1], $this->parser->parse($input), '->parse() does not parse objects'); } /** @@ -501,7 +585,7 @@ public function testObjectForMap($yaml, $expected) { $flags = Yaml::PARSE_OBJECT_FOR_MAP; - $this->assertEquals($expected, $this->parser->parse($yaml, $flags)); + $this->assertSameData($expected, $this->parser->parse($yaml, $flags)); } public function getObjectForMapTests() @@ -956,12 +1040,12 @@ public function testEmptyValue() hash: EOF; - $this->assertEquals(['hash' => null], Yaml::parse($input)); + $this->assertSame(['hash' => null], Yaml::parse($input)); } public function testCommentAtTheRootIndent() { - $this->assertEquals([ + $this->assertSame([ 'services' => [ 'app.foo_service' => [ 'class' => 'Foo', @@ -987,7 +1071,7 @@ class: Bar public function testStringBlockWithComments() { - $this->assertEquals(['content' => <<<'EOT' + $this->assertSame(['content' => <<<'EOT' # comment 1 header @@ -1015,7 +1099,7 @@ public function testStringBlockWithComments() public function testFoldedStringBlockWithComments() { - $this->assertEquals([['content' => <<<'EOT' + $this->assertSame([['content' => <<<'EOT' # comment 1 header @@ -1044,7 +1128,7 @@ public function testFoldedStringBlockWithComments() public function testNestedFoldedStringBlockWithComments() { - $this->assertEquals([[ + $this->assertSame([[ 'title' => 'some title', 'content' => <<<'EOT' # comment 1 @@ -1076,7 +1160,7 @@ public function testNestedFoldedStringBlockWithComments() public function testReferenceResolvingInInlineStrings() { - $this->assertEquals([ + $this->assertSame([ 'var' => 'var-value', 'scalar' => 'var-value', 'list' => ['var-value'], @@ -1116,7 +1200,7 @@ public function testYamlDirective() foo: 1 bar: 2 EOF; - $this->assertEquals(['foo' => 1, 'bar' => 2], $this->parser->parse($yaml)); + $this->assertSame(['foo' => 1, 'bar' => 2], $this->parser->parse($yaml)); } public function testFloatKeys() @@ -1166,7 +1250,7 @@ public function testExplicitStringCasting() '~' => 'null', ]; - $this->assertEquals($expected, $this->parser->parse($yaml)); + $this->assertSame($expected, $this->parser->parse($yaml)); } public function testColonInMappingValueException() @@ -1467,7 +1551,7 @@ public function testParseDateAsMappingValue() $expectedDate->setDate(2002, 12, 14); $expectedDate->setTime(0, 0, 0); - $this->assertEquals(['date' => $expectedDate], $this->parser->parse($yaml, Yaml::PARSE_DATETIME)); + $this->assertSameData(['date' => $expectedDate], $this->parser->parse($yaml, Yaml::PARSE_DATETIME)); } /** @@ -1687,7 +1771,7 @@ public function testBackslashInSingleQuotedString() public function testParseMultiLineString() { - $this->assertEquals("foo bar\nbaz", $this->parser->parse("foo\nbar\n\nbaz")); + $this->assertSame("foo bar\nbaz", $this->parser->parse("foo\nbar\n\nbaz")); } /** @@ -1695,7 +1779,7 @@ public function testParseMultiLineString() */ public function testParseMultiLineMappingValue($yaml, $expected, $parseError) { - $this->assertEquals($expected, $this->parser->parse($yaml)); + $this->assertSame($expected, $this->parser->parse($yaml)); } public function multiLineDataProvider() @@ -1762,7 +1846,7 @@ public function multiLineDataProvider() */ public function testInlineNotationSpanningMultipleLines($expected, string $yaml) { - $this->assertEquals($expected, $this->parser->parse($yaml)); + $this->assertSame($expected, $this->parser->parse($yaml)); } public function inlineNotationSpanningMultipleLinesProvider(): array @@ -2136,7 +2220,7 @@ public function testRootLevelInlineMappingFollowedByMoreContentIsInvalid() public function testTaggedInlineMapping() { - $this->assertEquals(new TaggedValue('foo', ['foo' => 'bar']), $this->parser->parse('!foo {foo: bar}', Yaml::PARSE_CUSTOM_TAGS)); + $this->assertSameData(new TaggedValue('foo', ['foo' => 'bar']), $this->parser->parse('!foo {foo: bar}', Yaml::PARSE_CUSTOM_TAGS)); } public function testInvalidInlineSequenceContainingStringWithEscapedQuotationCharacter() @@ -2151,7 +2235,7 @@ public function testInvalidInlineSequenceContainingStringWithEscapedQuotationCha */ public function testCustomTagSupport($expected, $yaml) { - $this->assertEquals($expected, $this->parser->parse($yaml, Yaml::PARSE_CUSTOM_TAGS)); + $this->assertSameData($expected, $this->parser->parse($yaml, Yaml::PARSE_CUSTOM_TAGS)); } public function taggedValuesProvider() @@ -2347,7 +2431,7 @@ public function testCanParseVeryLongValue() $yamlString = Yaml::dump($trickyVal); $arrayFromYaml = $this->parser->parse($yamlString); - $this->assertEquals($trickyVal, $arrayFromYaml); + $this->assertSame($trickyVal, $arrayFromYaml); } public function testParserCleansUpReferencesBetweenRuns() @@ -2462,7 +2546,7 @@ public function testMergeKeysWhenMappingsAreParsedAsObjects() ], ]; - $this->assertEquals($expected, $this->parser->parse($yaml, Yaml::PARSE_OBJECT_FOR_MAP)); + $this->assertSameData($expected, $this->parser->parse($yaml, Yaml::PARSE_OBJECT_FOR_MAP)); } public function testFilenamesAreParsedAsStringsWithoutFlag() @@ -2555,7 +2639,7 @@ public function testParseReferencesOnMergeKeysWithMappingsParsedAsObjects() ], ]; - $this->assertEquals($expected, $this->parser->parse($yaml, Yaml::PARSE_OBJECT_FOR_MAP)); + $this->assertSameData($expected, $this->parser->parse($yaml, Yaml::PARSE_OBJECT_FOR_MAP)); } public function testEvalRefException() @@ -2830,6 +2914,15 @@ public function testParseIdeographicSpaces() 'regular_space' => 'a b', ], $this->parser->parse($expected)); } + + private function assertSameData($expected, $actual) + { + $this->assertEquals($expected, $actual); + $this->assertSame( + var_export($expected, true), + var_export($actual, true) + ); + } } class B