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/CHANGELOG-6.0.md b/CHANGELOG-6.0.md index 54219602ca6bb..cc075a6faef9d 100644 --- a/CHANGELOG-6.0.md +++ b/CHANGELOG-6.0.md @@ -7,6 +7,42 @@ 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.12 (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 #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 #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 #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.0.11 (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 30dd92ff2ff3b..ad35374179765 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; @@ -96,6 +97,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 7a8e0cc555246..abbbdc05bd3ce 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 b00de47e0890c..0648e12c1a8c9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -155,8 +155,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\PhpStanExtractor; @@ -2382,11 +2384,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 822b35ea75d57..a28d12b18c377 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Test/KernelTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Test/KernelTestCase.php @@ -69,7 +69,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 c2eb6b880139d..1cef71ac06e66 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -50,6 +50,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; @@ -1909,7 +1911,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/Component/Console/Application.php b/src/Symfony/Component/Console/Application.php index 4ce5022a30893..c388427b70d8d 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 0802f1b38bd74..2fd311a4fbff0 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 b425449ef389f..66f86a5f75ea1 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 307c0179417cf..70e54870b6dab 100644 --- a/src/Symfony/Component/Console/Tests/ApplicationTest.php +++ b/src/Symfony/Component/Console/Tests/ApplicationTest.php @@ -21,6 +21,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; @@ -42,6 +43,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 @@ -1987,25 +2070,26 @@ public function isEnabled(): bool } } -class SignableCommand extends Command implements SignalableCommandInterface +class BaseSignableCommand extends Command { public $signaled = false; - public $loop = 100; + public $loop = 1000; + private $emitsSignal; protected static $defaultName = 'signal'; - public function getSubscribedSignals(): array + public function __construct(bool $emitsSignal = true) { - return SignalRegistry::isSupported() ? [\SIGALRM] : []; - } - - public function handleSignal(int $signal): void - { - $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) { @@ -2016,3 +2100,34 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } } + +class SignableCommand extends BaseSignableCommand implements SignalableCommandInterface +{ + protected static $defaultName = 'signal'; + + 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 e3c9476cf6d5e..3cd410fd7be4a 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 0bbd5b4772aff..6d3755e0a6ac3 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 = ''; } @@ -574,7 +574,7 @@ public static function makeRelative(string $path, string $basePath): string */ public static function isLocal(string $path): bool { - return '' !== $path && false === mb_strpos($path, '://'); + return '' !== $path && false === strpos($path, '://'); } /** @@ -638,7 +638,7 @@ public static function getLongestCommonBasePath(string ...$paths): ?string // Prevent false positives for common prefixes // see isBasePath() - if (0 === mb_strpos($path.'/', $basePath.'/')) { + if (0 === strpos($path.'/', $basePath.'/')) { // next path continue 2; } @@ -666,12 +666,12 @@ public static function join(string ...$paths): string if (null === $finalPath) { // For first part we keep slashes, like '/top', 'C:\' or 'phar://' $finalPath = $path; - $wasScheme = (false !== mb_strpos($path, '://')); + $wasScheme = (false !== strpos($path, '://')); continue; } // 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 .= '/'; } @@ -717,7 +717,7 @@ public static function isBasePath(string $basePath, string $ofPath): bool // Don't append a slash for the root "/", because then that root // won't be discovered as common prefix ("//" is not a prefix of // "/foobar/"). - return 0 === mb_strpos($ofPath.'/', rtrim($basePath, '/').'/'); + return 0 === strpos($ofPath.'/', rtrim($basePath, '/').'/'); } /** @@ -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 (0 === mb_strpos($path, '/')) { + if (0 === strpos($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 f73187bb4d5ac..de835794afcc8 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) || false === strpos($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 dc900d9beae58..2934fc86332c1 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 03b07a2dcbfe0..75697f409bc41 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 9f5cccc65a8a2..42405d95788e0 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 f7529196b2a9f..8994325fa970b 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 cf8b887400e3d..b4c5a6a90eae3 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 684409a9b84b0..8a59c93c9fd14 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 238b96c60b713..697a69b4bce12 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.0.11'; - public const VERSION_ID = 60011; + public const VERSION = '6.0.12'; + public const VERSION_ID = 60012; public const MAJOR_VERSION = 6; public const MINOR_VERSION = 0; - public const RELEASE_VERSION = 11; + public const RELEASE_VERSION = 12; 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 cbfb4f6b1314c..f60b96229beb8 100644 --- a/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php +++ b/src/Symfony/Component/Mailer/Transport/Smtp/EsmtpTransport.php @@ -100,15 +100,7 @@ public function addAuthenticator(AuthenticatorInterface $authenticator): void protected function doHeloCommand(): void { - try { - $response = $this->executeCommand(sprintf("EHLO %s\r\n", $this->getLocalDomain()), [250]); - } catch (TransportExceptionInterface $e) { - parent::doHeloCommand(); - - return; - } - - $capabilities = $this->getCapabilities($response); + $capabilities = $this->callHeloCommand(); /** @var SocketStream $stream */ $stream = $this->getStream(); @@ -122,14 +114,7 @@ protected function doHeloCommand(): void throw new TransportException('Unable to connect with STARTTLS.'); } - try { - $response = $this->executeCommand(sprintf("EHLO %s\r\n", $this->getLocalDomain()), [250]); - $capabilities = $this->getCapabilities($response); - } catch (TransportExceptionInterface $e) { - parent::doHeloCommand(); - - return; - } + $capabilities = $this->callHeloCommand(); } if (\array_key_exists('AUTH', $capabilities)) { @@ -137,10 +122,24 @@ protected function doHeloCommand(): void } } - private function getCapabilities(string $ehloResponse): array + private function callHeloCommand(): array { + try { + $response = $this->executeCommand(sprintf("EHLO %s\r\n", $this->getLocalDomain()), [250]); + } catch (TransportExceptionInterface $e) { + try { + parent::doHeloCommand(); + } catch (TransportExceptionInterface $ex) { + if (!$ex->getCode()) { + throw $e; + } + + throw $ex; + } + } + $capabilities = []; - $lines = explode("\r\n", trim($ehloResponse)); + $lines = explode("\r\n", trim($response)); array_shift($lines); foreach ($lines as $line) { if (preg_match('/^[0-9]{3}[ -]([A-Z0-9-]+)((?:[ =].*)?)$/Di', $line, $matches)) { diff --git a/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php b/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php index 6fcb20044254c..a83c7df4bbb16 100644 --- a/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php +++ b/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php @@ -297,15 +297,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 d9aeab5f5c38b..119ef48c24901 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 c73f74a858bfe..4bc57ed83d06b 100644 --- a/src/Symfony/Component/Mime/Email.php +++ b/src/Symfony/Component/Mime/Email.php @@ -479,7 +479,7 @@ private function prepareParts(): ?array if (null !== $html) { $htmlPart = new TextPart($html, $this->htmlCharset, 'html'); $html = $htmlPart->getBody(); - preg_match_all('(]*src\s*=\s*(?:([\'"])cid:([^"]+)\\1|cid:([^>\s]+)))i', $html, $names); + preg_match_all('(]*src\s*=\s*(?:([\'"])cid:(.+?)\\1|cid:([^>\s]+)))i', $html, $names); $names = array_filter(array_unique(array_merge($names[2], $names[3]))); } 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 1385a8ba409d2..d08d382e42c83 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 5946060fbfdf1..9bf138bc9ec7f 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/Encoder/ChainDecoder.php b/src/Symfony/Component/Serializer/Encoder/ChainDecoder.php index 3348e3e199570..17c030e6da6ba 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 514e30909c6d0..da6e99dd5e938 100644 --- a/src/Symfony/Component/Serializer/Encoder/ChainEncoder.php +++ b/src/Symfony/Component/Serializer/Encoder/ChainEncoder.php @@ -85,9 +85,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/Mapping/Loader/AnnotationLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php index 12eb7eb07c66f..aa40a90c866c8 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 ba17cae4ace25..afe21db314bd5 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): bool + public function supportsNormalization(mixed $data, string $format = null): bool { return $data instanceof \BackedEnum; } @@ -47,7 +47,7 @@ public function supportsNormalization($data, $format = null): bool * * @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): bool + public function supportsDenormalization(mixed $data, string $type, string $format = null): 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 f447c684a39eb..08656c2ba2215 100644 --- a/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; 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; @@ -29,7 +30,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([ @@ -37,6 +38,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); @@ -46,6 +48,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]); @@ -53,10 +57,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() 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() @@ -166,6 +167,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 f36120465da5a..6800d50e0be0b 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/BackedEnumNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/BackedEnumNormalizerTest.php @@ -115,8 +115,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 8564430be1b3c..cf96a83780bac 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 82691e1e799c5..8e29da0530826 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 b3649b6ccc4c6..e1e2c5a1947be 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 dc7a1c24f6cf2..f9dc926ef0796 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 f5be4ab40bf52..e28235d264d5e 100644 --- a/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php +++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php @@ -148,12 +148,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); + } } } } @@ -165,8 +169,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) { @@ -190,8 +198,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']; } @@ -212,8 +224,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); + } } } } @@ -234,13 +250,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. @@ -249,8 +271,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); + } } } } @@ -263,8 +289,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); + } } } @@ -288,8 +318,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 b5c49e0fa6e22..e38f9bf37f0a6 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 */ @@ -363,6 +809,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(); diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php b/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php index c03987269ae92..da91dc73dbc90 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 7aa27efdad216..848594b6be530 100644 --- a/src/Symfony/Component/Translation/MessageCatalogue.php +++ b/src/Symfony/Component/Translation/MessageCatalogue.php @@ -155,19 +155,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 f4f46788d106c..ea8248cb64c8f 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\Translator; @@ -559,6 +563,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 1a095ee6fe9ec..98e60ea3ac095 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -61,6 +61,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 2ebbbd047313c..5eebd102618d1 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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy