From 4df8b535e1a34446c0d4edd2a27a30fa2638a97f Mon Sep 17 00:00:00 2001
From: Kirill
+
+
+
+ Yii Framework API Demo Project
+
+
O<95uT9cS|^5>1IdOnlDBXxrZD(dpX4Hn}S{DVTYFus*{N>$T6b zoF4dxqW8DAC;ppx+vsP7?s#^;q$T^%2*C-wecI~aL#;fUu*z{3`e1_EZIN >Dx&3@|P?@=N z*_|lwl;qKLhR?K=&{YMUwK%$1 *&2wfmEt{QpVQeOt%`g+1nlO0AM z_HLWdHW9Ja^oX@e=bt=Z3wgXDyX+iQik>(LY~InS+2ZmU|FBs8+`sUFe^`J1`ozDB zt|X^jcX$*uyNgL0CZZCQBlo=5$jcaACDO8IP@`HU#P~GrvUMRys^KlQ%D jw8e#0;W<7Su+up+R}F-uYD#zu>bD6 z{Zn`R_oDZ==@Y+ Ujcg(z)9W5E3 z`PU2G(yI-BWbEkFY09DP*%dvfD%?VPMn;z`U5S-(5Zi7MU!~2Bt8Z*%ueXGct!6@= zS}KJXdrHaJPxy!RbK*IEeBd97-rwja{*q!R%SERn{${$;44<1Rp^n`YG_uPSzY*i} zs;Le{&Wc-&7t|xLxjhh^OD#y7w}ef?EN1F@y}3FmNzls0=wah9vKD=830`=bUM37% z<2PfLZWHgQW7zGu f@M7Rs4E)X4|0mD+4|;>z-HnFR;9K=D@u{hI)T-RCB~Fx$*kvzn zE01sDZf`eI7|fW=bg&dvF-JAX{Zfh|X|i)d^d$1>Ebqq-T7{i_>`rF0T8($tq?%q$ zWM>zJ-B;u3ipFT7MnpkAPtD>I;XinP<1yKR{%-yi-hUhBU&l}Uw~H`_m&c5RUav#K zb+hGVnYT37t+R~Scrm Xug z zqbnn;Ss=CZM2S!>quji%qieWF5bR2rOV^x?sn6zb;AO+#&OZ%B?{Cx- zzcv ct_P9=f&eVyM14m-~q1@#Q#U9Zg&i;XYi$ zO5?JiuI@xfXT>dCF4Rt(>kZLhN|*6&*+(vY@>W`s)paIqRvhi+^A_s~AKpYPad Cup>ex_eTs7$RZoJcVnUha}MRl0O%&2%fD=zXRyS4iM z$Tvhwo@fEL6&~jJ_k0<*fpS7tG>fE?>0wI}4=1I9(v!wb9Iv=d7dIbw$33pH>|q@8 z<}UB?kPN!3#dEB1{lWStfSth0hu^QiJ&WGoh$sF`x7MX g?X}EJ0f| zo3vLMoS#(WCDBEb*;B;s6nG-H7Utf0hh*0uynp@ymUyl|Ka1Yq@F)Hv@u zf_~yqYk1-RqNJ&JY|r{OpHmVm;)DhpM8xTg)5Wo7&$Y7H8SM?3UoeF+>NDjaGh!9D z2l0@=L~#l)!R`Iyf(vO_A)T|c4Rp|vRknvQMOO`{bj&dfse@3QemsAF=O2IWzxcq9 z!}=BW#LxMOU~gzQfq8IJ>9#E!suJdQ0QPVSy|vrhy9>W+ZpHC1Ig<%2g`nN-xSF<6 ze E>D zAs_pX{>1v>9shUlZ-3w)-rq)__+@3APPxhQhQ--&toQkOdQ(iTnhTptr!84SrzWt7 z>nJ!Ec1J~@EcWSvJ|3y-e&Q7Pj%1qRwC_<;uJCEa(55Za(<^bPCnxIwOY91~A4n>3 zM{W)|TPJJnkX^Kkec)!qmZ;Mo&))-n`1#fP+qa_kw}1b{pYPo%ff5G^ks-%PH|w{B zOe*{149&(j>yk_lxlIa+#g3Un%NL4X6xKvJD%@62*W3NfRhcyvIF)=^EU#-dZRONn zP0>BeVf|VNYcX2}G`$dlvFD13vY*%&L^7EfKF-TFKKx+)`9b#Y^S?loNZ7yd^AmrY z81^zMVm2mO8!g(H9dk`FM)wT7AgEo0s=EXmG-VOmgme}fjp|Ie%&2$`X;CyC4VbmU z?q@23FH@bBh3R=kb;(K<%_C2O!GQy2kDe~&YXJp?t~}zzOtfkhESPK+TsGm4{nwpr z^tt~3(Igc1?;ub7@-l^20Z))oXNGRmB%NNx1fiJudQxXMR@;`V1!LwuGZn2puI(<= zb&H;>J G5Oy@4bib$QY4F>Li=Ol__Eq7zkVL?6{l zg4B_^(h$3Eq@!|SoZV(6WLdS!O?mE|Kf_P`X8jYIG!(tR{ktdrcysFK9g(@~Ryh(} z&9GN-zqp{Slg;^PE8P-`y~=B^!Dgh#qX*06AwfWQaK;sB(v{NMbJ`HDQIA&Qg~jyo zgsX7hY>F1w1sxVl8H6)UWZZR{81svUp8E@)O028YPBw4`+voVPU#&mi@naDG{M#pf zSqS)pp(FLo&LXC 0YI zT!paThd`N_ft`=b)a4s>Vetpo)@c;qY-)8p?`f9p 6G!Txljw{3i7kk9aw zzqda_(fixKdE(z!+uq_OgcEnOeGxI*mCY0W(3agbJ!dO~OssRD7JX$VJJ!TihS852 zvSM!WLYVurY|E{VoUXJAZM7#y T6%CqSG?XRBr71y3lW>;4h151T$4_A!51^%L?`8HkZu{h_f zA~aQddE%r48ib7)$V-Y?HT&G+<#1bSGviiDYv@2@ZdF{4qSJuwh{)HuafnLdg_rk* z?O)SI$;DYxnCq^y(=jat+zmCQ&-f>Pm%oV`ir(M8Jn>`K)(cZhx(f2tl;G|087lA0 zgOMDpa(QC*6GpEREgNs`qY_KCR9MFZ^C(`xl=0jUI`g*D3;JTi-iw?$oqa@C;ov>a zx#gJDnOFG)Su+&wcH7@}c06g2SV!3HA+*@5vWh(RQ~vSate=DY4@K{9|N4oaQj5Bz z0i|#iB8UtlSHUllkkMz2% ZQ(J4oM{bi_`sr(8Kv zASsc7&p*vSMSq_^=#HNpir(M;6>ah;cNv* zVkt%uS1rT?GRY~a+hc|>ZOlrm78a{jRlgPaqBmArka6t%#%L>lKe@OfbIC|u$RIr$ zC%7()`)4(uM0~2oy_KBmFl~O2Kjj1LZ{s(>Kg4hU;)#FZ+7o)tv9Q4?Jsw(4kr{>J z#EH3>Ej4K#2qLT06KCum@*v?Uh)=iVMZVeREGlekc}cj5Bo-a)s+egO!7pzMN8fE+ zE^F({tzQ^8f~d(7Uj$<1lNSOyGM+XuE=n7cZ(xV>C-MXN@2KC{|9jD|CIem{UJSe# zcrox|;Kjg;ffoZW23`!j7 f@M7S_z>9$w11|<%47?b4 zG4NvG#lVY!7XvQ_UJSe#crox|;Kjg;ffoZW23`!j7 f@M8x4=i=}D!$12U|M=}M|JR=r#GkL?r7NzY zLgH(KJ}#xHxX$y_(VE0u!NbeiW`=teE)rHk!x1sjlnG7gWR<;qCXTb|CBPv0Ige(M ztZZryC7R>P*H+_0=4>!8>n+PT>}JU=$PXlUHqb>hQ7NI)=4Dz!N^*1UD`heA%B-wL zep!zK+x07d n9ax@xos7fZ9VcT-_Cy;dA?o!G=fVSJ1(;R>&R)FQBiiIsx3xw zF>1 zkyG|RjhMn4IlkSDqBtLQkzb4=e{{-xyY}lpd>PFJRo`xY9(~!B z-5*BX$FDD=F9IO>!%*KSxBF4u*ieyIcEwL$Ms5iw4i&BMjT)$ouliF|7Ep&c%6+@| z2_6H_%k9*!hYRu%ybE6;$WVc6=VcxPw&r=s$u9xVC@LGI@$(Zr)`Mr%P;=)O%?JpB zb^-d@&w` +Js96KgxZ-#oOdjv(*J&%h9)8LU1MMe*kmGn(6Q09B3X&{Oy4*`v&^ zLVuLEKvCr(c?_ru=qTWMxP9P%s13e%TeuXMto$NuqJgH^jYe@Z3hm3iC3!ze?Cz(L zVAoJ<-Fzds@@t?WxK|^W4wP4;b!&T7oMrwWzue2eb;37(${_H8qM<3pHw*)xjvqqf z@5IDKc)zbMt9#v9n`Ji80mK0yc>d7wS)BUb&marp(2>xmc9y~O9wb41a~q(X00lJ5 z^W7Mpn1RR*-2mUZaP_wqtXtm)xoFC|9v%$Lz<;(k^zHEXAoc_D;qNRC2A~0O|EOsI z9<|lITOm`(KtvsfQS)`c4`Sbp+^BU^c+?=p1InN+K(c>9`IOI{pJhKiMBqy^IzeS{ z_aITDtOVMdIEUW9l(7qs{vrl<4!qba;9_{>@T@^h1`%O0xCj80Eg%ViJ5a_igEkJB z5JVh=r_JtnIs^P7tojkOX{ci8_qzu8qg4q)XtdR!t@nDnxQPIcQ-Xv8vTxE2($qvC zO3hs=hG)Q?Z4D^9s2p|BTVNl&k1ow1t;2(*A_UO~Ru12kxG2F446>HR7wDP11@XJ9 z(!EMIuG}^s6y5dVE@P12d*uT?&}%iIhOb6;FbH51#r30u9-r+|;CCaC{ CL{(XWF}s_C%I*dku|Wfej)0c^{KyqCLGbYihumwBtKeJurtga; zfA2p2|BHY6XYlj-_hR71z`(%&j{f2wL2UE>=YRQs{sMxV%?6WaWFt^*!-OL3*+g=g zT_;PKmB(!4z@bft#YKsCfuz)oSCh=af_#MHIT!TA?e>bZRz HFM;`)s>#n`Via{r4tYK>C}2LsJso)O$-rD zF$4@O7^;h3#C8rBRBa9hB8c5Mu76&X6=X)M{lQa1C2rLZ*6nT~pf)=Q>=^vL04BHs zzYE^!9{CLa8(iPr%zozqzBjYqVv506fMEt7G+0w-@USkJtcML66@!EM<4E=)i~-B+ z`(MF$Ltq6S0*qVj1L#p)BS;zDW3oShPpKntcTmybO#B8cBv`n+p@ibEVD`TNY-lXF zfp4A?z!p&Nmj;~D-SgFD?t@i@c6Q*DqrpQ~@ag9f2YzGt@;mDahB+$1X`H|_^kAOL z6s{PB)nMEQ!)h1TA#nWp-+c2_kYg*m$AQ(?yIBsw698GjE(B!o#2kot#9=>u`F2a? z=XN#t|Irr}f|Vg2fY1qS`G7I~!#%Jm;R^^_P7r+omxTu&A%Y+ax(KX&)A`xO|KmtR z5C|cLI=phY-zkCeYP4eEQQtXJJ1bicNb?}reS`@hI;D5_3qv#Gd-w!sHj)3ugXV%< z1b*{~poVA;eEt^(+CKP18(fzmup9RKa7DMB!3)`L(?X~8BSTdsi0F2VF&9Sa))=YW zNHjK3Aa0Dg${2{I&L!4(_^N?qa8>s%Yzw!{!JRj@lR qjVeG`rrzKa`3|-V&6iTdxQhmL$`f|?qOVIzoO@XVYo8zv!M^Z`PT{px?e{{ z_b>#S>6QN7^@2+tVz>e09qxxqe~b}M_8_Z+(1CLvfI)P5g6_Nf;*kfT#1IZZJKSLi zau?1aL^;{eMS}`MXWX@D2rceW0!Wotwhn|jL%1_2R}fcsJ%=d#qe(;Yw+JQ#Wf`h% zPj|tE=-3{V53B+d#?3u)zta}NnCHR5aQ8!oPMO_9i1R@XTsPeHMGb<{J(3#aYzQ>} zFoL4H7=lTvefaqZ5bym3_k&hLtwSg^w8REg8-^r+ C^f<)-y`*7hD`w&Wf-*exB-aBP5h_N%U7!a_x z#Snl)@AY4xT8LTiO&kVd! 5(Z z9m2Q|`oVgDxxJ5l;s(eWMu@`z;;z~sZJ&YoLX!s|a6Jqm;Td;>JB&33^@Bk33(y0V z{!*J)enk)%O@dkQpwsTndyG#J @~7$~qge(nG)z&eD8P#63KZeS1#h-OEO z2d1}a!9oo33f)_Su>{lh(}%>~ e6#%!(bt1*9Yh0$ j>B@03Af+U^9`yuT-_&f=wC36k>ExcaS*f zFYt2U#D-Q4-r_!92IUUoBCf$XL4)e1tU$(o86cYvOZd&qZeRp@HzfAm(+&L YM zaMg$Vx)UscS%AUPeQ-5QAl#kf!)*efp{`*90pwJxU@G8&0U9`bQG F%l%_84?P00)j;bovK&UBA2x4v`tYOnU^>AB z56ywGS1_n7bTRV?Pwqj@@Er(KH~fOu`Opwh9lsb9Y`E7h8c_A5h3Od&O!dRyJ`fCA z(*QaMRPIjxUK7|ZAYlmLK&rk)Z^LZDy*<#OgC)7^%VUOTxV8GGZ-b0}yYyEBWVmJn z4S2*L{uN9=RJ}TESeXMo{E|bs1+D`u!9c=LNj~_IA*utLJV-mtK9vvz#XvpO1LJz= zf}bA#+yza#kK(@x$uP47JPm}wYz=6;<3rE`vl?*@Fd*&&v+G~M#}3j0(__w{ti#nD zc+ebF?m0sSAcrq%-~RdDYWUEP1jb~CVldw@m6v_^TljdYN-#lSeql-k0!&7hp=P-M z(aAvmU5Fowbe|opV=!rVGYXnt|9WC#2=NA4fr+!9M}PGQO$Ir1U^Zs>ZUE+AeHYv& zv>2rI%OjwAh!D(PfsjC}hs*M^va<AZs2(hT!&x*_6Q|b?^&7e~WMK0VPb0K<7I? zm;fNssqTFdz% 6jCW|IlV8`~I8jcsqoK!d~QV=Cv#c7?gxaaC0| zN@Tp!#Ozs_&q6}cI1Qt(k<1WQE#ys>O~VT %}b0mS@PyBQI3q_wvz}lKcw11 zwd9yn$7WG0yCRxq%5=7)H`^S~i^H`&Xo$KypP6NQ+gO2g+}kp12>R|aBeh8y?&6)5 z!STKlobT)9#WJ1MH+OPHUEve_*!Y(T92!CI`0qvUZ^tKoiMYnggObCMwvBV?<1A_s z<*@e1 3GCs} zYw=tD>OYMi|4RHuez2kF{q4U#@o(BjudEH1#w0V=SN%C%S(LauS8$m46qYm&uIWW2 z5}exY49R&%%@Ubh#`eOc+V-flse?`FJ|?D9c?r3ZA)Br(_h&<`+lZWo37O`zdPN?B zxk*5#T!mLT6Hf9r@bSV&vz_%V|LUK{kA0<{`NJRl4@K{9|K*9_JW+@^rp9|@g&_1X zxKIU$25zt?)7{RlTbVg`F e$GP+cs=`V(TC4bBRQ1t%xpTEPO zU>lhDR;Lm@w#wVJ-mXQr)@(Rh*z&@94O!_|(Xu#UIl3qH%lNvzlG>ipvR0h;GoG;Q z1*!Og0;%46CSv1AkwtQ%<^-Rt?FN~y8s< 0|(zGYP{{Y3uJ zAzAFN<^Nvv{$@S#Z)duwrR2Qw>JzW`?EI`9!}ZyajWWWsYC@=tS~!GF>Dzv4TFg$l ziam#1kkE##yiI@!MK70vq$oNft;0hg+ief&l)aPVb_ZIU8k5zIK*%nf7t2GxZsmS` zL{JS0)*5XDpV=Q2lAS-#kGRXvz3Bbz@WgNKvy#}y |U_bxA36 z5OYaFRfc1y;$^&-bU~TU?Q_S@3%$fX &{N9Wb;%&^IItX8db((5bZ*OT+2)wfP780RZGl`Sil=R5v#Kx0Rj z7;#_C^YXwt%|fS^Q&5@0Xd`S_obYf7>BJ{SdK4&%3HjIvC!gd0?);7q`~>;_wtM17 z!~LS#6RnPKZh9EeG{v7$Ht{eU*-Q@fd^TmE|1mNl{aOb z7d7791O+D3%4us6p_3~+nB{ExitE~1MsIj?5Rq=vR%pU*j!d759kKSfBZGS6r}&BA z^FI{5znM?`{kUvUt{lsXJF9mg9cL>&H(1Ujwsjja>)8zy&LMk$u{vGWO1Eo;8)D8a z3{}y&w`MO9za<)dd9zp}K;fX29x{=uE47vpFX$yXNlwA`f?38jOp=SW*n3F@sn&z) zfXMSU-hakF@paH%97WI%|BDVq?{C{DexBIPa4n2_c9TsfSiKwz>9K-y6S7k<^=_xt zo=My3UN>;cHT30pW20lw-dAeALAv$!(9_jwI`hxMvD4dLw(3}lFlXb&$zr}9FWbnC z=!=RLCz8bUrH(q+!|sHyl=PIH>V0i|jvssG4}ZuH kxr;3_brF)JhPgB7f zpT%gyQ0o)35G~K# Vt(0j zrR|-Ct8bEONccDBFcFUPf`K#X=W@D48+mf@B^(0Ux>e*wT+<7mza87WRJf%Ox+dxg zDb;A-&X4##er!6NA4JjL$ 1a_NZ1d*SiAJvtXLUIV^W>Q7=dKD#DV!9h zzLUl9Bo*M5(VNulyjdG)G&V^p;z#l`fSr8Be~W)8dVgC#@e=|&j>)K;T?>_*`p3e! zrPi)e$J1Fu1ddJXfwsS7;hd+Avf5IjPVy^up>4Mi9@J;nxv7k0(Iyop#hSN|(yTFY zSRk1mR4rpR#(uur2{Xh?wnwM*+u I?t? literal 0 HcmV?d00001 diff --git a/blog-api/data/nginx/default.conf b/blog-api/data/nginx/default.conf new file mode 100644 index 000000000..0ebd9e1bb --- /dev/null +++ b/blog-api/data/nginx/default.conf @@ -0,0 +1,30 @@ +server { + listen 80; + + set $index_file "public/index.php"; + set $root "/app"; + + root $root; + index $index_file; + + error_log /var/log/nginx/error.log; #set + access_log /var/log/nginx/access.log; #set + + location ~* \.(js|css|png|html)$ { + root $root/public; + access_log off; + } + + location ~ [^/]\.php(/|$) { + fastcgi_pass yii-php:9000; + fastcgi_index $root/$index_file; + include fastcgi_params; + fastcgi_split_path_info ^(.+?\.php)(/.*)$; + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } + + location / { + try_files $uri $uri/ /$index_file?$query_string; + } +} diff --git a/blog-api/docker-compose.yml b/blog-api/docker-compose.yml new file mode 100644 index 000000000..ecae76695 --- /dev/null +++ b/blog-api/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3' + +services: + php: + container_name: yii-php + image: yiisoftware/yii-php:8.1-fpm + working_dir: /app + volumes: + - ./:/app + nginx: + image: nginx:alpine + container_name: yii-nginx + ports: + - 8080:80 + - 8081:81 + volumes: + - ./:/app + - ./data/nginx/:/etc/nginx/conf.d/ + depends_on: + - php + restart: always diff --git a/blog-api/infection.json.dist b/blog-api/infection.json.dist new file mode 100644 index 000000000..3776e2235 --- /dev/null +++ b/blog-api/infection.json.dist @@ -0,0 +1,16 @@ +{ + "source": { + "directories": [ + "src" + ] + }, + "logs": { + "text": "php:\/\/stderr", + "stryker": { + "report": "master" + } + }, + "mutators": { + "@default": true + } +} diff --git a/phpunit.xml.dist b/blog-api/phpunit.xml.dist similarity index 100% rename from phpunit.xml.dist rename to blog-api/phpunit.xml.dist diff --git a/blog-api/psalm.xml b/blog-api/psalm.xml new file mode 100644 index 000000000..a3df09486 --- /dev/null +++ b/blog-api/psalm.xml @@ -0,0 +1,18 @@ + + + diff --git a/public/.htaccess b/blog-api/public/.htaccess similarity index 100% rename from public/.htaccess rename to blog-api/public/.htaccess diff --git a/public/assets/.gitignore b/blog-api/public/assets/.gitignore similarity index 100% rename from public/assets/.gitignore rename to blog-api/public/assets/.gitignore diff --git a/public/favicon.ico b/blog-api/public/favicon.ico similarity index 100% rename from public/favicon.ico rename to blog-api/public/favicon.ico diff --git a/blog-api/public/index.php b/blog-api/public/index.php new file mode 100644 index 000000000..bc508b5ed --- /dev/null +++ b/blog-api/public/index.php @@ -0,0 +1,43 @@ +withTemporaryErrorHandler(new ErrorHandler( + new Logger([new FileTarget(dirname(__DIR__) . '/runtime/logs/app.log')]), + new JsonRenderer(), + )); +$runner->run(); diff --git a/public/robots.txt b/blog-api/public/robots.txt similarity index 100% rename from public/robots.txt rename to blog-api/public/robots.txt diff --git a/blog-api/resources/messages/en/app.php b/blog-api/resources/messages/en/app.php new file mode 100644 index 000000000..1451dd590 --- /dev/null +++ b/blog-api/resources/messages/en/app.php @@ -0,0 +1,7 @@ + 'Page not found', +]; diff --git a/blog-api/resources/messages/ru/app.php b/blog-api/resources/messages/ru/app.php new file mode 100644 index 000000000..ea39702a9 --- /dev/null +++ b/blog-api/resources/messages/ru/app.php @@ -0,0 +1,7 @@ + 'Страница не найдена', +]; diff --git a/runtime/.gitignore b/blog-api/runtime/.gitignore similarity index 100% rename from runtime/.gitignore rename to blog-api/runtime/.gitignore diff --git a/blog-api/src/Auth/AuthController.php b/blog-api/src/Auth/AuthController.php new file mode 100644 index 000000000..5aa01a482 --- /dev/null +++ b/blog-api/src/Auth/AuthController.php @@ -0,0 +1,114 @@ +responseFactory = $responseFactory; + $this->userService = $userService; + } + + /** + * @OA\Post( + * tags={"auth"}, + * path="/auth/", + * summary="Authenticate by params", + * description="", + * @OA\Response( + * response="200", + * description="Success", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/Response"), + * @OA\Schema( + * @OA\Property( + * property="data", + * type="object", + * @OA\Property(property="token", format="string", example="uap4X5Bd7078lxIFvxAflcGAa5D95iSSZkNjg3XFrE2EBRBlbj"), + * ), + * ), + * }, + * ) + * ), + * @OA\Response( + * response="400", + * description="Bad request", + * @OA\JsonContent(ref="#/components/schemas/BadResponse") + * ), + * @OA\RequestBody( + * required=true, + * @OA\MediaType( + * mediaType="application/json", + * @OA\Schema(ref="#/components/schemas/AuthRequest"), + * ), + * ), + * ) + */ + public function login(AuthRequest $request): ResponseInterface + { + return $this->responseFactory->createResponse( + [ + 'token' => $this->userService + ->login( + $request->getLogin(), + $request->getPassword() + ) + ->getToken(), + ] + ); + } + + /** + * @OA\Post( + * tags={"auth"}, + * path="/logout/", + * summary="Logout", + * description="", + * security={{"ApiKey": {}}}, + * @OA\Response( + * response="200", + * description="Success", + * @OA\JsonContent(ref="#/components/schemas/Response") + * ), + * @OA\Response( + * response="400", + * description="Bad request", + * @OA\JsonContent(ref="#/components/schemas/BadResponse") + * ), + * ) + */ + public function logout(UserRequest $request): ResponseInterface + { + $this->userService->logout($request->getUser()); + + return $this->responseFactory->createResponse(); + } +} diff --git a/blog-api/src/Auth/AuthRequest.php b/blog-api/src/Auth/AuthRequest.php new file mode 100644 index 000000000..b6d3415b9 --- /dev/null +++ b/blog-api/src/Auth/AuthRequest.php @@ -0,0 +1,42 @@ +getAttributeValue('body.login'); + } + + public function getPassword(): string + { + return (string) $this->getAttributeValue('body.password'); + } + + public function getRules(): array + { + return [ + 'body.login' => [ + new Required(), + ], + 'body.password' => [ + new Required(), + ], + ]; + } +} diff --git a/blog-api/src/Auth/AuthRequestErrorHandler.php b/blog-api/src/Auth/AuthRequestErrorHandler.php new file mode 100644 index 000000000..3aa2a8033 --- /dev/null +++ b/blog-api/src/Auth/AuthRequestErrorHandler.php @@ -0,0 +1,18 @@ +postRepository = $postRepository; + $this->responseFactory = $responseFactory; + $this->postFormatter = $postFormatter; + $this->postBuilder = $postBuilder; + $this->blogService = $blogService; + } + + /** + * @OA\Get( + * tags={"blog"}, + * path="/blog/", + * summary="Returns paginated blog posts", + * description="", + * @OA\Parameter(ref="#/components/parameters/PageRequest"), + * @OA\Response( + * response="200", + * description="Success", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/Response"), + * @OA\Schema( + * @OA\Property( + * property="data", + * type="object", + * @OA\Property( + * property="posts", + * type="array", + * @OA\Items(ref="#/components/schemas/Post") + * ), + * @OA\Property( + * property="paginator", + * type="object", + * ref="#/components/schemas/Paginator" + * ), + * ), + * ), + * }, + * ) + * ), + * ) + */ + public function index(PaginatorFormatter $paginatorFormatter, #[Query('page')] int $page = 1): Response + { + $paginator = $this->blogService->getPosts($page); + $posts = []; + foreach ($paginator->read() as $post) { + $posts[] = $this->postFormatter->format($post); + } + + return $this->responseFactory->createResponse( + [ + 'paginator' => $paginatorFormatter->format($paginator), + 'posts' => $posts, + ] + ); + } + + /** + * @OA\Get( + * tags={"blog"}, + * path="/blog/{id}", + * summary="Returns a post with a given ID", + * description="", + * @OA\Parameter( + * @OA\Schema(type="int", example="2"), + * in="path", + * name="id", + * parameter="id" + * ), + * @OA\Response( + * response="200", + * description="Success", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/Response"), + * @OA\Schema( + * @OA\Property( + * property="data", + * type="object", + * @OA\Property( + * property="post", + * type="object", + * ref="#/components/schemas/Post" + * ), + * ), + * ), + * }, + * ) + * ), + * @OA\Response( + * response="404", + * description="Not found", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/BadResponse"), + * @OA\Schema( + * @OA\Property(property="error_message", example="Entity not found"), + * @OA\Property(property="error_code", nullable=true, example=404) + * ), + * }, + * ) + * ), + * ) + */ + public function view(#[Route('id')] int $id): Response + { + return $this->responseFactory->createResponse( + [ + 'post' => $this->postFormatter->format( + $this->blogService->getPost($id) + ), + ] + ); + } + + /** + * @OA\Post( + * tags={"blog"}, + * path="/blog", + * summary="Creates a blog post", + * description="", + * security={{"ApiKey": {}}}, + * @OA\Response( + * response="200", + * description="Success", + * @OA\JsonContent( + * ref="#/components/schemas/Response" + * ) + * ), + * @OA\RequestBody( + * required=true, + * @OA\MediaType( + * mediaType="application/json", + * @OA\Schema(ref="#/components/schemas/EditPostRequest"), + * ), + * ), + * ) + */ + public function create(EditPostRequest $postRequest, UserRequest $userRequest): Response + { + $post = $this->postBuilder->build(new Post(), $postRequest); + $post->setUser($userRequest->getUser()); + + $this->postRepository->save($post); + + return $this->responseFactory->createResponse(); + } + + /** + * @OA\Put( + * tags={"blog"}, + * path="/blog/{id}", + * summary="Updates a blog post with a given ID", + * description="", + * security={{"ApiKey": {}}}, + * @OA\Parameter( + * @OA\Schema(type="int", example="2"), + * in="path", + * name="id", + * parameter="id" + * ), + * @OA\Response( + * response="200", + * description="Success", + * @OA\JsonContent( + * ref="#/components/schemas/Response" + * ) + * ), + * @OA\RequestBody( + * required=true, + * @OA\MediaType( + * mediaType="application/json", + * @OA\Schema(ref="#/components/schemas/EditPostRequest"), + * ), + * ) + * ) + */ + public function update(EditPostRequest $postRequest, #[Route('id')] int $id): Response + { + $post = $this->postBuilder->build( + $this->blogService->getPost($id), + $postRequest + ); + + $this->postRepository->save($post); + + return $this->responseFactory->createResponse(); + } +} diff --git a/blog-api/src/Blog/BlogService.php b/blog-api/src/Blog/BlogService.php new file mode 100644 index 000000000..421187834 --- /dev/null +++ b/blog-api/src/Blog/BlogService.php @@ -0,0 +1,49 @@ +postRepository = $postRepository; + } + + public function getPosts(int $page): PaginatorInterface + { + $dataReader = $this->postRepository->findAll(); + + return (new OffsetPaginator($dataReader)) + ->withPageSize(self::POSTS_PER_PAGE) + ->withCurrentPage($page); + } + + /** + * @param int $id + * + * @throws NotFoundException + * + * @return Post + */ + public function getPost(int $id): Post + { + /** + * @var Post|null $post + */ + $post = $this->postRepository->findOne(['id' => $id]); + if ($post === null) { + throw new NotFoundException(); + } + + return $post; + } +} diff --git a/blog-api/src/Blog/EditPostRequest.php b/blog-api/src/Blog/EditPostRequest.php new file mode 100644 index 000000000..42de4c172 --- /dev/null +++ b/blog-api/src/Blog/EditPostRequest.php @@ -0,0 +1,68 @@ +getAttributeValue('router.id'); + } + + public function getTitle(): string + { + return (string) $this->getAttributeValue('body.title'); + } + + public function getText(): string + { + return (string) $this->getAttributeValue('body.text'); + } + + public function getStatus(): PostStatus + { + return PostStatus::from($this->getAttributeValue('body.status')); + } + + public function getRules(): array + { + return [ + 'body.title' => [ + new Required(), + new HasLength(min: 5, max: 255), + ], + 'body.text' => [ + new Required(), + new HasLength(min: 5, max: 1000), + ], + 'body.status' => [ + new Required(), + static function ($value): Result { + $result = new Result(); + if (!PostStatus::isValid($value)) { + $result->addError('Incorrect status'); + } + + return $result; + }, + ], + ]; + } +} diff --git a/blog-api/src/Blog/Post.php b/blog-api/src/Blog/Post.php new file mode 100644 index 000000000..99c20e2dc --- /dev/null +++ b/blog-api/src/Blog/Post.php @@ -0,0 +1,108 @@ +created_at = new DateTimeImmutable(); + $this->updated_at = new DateTimeImmutable(); + $this->resetSlug(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getSlug(): ?string + { + return $this->slug; + } + + public function resetSlug(): void + { + $this->slug = Random::string(128); + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): void + { + $this->content = $content; + } + + public function setStatus(PostStatus $status): void + { + $this->status = $status->getValue(); + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->created_at; + } + + public function getUpdatedAt(): DateTimeImmutable + { + return $this->updated_at; + } + + public function setUser(User $user): void + { + $this->user = $user; + } + + public function getUser(): ?User + { + return $this->user; + } +} diff --git a/blog-api/src/Blog/PostBuilder.php b/blog-api/src/Blog/PostBuilder.php new file mode 100644 index 000000000..0083f564d --- /dev/null +++ b/blog-api/src/Blog/PostBuilder.php @@ -0,0 +1,17 @@ +setTitle($request->getTitle()); + $post->setContent($request->getText()); + $post->setStatus($request->getStatus()); + + return $post; + } +} diff --git a/blog-api/src/Blog/PostFormatter.php b/blog-api/src/Blog/PostFormatter.php new file mode 100644 index 000000000..5363a0309 --- /dev/null +++ b/blog-api/src/Blog/PostFormatter.php @@ -0,0 +1,27 @@ + $post->getId(), + 'title' => $post->getTitle(), + 'content' => $post->getContent(), + ]; + } +} diff --git a/blog-api/src/Blog/PostRepository.php b/blog-api/src/Blog/PostRepository.php new file mode 100644 index 000000000..8c2e5bb13 --- /dev/null +++ b/blog-api/src/Blog/PostRepository.php @@ -0,0 +1,38 @@ +orm = $orm; + parent::__construct($select); + } + + public function findAll(array $scope = [], array $orderBy = []): EntityReader + { + return new EntityReader( + $this + ->select() + ->where($scope) + ->orderBy($orderBy) + ); + } + + public function save(Post $user): void + { + $transaction = new Transaction($this->orm); + $transaction->persist($user); + $transaction->run(); + } +} diff --git a/blog-api/src/Blog/PostStatus.php b/blog-api/src/Blog/PostStatus.php new file mode 100644 index 000000000..da2e9b033 --- /dev/null +++ b/blog-api/src/Blog/PostStatus.php @@ -0,0 +1,21 @@ +status; + } + + public function setStatus(string $status): self + { + $this->status = $status; + + return $this; + } + + public function getErrorMessage(): string + { + return $this->errorMessage; + } + + public function setErrorMessage(string $errorMessage): self + { + $this->errorMessage = $errorMessage; + + return $this; + } + + public function getErrorCode(): ?int + { + return $this->errorCode; + } + + public function setErrorCode(int $errorCode): self + { + $this->errorCode = $errorCode; + + return $this; + } + + public function getData(): ?array + { + return $this->data; + } + + public function setData(?array $data): self + { + $this->data = $data; + + return $this; + } + + public function toArray(): array + { + return [ + 'status' => $this->getStatus(), + 'error_message' => $this->getErrorMessage(), + 'error_code' => $this->getErrorCode(), + 'data' => $this->getData(), + ]; + } +} diff --git a/blog-api/src/Exception/ApplicationException.php b/blog-api/src/Exception/ApplicationException.php new file mode 100644 index 000000000..8c919156b --- /dev/null +++ b/blog-api/src/Exception/ApplicationException.php @@ -0,0 +1,11 @@ +getStatusCode() !== Status::OK) { + return $this + ->createErrorResponse() + ->setErrorCode($response->getStatusCode()) + ->setErrorMessage($this->getErrorMessage($response)); + } + + return $this + ->createSuccessResponse() + ->setData($response->getData()); + } + + public function createSuccessResponse(): ApiResponseData + { + return $this + ->createResponse() + ->setStatus('success'); + } + + public function createErrorResponse(): ApiResponseData + { + return $this + ->createResponse() + ->setStatus('failed'); + } + + public function createResponse(): ApiResponseData + { + return new ApiResponseData(); + } + + private function getErrorMessage(DataResponse $response): string + { + $data = $response->getData(); + if (is_string($data) && !empty($data)) { + return $data; + } + + return 'Unknown error'; + } +} diff --git a/blog-api/src/Factory/RestGroupFactory.php b/blog-api/src/Factory/RestGroupFactory.php new file mode 100644 index 000000000..f4c74dd7d --- /dev/null +++ b/blog-api/src/Factory/RestGroupFactory.php @@ -0,0 +1,47 @@ + Method::GET, + 'list' => Method::GET, + 'post' => Method::POST, + 'put' => Method::PUT, + 'delete' => Method::DELETE, + 'patch' => Method::PATCH, + 'options' => Method::OPTIONS, + ]; + + public static function create(string $prefix, string $controller): Group + { + return Group::create($prefix)->routes(...self::createDefaultRoutes($controller)); + } + + private static function createDefaultRoutes(string $controller): array + { + $routes = []; + $reflection = new ReflectionClass($controller); + foreach (self::METHODS as $methodName => $httpMethod) { + if ($reflection->hasMethod($methodName)) { + $pattern = ($methodName === 'list' || $methodName === 'post') ? '' : self::ENTITY_PATTERN; + $routes[] = Route::methods([$httpMethod], $pattern)->action([$controller, $methodName]); + } + } + if ($reflection->hasMethod('options')) { + $routes[] = Route::methods([Method::OPTIONS], '')->action([$controller, 'options']); + } + + return $routes; + } +} diff --git a/blog-api/src/Formatter/ApiResponseFormatter.php b/blog-api/src/Formatter/ApiResponseFormatter.php new file mode 100644 index 000000000..1609c1b1d --- /dev/null +++ b/blog-api/src/Formatter/ApiResponseFormatter.php @@ -0,0 +1,36 @@ +apiResponseDataFactory = $apiResponseDataFactory; + $this->jsonDataResponseFormatter = $jsonDataResponseFormatter; + } + + public function format(DataResponse $dataResponse): ResponseInterface + { + $response = $dataResponse->withData( + $this->apiResponseDataFactory + ->createFromResponse($dataResponse) + ->toArray() + ); + + return $this->jsonDataResponseFormatter->format($response); + } +} diff --git a/blog-api/src/Formatter/PaginatorFormatter.php b/blog-api/src/Formatter/PaginatorFormatter.php new file mode 100644 index 000000000..84221d5c0 --- /dev/null +++ b/blog-api/src/Formatter/PaginatorFormatter.php @@ -0,0 +1,28 @@ + $paginator->getPageSize(), + 'currentPage' => $paginator->getCurrentPage(), + 'totalPages' => $paginator->getTotalPages(), + ]; + } +} diff --git a/blog-api/src/Handler/NotFoundHandler.php b/blog-api/src/Handler/NotFoundHandler.php new file mode 100644 index 000000000..3ab9ddfe3 --- /dev/null +++ b/blog-api/src/Handler/NotFoundHandler.php @@ -0,0 +1,33 @@ +formatter->format( + $this->dataResponseFactory->createResponse( + $this->translator->translate('404.title'), + Status::NOT_FOUND, + ) + ); + } +} diff --git a/blog-api/src/InfoController.php b/blog-api/src/InfoController.php new file mode 100644 index 000000000..c02fe1d72 --- /dev/null +++ b/blog-api/src/InfoController.php @@ -0,0 +1,56 @@ +createResponse(['version' => $this->versionProvider->version, 'author' => 'yiisoft']); + } +} diff --git a/src/Installer.php b/blog-api/src/Installer.php similarity index 100% rename from src/Installer.php rename to blog-api/src/Installer.php diff --git a/blog-api/src/Middleware/ExceptionMiddleware.php b/blog-api/src/Middleware/ExceptionMiddleware.php new file mode 100644 index 000000000..5e3a50214 --- /dev/null +++ b/blog-api/src/Middleware/ExceptionMiddleware.php @@ -0,0 +1,35 @@ +dataResponseFactory = $dataResponseFactory; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + try { + return $handler->handle($request); + } catch (ApplicationException $e) { + return $this->dataResponseFactory->createResponse($e->getMessage(), $e->getCode()); + } catch (RequestValidationException $e) { + return $this->dataResponseFactory->createResponse($e->getFirstError(), Status::BAD_REQUEST); + } + } +} diff --git a/blog-api/src/Queue/LoggingAuthorizationHandler.php b/blog-api/src/Queue/LoggingAuthorizationHandler.php new file mode 100644 index 000000000..38ff6707a --- /dev/null +++ b/blog-api/src/Queue/LoggingAuthorizationHandler.php @@ -0,0 +1,24 @@ +logger->info('User is login', [ + 'data' => $message->getData(), + ]); + } +} diff --git a/blog-api/src/Queue/UserLoggedInMessage.php b/blog-api/src/Queue/UserLoggedInMessage.php new file mode 100644 index 000000000..1d2cc3d92 --- /dev/null +++ b/blog-api/src/Queue/UserLoggedInMessage.php @@ -0,0 +1,39 @@ +id = $id; + } + + public function getId(): ?string + { + return $this->id; + } + + public function getHandlerName(): string + { + return LoggingAuthorizationHandler::NAME; + } + + public function getData(): array + { + return [ + 'user_id' => $this->userId, + 'time' => $this->time, + ]; + } +} diff --git a/blog-api/src/RestControllerTrait.php b/blog-api/src/RestControllerTrait.php new file mode 100644 index 000000000..f7315f853 --- /dev/null +++ b/blog-api/src/RestControllerTrait.php @@ -0,0 +1,51 @@ +login = $login; + $this->created_at = new DateTimeImmutable(); + $this->updated_at = new DateTimeImmutable(); + $this->setPassword($password); + $this->resetToken(); + } + + public function getId(): ?string + { + return $this->id === null ? null : (string) $this->id; + } + + public function getToken(): string + { + return $this->token; + } + + public function resetToken(): void + { + $this->token = Random::string(128); + } + + public function getLogin(): string + { + return $this->login; + } + + public function setLogin(string $login): void + { + $this->login = $login; + } + + public function validatePassword(string $password): bool + { + return (new PasswordHasher())->validate($password, $this->passwordHash); + } + + public function setPassword(string $password): void + { + $this->passwordHash = (new PasswordHasher())->hash($password); + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->created_at; + } + + public function getUpdatedAt(): DateTimeImmutable + { + return $this->updated_at; + } +} diff --git a/blog-api/src/User/UserController.php b/blog-api/src/User/UserController.php new file mode 100644 index 000000000..ee7ecf30c --- /dev/null +++ b/blog-api/src/User/UserController.php @@ -0,0 +1,133 @@ +responseFactory = $responseFactory; + $this->userRepository = $userRepository; + $this->userFormatter = $userFormatter; + } + + /** + * @OA\Get( + * tags={"user"}, + * path="/users", + * summary="Returns paginated users", + * description="", + * security={{"ApiKey": {}}}, + * @OA\Response( + * response="200", + * description="Success", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/Response"), + * @OA\Schema( + * @OA\Property( + * property="data", + * type="object", + * @OA\Property( + * property="users", + * type="array", + * @OA\Items(ref="#/components/schemas/User") + * ), + * ), + * ), + * }, + * ) + * ), + * ) + */ + public function list(): ResponseInterface + { + $dataReader = $this->userRepository->findAllOrderByLogin(); + $result = []; + foreach ($dataReader->read() as $user) { + $result[] = $this->userFormatter->format($user); + } + + return $this->responseFactory->createResponse( + [ + 'users' => $result, + ] + ); + } + + /** + * @OA\Get( + * tags={"user"}, + * path="/users/{id}", + * summary="Returns a user with a given ID", + * description="", + * security={{"ApiKey": {}}}, + * @OA\Parameter( + * @OA\Schema(type="int", example="2"), + * in="path", + * name="id", + * parameter="id" + * ), + * @OA\Response( + * response="200", + * description="Success", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/Response"), + * @OA\Schema( + * @OA\Property( + * property="data", + * type="object", + * @OA\Property( + * property="user", + * type="object", + * ref="#/components/schemas/User" + * ), + * ), + * ), + * }, + * ) + * ), + * ) + */ + public function get(#[Route('id')] int $id): ResponseInterface + { + /** + * @var User $user + */ + $user = $this->userRepository->findByPK($id); + if ($user === null) { + throw new NotFoundException(); + } + + return $this->responseFactory->createResponse( + [ + 'user' => $this->userFormatter->format($user), + ] + ); + } +} diff --git a/blog-api/src/User/UserFormatter.php b/blog-api/src/User/UserFormatter.php new file mode 100644 index 000000000..4b44e09af --- /dev/null +++ b/blog-api/src/User/UserFormatter.php @@ -0,0 +1,27 @@ + $user->getLogin(), + 'created_at' => $user + ->getCreatedAt() + ->format('d.m.Y H:i:s'), + ]; + } +} diff --git a/blog-api/src/User/UserRepository.php b/blog-api/src/User/UserRepository.php new file mode 100644 index 000000000..7dfb76b93 --- /dev/null +++ b/blog-api/src/User/UserRepository.php @@ -0,0 +1,63 @@ +orm = $orm; + parent::__construct($select); + } + + public function findAllOrderByLogin(): EntityReader + { + return (new EntityReader($this->select())) + ->withSort( + Sort::only(['login'])->withOrderString('login') + ); + } + + public function findIdentity(string $id): ?IdentityInterface + { + return $this->findIdentityBy('id', $id); + } + + public function findIdentityByToken(string $token, string $type = null): ?IdentityInterface + { + return $this->findIdentityBy('token', $token); + } + + public function findByLogin(string $login): ?IdentityInterface + { + return $this->findIdentityBy('login', $login); + } + + public function save(IdentityInterface $user): void + { + $transaction = new Transaction($this->orm); + $transaction->persist($user); + $transaction->run(); + } + + private function findIdentityBy(string $field, string $value): ?IdentityInterface + { + /** + * @var $identity IdentityInterface|null + */ + return $this->findOne([$field => $value]); + } +} diff --git a/blog-api/src/User/UserRequest.php b/blog-api/src/User/UserRequest.php new file mode 100644 index 000000000..08c715aa5 --- /dev/null +++ b/blog-api/src/User/UserRequest.php @@ -0,0 +1,19 @@ +getAttributeValue('attributes.' . Authentication::class); + } +} diff --git a/blog-api/src/User/UserService.php b/blog-api/src/User/UserService.php new file mode 100644 index 000000000..43ee5a397 --- /dev/null +++ b/blog-api/src/User/UserService.php @@ -0,0 +1,70 @@ +currentUser = $currentUser; + $this->identityRepository = $identityRepository; + $this->queueFactory = $queueFactory; + } + + /** + * @param string $login + * @param string $password + * + * @throws InvalidConfigException + * @throws BadRequestException + * + * @return IdentityInterface + */ + public function login(string $login, string $password): IdentityInterface + { + $identity = $this->identityRepository->findByLogin($login); + if ($identity === null) { + throw new BadRequestException('No such user.'); + } + + if (!$identity->validatePassword($password)) { + throw new BadRequestException('Invalid password.'); + } + + if (!$this->currentUser->login($identity)) { + throw new BadRequestException(); + } + + $identity->resetToken(); + $this->identityRepository->save($identity); + + $queueMessage = new UserLoggedInMessage($identity->getId(), time()); + $this->queueFactory->get(LoggingAuthorizationHandler::CHANNEL)->push($queueMessage); + + return $identity; + } + + public function logout(User $user): void + { + $user->resetToken(); + $this->identityRepository->save($user); + } +} diff --git a/blog-api/src/VersionProvider.php b/blog-api/src/VersionProvider.php new file mode 100644 index 000000000..9b9bcd666 --- /dev/null +++ b/blog-api/src/VersionProvider.php @@ -0,0 +1,12 @@ +haveHttpHeader('Content-Type', 'application/json'); + $I->sendPOST( + '/auth/', + [ + 'login' => 'Opal1144', + 'password' => 'Opal1144', + ] + ); + $I->seeResponseCodeIs(HttpCode::OK); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'success', + 'error_message' => '', + 'error_code' => null, + ] + ); + + $response = Json::decode($I->grabResponse()); + $I->seeInDatabase( + 'user', + [ + 'id' => 1, + 'token' => $response['data']['token'], + ] + ); + } + + public function logout(AcceptanceTester $I): void + { + $I->haveHttpHeader( + 'X-Api-Key', + 'lev1ZsWCzqrMlXRI2sT8h4ApYpSgBMl1xf6D4bCRtiKtDqw6JN36yLznargilQ_rEJz9zTfcUxm53PLODCToF9gGin38Rd4NkhQPOVeH5VvZvBaQlUg64E6icNCubiAv' + ); + + $I->sendPOST( + '/logout/' + ); + + $I->seeResponseCodeIs(HttpCode::OK); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'success', + 'error_message' => '', + 'error_code' => null, + ] + ); + + $I->dontSeeInDatabase( + 'user', + [ + 'id' => 1, + 'token' => 'lev1ZsWCzqrMlXRI2sT8h4ApYpSgBMl1xf6D4bCRtiKtDqw6JN36yLznargilQ_rEJz9zTfcUxm53PLODCToF9gGin38Rd4NkhQPOVeH5VvZvBaQlUg64E6icNCubiAv', + ] + ); + } + + public function logoutWithBadToken(AcceptanceTester $I): void + { + $I->haveHttpHeader( + 'X-Api-Key', + 'bad-token' + ); + + $I->haveHttpHeader( + 'Accept', + 'application/json' + ); + + $I->sendPOST( + '/logout/' + ); + + $I->seeResponseCodeIs(HttpCode::UNAUTHORIZED); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'failed', + 'error_message' => 'Unauthorised request', + 'error_code' => HttpCode::UNAUTHORIZED, + 'data' => null, + ] + ); + } +} diff --git a/blog-api/tests/Acceptance/BlogCest.php b/blog-api/tests/Acceptance/BlogCest.php new file mode 100644 index 000000000..d68315881 --- /dev/null +++ b/blog-api/tests/Acceptance/BlogCest.php @@ -0,0 +1,219 @@ +haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader( + 'X-Api-Key', + 'lev1ZsWCzqrMlXRI2sT8h4ApYpSgBMl1xf6D4bCRtiKtDqw6JN36yLznargilQ_rEJz9zTfcUxm53PLODCToF9gGin38Rd4NkhQPOVeH5VvZvBaQlUg64E6icNCubiAv' + ); + + $I->sendPOST( + '/blog/', + [ + 'title' => 'test title', + 'text' => 'test text', + 'status' => 0, + ] + ); + $I->seeResponseCodeIs(HttpCode::OK); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'success', + 'error_message' => '', + 'error_code' => null, + 'data' => null, + ] + ); + + $I->seeInDatabase( + 'post', + [ + 'title' => 'test title', + 'content' => 'test text', + 'status' => 0, + ] + ); + } + + public function createBadParams(AcceptanceTester $I): void + { + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader( + 'X-Api-Key', + 'lev1ZsWCzqrMlXRI2sT8h4ApYpSgBMl1xf6D4bCRtiKtDqw6JN36yLznargilQ_rEJz9zTfcUxm53PLODCToF9gGin38Rd4NkhQPOVeH5VvZvBaQlUg64E6icNCubiAv' + ); + + $I->sendPOST( + '/blog/', + [ + 'title' => 'test title', + 'status' => 0, + ] + ); + $I->seeResponseCodeIs(HttpCode::BAD_REQUEST); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'failed', + 'error_message' => 'Value not passed.', + 'error_code' => 400, + 'data' => null, + ] + ); + + $I->dontSeeInDatabase( + 'post', + [ + 'title' => 'test title', + 'status' => 0, + ] + ); + } + + public function createBadAuth(AcceptanceTester $I): void + { + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->sendPOST( + '/blog/', + [ + 'title' => 'test title', + 'text' => 'test text', + 'status' => 0, + ] + ); + $I->seeResponseCodeIs(HttpCode::UNAUTHORIZED); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'failed', + 'error_message' => 'Unauthorised request', + 'error_code' => HttpCode::UNAUTHORIZED, + 'data' => null, + ] + ); + } + + public function update(AcceptanceTester $I): void + { + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader( + 'X-Api-Key', + 'lev1ZsWCzqrMlXRI2sT8h4ApYpSgBMl1xf6D4bCRtiKtDqw6JN36yLznargilQ_rEJz9zTfcUxm53PLODCToF9gGin38Rd4NkhQPOVeH5VvZvBaQlUg64E6icNCubiAv' + ); + + $I->sendPUT( + '/blog/1', + [ + 'title' => 'test title', + 'text' => 'test text', + 'status' => 0, + ] + ); + $I->seeResponseCodeIs(HttpCode::OK); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'success', + 'error_message' => '', + 'error_code' => null, + 'data' => null, + ] + ); + + $I->seeInDatabase( + 'post', + [ + 'id' => 1, + 'title' => 'test title', + 'content' => 'test text', + 'status' => 0, + ] + ); + } + + public function updateBadAuth(AcceptanceTester $I): void + { + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->sendPUT( + '/blog/1', + [ + 'title' => 'test title', + 'text' => 'test text', + 'status' => 0, + ] + ); + $I->seeResponseCodeIs(HttpCode::UNAUTHORIZED); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'failed', + 'error_message' => 'Unauthorised request', + 'error_code' => HttpCode::UNAUTHORIZED, + 'data' => null, + ] + ); + } + + public function index(AcceptanceTester $I): void + { + $I->sendGET( + '/blog/', + [ + 'page' => 2, + ] + ); + $I->seeResponseCodeIs(HttpCode::OK); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'success', + 'error_message' => '', + 'error_code' => null, + 'data' => [ + 'paginator' => [ + 'pageSize' => 10, + 'currentPage' => 2, + 'totalPages' => 2, + ], + 'posts' => [ + [ + 'id' => 11, + 'title' => 'Eveniet est nam sapiente odit architecto et.', + ], + ], + ], + ] + ); + } + + public function view(AcceptanceTester $I): void + { + $I->sendGET('/blog/11'); + $I->seeResponseCodeIs(HttpCode::OK); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'success', + 'error_message' => '', + 'error_code' => null, + 'data' => [ + 'post' => [ + 'id' => 11, + 'title' => 'Eveniet est nam sapiente odit architecto et.', + ], + ], + ] + ); + } +} diff --git a/blog-api/tests/Acceptance/SiteCest.php b/blog-api/tests/Acceptance/SiteCest.php new file mode 100644 index 000000000..0c704116d --- /dev/null +++ b/blog-api/tests/Acceptance/SiteCest.php @@ -0,0 +1,59 @@ +sendGET('/'); + $I->seeResponseCodeIs(HttpCode::OK); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'success', + 'error_message' => '', + 'error_code' => null, + 'data' => [ + 'version' => '3.0', + 'author' => 'yiisoft', + ], + ] + ); + } + + public function testNotFoundPage(AcceptanceTester $I): void + { + $I->sendGET('/not_found_page'); + $I->seeResponseCodeIs(HttpCode::NOT_FOUND); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'failed', + 'error_message' => 'Page not found', + 'error_code' => 404, + 'data' => null, + ] + ); + } + + public function testNotFoundPageRu(AcceptanceTester $I): void + { + $I->sendGET('/ru/not_found_page'); + $I->seeResponseCodeIs(HttpCode::NOT_FOUND); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'failed', + 'error_message' => 'Страница не найдена', + 'error_code' => 404, + 'data' => null, + ] + ); + } +} diff --git a/blog-api/tests/Acceptance/UserCest.php b/blog-api/tests/Acceptance/UserCest.php new file mode 100644 index 000000000..182731d2e --- /dev/null +++ b/blog-api/tests/Acceptance/UserCest.php @@ -0,0 +1,99 @@ +haveHttpHeader( + 'X-Api-Key', + 'lev1ZsWCzqrMlXRI2sT8h4ApYpSgBMl1xf6D4bCRtiKtDqw6JN36yLznargilQ_rEJz9zTfcUxm53PLODCToF9gGin38Rd4NkhQPOVeH5VvZvBaQlUg64E6icNCubiAv' + ); + $I->sendGET('/users/'); + $I->seeResponseCodeIs(HttpCode::OK); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'success', + 'error_message' => '', + 'error_code' => null, + 'data' => [ + 'users' => [ + [ + 'login' => 'Athena7928', + 'created_at' => '26.07.2020 20:18:11', + ], + ], + ], + ] + ); + } + + public function view(AcceptanceTester $I): void + { + $I->haveHttpHeader( + 'X-Api-Key', + 'lev1ZsWCzqrMlXRI2sT8h4ApYpSgBMl1xf6D4bCRtiKtDqw6JN36yLznargilQ_rEJz9zTfcUxm53PLODCToF9gGin38Rd4NkhQPOVeH5VvZvBaQlUg64E6icNCubiAv' + ); + $I->sendGET('/users/1'); + $I->seeResponseCodeIs(HttpCode::OK); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'success', + 'error_message' => '', + 'error_code' => null, + 'data' => [ + 'user' => [ + 'login' => 'Opal1144', + 'created_at' => '26.07.2020 20:18:11', + ], + ], + ] + ); + } + + public function viewBadId(AcceptanceTester $I): void + { + $I->haveHttpHeader( + 'X-Api-Key', + 'lev1ZsWCzqrMlXRI2sT8h4ApYpSgBMl1xf6D4bCRtiKtDqw6JN36yLznargilQ_rEJz9zTfcUxm53PLODCToF9gGin38Rd4NkhQPOVeH5VvZvBaQlUg64E6icNCubiAv' + ); + $I->sendGET('/users/1000'); + $I->seeResponseCodeIs(HttpCode::NOT_FOUND); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'failed', + 'error_message' => 'Entity not found', + 'error_code' => HttpCode::NOT_FOUND, + 'data' => null, + ] + ); + } + + public function notAllowedMethod(AcceptanceTester $I): void + { + $I->haveHttpHeader( + 'X-Api-Key', + 'lev1ZsWCzqrMlXRI2sT8h4ApYpSgBMl1xf6D4bCRtiKtDqw6JN36yLznargilQ_rEJz9zTfcUxm53PLODCToF9gGin38Rd4NkhQPOVeH5VvZvBaQlUg64E6icNCubiAv' + ); + $I->sendPut('/users/1'); + $I->seeResponseCodeIs(HttpCode::METHOD_NOT_ALLOWED); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson( + [ + 'status' => 'failed', + 'error_message' => 'Method is not implemented yet', + 'error_code' => HttpCode::METHOD_NOT_ALLOWED, + 'data' => null, + ] + ); + } +} diff --git a/blog-api/tests/Cli.suite.yml b/blog-api/tests/Cli.suite.yml new file mode 100644 index 000000000..999946299 --- /dev/null +++ b/blog-api/tests/Cli.suite.yml @@ -0,0 +1,6 @@ +actor: CliTester +modules: + enabled: + - Cli + - \App\Tests\Support\Helper\Cli + step_decorators: ~ diff --git a/blog-api/tests/Cli/ConsoleCest.php b/blog-api/tests/Cli/ConsoleCest.php new file mode 100644 index 000000000..f6d7a11ec --- /dev/null +++ b/blog-api/tests/Cli/ConsoleCest.php @@ -0,0 +1,17 @@ +runShellCommand($command); + $I->seeInShellOutput('Yii Console'); + } +} diff --git a/tests/Functional.suite.yml b/blog-api/tests/Functional.suite.yml similarity index 100% rename from tests/Functional.suite.yml rename to blog-api/tests/Functional.suite.yml diff --git a/blog-api/tests/Functional/UserControllerTest.php b/blog-api/tests/Functional/UserControllerTest.php new file mode 100644 index 000000000..b18509cbf --- /dev/null +++ b/blog-api/tests/Functional/UserControllerTest.php @@ -0,0 +1,60 @@ +tester = new FunctionalTester(); + } + + public function testGetIndex() + { + $method = 'GET'; + $url = '/'; + + $this->tester->bootstrapApplication('web', dirname(__DIR__, 2)); + $response = $this->tester->doRequest($method, $url); + + $this->assertEquals( + [ + 'status' => 'success', + 'error_message' => '', + 'error_code' => null, + 'data' => ['version' => '3.0', 'author' => 'yiisoft'], + ], + $response->getContentAsJson() + ); + } + + public function testGetIndexMockVersion() + { + $method = 'GET'; + $url = '/'; + + $this->tester->bootstrapApplication('web', dirname(__DIR__, 2)); + + $this->tester->mockService(VersionProvider::class, new VersionProvider('3.0.0')); + + $response = $this->tester->doRequest($method, $url); + + $this->assertEquals( + [ + 'status' => 'success', + 'error_message' => '', + 'error_code' => null, + 'data' => ['version' => '3.0.0', 'author' => 'yiisoft'], + ], + $response->getContentAsJson() + ); + } +} diff --git a/tests/Support/AcceptanceTester.php b/blog-api/tests/Support/AcceptanceTester.php similarity index 90% rename from tests/Support/AcceptanceTester.php rename to blog-api/tests/Support/AcceptanceTester.php index 012aa7e87..664821bd1 100644 --- a/tests/Support/AcceptanceTester.php +++ b/blog-api/tests/Support/AcceptanceTester.php @@ -7,7 +7,7 @@ use Codeception\Actor; /** - * Inherited Methods + * Inherited Methods. * * @method void wantToTest($text) * @method void wantTo($text) @@ -21,12 +21,12 @@ * @method void pause() * * @SuppressWarnings(PHPMD) -*/ + */ class AcceptanceTester extends Actor { use _generated\AcceptanceTesterActions; /** - * Define custom actions here + * Define custom actions here. */ } diff --git a/tests/Support/CliTester.php b/blog-api/tests/Support/CliTester.php similarity index 90% rename from tests/Support/CliTester.php rename to blog-api/tests/Support/CliTester.php index ad635d690..4b08ad4fd 100644 --- a/tests/Support/CliTester.php +++ b/blog-api/tests/Support/CliTester.php @@ -7,7 +7,7 @@ use Codeception\Actor; /** - * Inherited Methods + * Inherited Methods. * * @method void wantToTest($text) * @method void wantTo($text) @@ -21,12 +21,12 @@ * @method void pause() * * @SuppressWarnings(PHPMD) -*/ + */ class CliTester extends Actor { use _generated\CliTesterActions; /** - * Define custom actions here + * Define custom actions here. */ } diff --git a/blog-api/tests/Support/Data/database.db b/blog-api/tests/Support/Data/database.db new file mode 100644 index 0000000000000000000000000000000000000000..1ac562e360395590d4b6fe179defde15f0fb0892 GIT binary patch literal 114688 zcmeFaTdZSQnkE#Hky)9Mmx}J`sV;`;nsP*y6q!Rh()RH+3sl%XzTb{-=Nuo+=*+gg zZEs)pw!M8lQmd5>0x^sbNDR|xGz|~DfJQB(8EGUyVk9IaB%T1FegGa&OGt+ ++ + + + + ++ l7SozK#{KkWa4 u;M`#YcDhVOm!;|JgWAHRR|(I0*E zC%Y;CkmV>aH7D80w)TbDDV;mKKgIodHlmd;o-me{>tc1O?PfB zjp^;moXv0Nj=Q>zo<)sVB+@mzz5cgc{Y&XaCfUmPTFF#7<9l{|)W^@?@*TZ?EOz|b zt0Umm_rLBPZ@T%J@6!)``18!_Kl54k${d T@Ro6R_1Qy`!pDe zfA;G2m9?6h-VLEh B0exf7!C>sw8z1Z4vNVmA z*}pY#S06vEteN@h*w%Ai`IdWJb9wvq^S@{mD#=F2SITsJpOQ|WsrP4VjZ7g|)t|5R zLq790GTBTcQ%z@@zGwUtB$jXaYE{g#oWb&^lg)JUJi|}g7w2RM{mF-)ef9AV-v43y z2M@pcnPvCQoq8;Px}7>h%f4MX6VtxOa^bT+?|D}*db?Vvw==#%^*nQ}UVkgk`TB1^ z<0uXTm6tsG-1ki0^X#+jUw;3G1&roLZyrr#5bT@be#|f 3eS)!DpKv zzW>7tUi?FOvFtN75k-;y?couTXe`d_@99_TxqsVN)LVHM7f3(#*=PS+{I}o#;SYZB z@ZV_4X6JSO|H`J)Ups9b&Gla+I{tjO&i1#yhF8^|IWUYF86>#g?|d7tzTtZ>^Qzt? zzyH_&lJLyG9uuD7v-$rk!k!2?F+taaNeR5Hz0vT~ut?^}r0*Vq5U&pw21{piE+gD3m%|3BZKeDWus{LPR5=Z|Y2 z{dXUwKKw87@#)Wr0w)TbC~%^{FF}E?)*n57@cW-U{p!85GskH)TFXr>5sK{k^HQo2 zH~OQ!yY!curG|S`3Kw&?qw-)l+Fh6Gp}R<{+@Fn$rTDTJuiN2*v3FL(Xs(zIrkrFT znK#Rc&bl@AFYM`LHcdyfXVuKmnOXB(y|axu{eu5IF~7Y`W}D;SIMgpR%huhUWyT_* z#7{%}p9ar =}98y zueoROVK7>^;>G%TYFun}Gv;X7vz(h+eUQkNW8q*Ry{YVjv&^W_tyQwqyZ%+Cv6~nD z*Og7{I+bv;XM>C7IC<7i#WoS6Iv8Fg+?;u3-3_{d$l6H8O~b6u8b)tqnA1=q9?}!P z8`?AbZjx=qc9UVJJ`NXetgN}bj#l^8%5>XJm9I*6cX# |GPW<*F*GLw6akEqo73!mzTPe(zqi|vyKI??K`?@<0*zNp!dR7nTvu1BK znf2rLXcVqBTD{Gtv#Snvfy?eLJYMCZ{@Q#LpY9s=HZX{l{EJ#^F)UXa{Y|8`m|pI0 zYO!#?dKa$l?N&cA@3gG?xm7lusS%7N&h)eg#_DoBez9-f_14Q^Hd=B+Uu &d%F%!iUEHup2?>o?LpC4l&hnqJ4q)8q3Gl$ zeZ5Y_!m)GXVxBtBH~NuT`6kkhwHA@q&L~AAiQIIs_789F(t!&+E7VWJ(z47*FcHx6 zHbyVPk;!0X=F{74xqg%ECz4m@dL5b9Hlsl?+m7$ztx__!Tzt`avD(LbndG$g`XtX@ z|0wpA`#$IWBQAHOS#PET(^$ZNW}3@lsXDFJ?qaD{a(Z^L-cKw0NNL)?42>I&aC#By z&svdwTaG_nB z23y<3puP`|?aH z$y6d~(O(7ZRl|sfk6v|B|e*g8nnRd&& zMJ7}{zs>kBca^*S+PI3(%cTm?^@1%_|>~ul*dzZ6TES+(szqxWqRExgxpqq6i&`N^X4#BnFe=*=y|xCZmg`* z>U^<{7OQ8`efiEdmP2b=zg=dEcZs{!px55bB4-!n+IhO=WD~=jWuL_x{cv?Ms$bMD z&3xo?bF)bq^=W$;4QHZOubN(Wt>i`#W7y8*{dcQWCF~D$9rHREs5WowdAEL*zX;cY zxAkQ#Z946CEIu^)*?A`u&n-sl{?KSorZa1lY`EcQ&hA{4(pSlg#W1tzUys|nMC-D* zy$BQoC99svrbA9~xpH%j@||_jycwS5m-}SMOb06s>peA$?^*;uR>Qam-1KgO!B}w= zx(e)!q`3%oDo!JUyz8tK2ybq#TJB &xMZEv%+hmo4Kk2_{%)7YkB zX1iH%?&ryGk*V)8@2X*Z z*OK`IHH_q4u2C9i!nMuKsM_xA=5}t;wimmUIli{zv5I@K4K)gOCO4^EH!I=!?$$pa zq?`8YqCQX8o4aHqGxD#Cy?n>nRC4a(A}|?OqDITC=kGGf)UJ1%tzE7nfk}EgSXq zavCv$v!`g5Ydi2^4IoG5Ujz=;AU3Y;i#qQHp)CkmV>aH7D80&hWq zCy##e@XDHY*GoGXh)3W1{-dW4n Sa0IQ;RWA3gM oF?+0e?-dMy74V z5~27b@&6zH*AJf9-~V5}U;5;~_~g@%|KQ`tA8kJTlMfsC`1I#QffEHz6gW}fM1d0p zP82v%;8%+RU;XBLKS3`3o`w0{=U#u~y{8z`(a8Po^Pg%&M?3qwPyVT8yJzZu_w@sx zy!Rs%5P06kcV0;FeGcc~@O {@%^Qk5J~?{@@?};Oy+-Zj!gA=s+qMn~Pw66}@W4+WAs0wYj~m zo_D6p*?hAL cNU+eo$1Xu8g8eu(ROxOX>B^`;Z?g( z-vtV>@a3@Q?4xHF)%0R}c^4UVI#%Gkv#`&0$@O)iG_<>|`r0ylwqyHjwyye&^~$$c zTRzt?=cW(8;L?kV)0_BO>m?dt(F*57wA0tO+?8SX%+Gyp4~?ob%O3i+)@tNqdzOXo z_?ozDWc2wo1&A_qR_J+^Z%?fipVfDq<(IzhdiA-_m>TX=pNS`+Lzgd@;+?K s81~+`L{IGJ@{n`DusG;UV*{TfpBUEC z=L`mT-_&QVT;ISn<$I3IWjnGkEMDUf@Ej1t?)&}D$k#WG< ;23)jeq5^Q^Y`3xKOf;OOLIQu zNH^wk?;9==y`Ju0_?};lKqGSsij44)Z||&q=FaS`(M0^>xvzx>%q%qWu@T&tH?Uqy zUw`S$=Xlb{0=-S6x1NKVm@k$VgK}(4*2Q<5CIaNX!E}u&_noaR_jl1LIKtxE_&V;# z8VpEWk_@-GQ`4Mt49_tXTnBld*Km75|L$|wppk)jl8#*ypgJJBJj?`VfHs&Yo;k8+ zt~te;x;{+6ouh{sQ%(AMnCr~g<7Li-ykV{Ih8E}H&P|i#H^3WxykTk1jO9dT?V7f0 ztS~8`G2 5<}w=gl<-SpW^}=PTdJm|(g1${tt? zui9cf!ZtJW^XItYtWg~4@zZl_^&I4}`35|IbL% zdto`yhFe*Q3C$VU6xh;--2qCuhIQC>=!CcW)BVqU&s|anTu3V16H*ve`mnRGt1#;0 z_l09XEpQB$%Rnc?(ql9myBgFSVBLh}K*Ax!gJu65oY?{)xjndEU62)wzTAIKd8qa> z_tM&3gTMDa*EAvju=ZdygZ!YSCi+&v1l+=$GAIBNDW>8K017~%y26qf8$876c1c1# zKMG6ImZY;y^NyEfGjSXA$niM~#KkZmZ8lclq(oB;QsTWBg5jG(7DFI{1~$Ht#J=Q< zXAU4A)O)y1T)+QW3PYr<;H+k*VdF_h5^{IhdqpGOFtsLpmE6GRDar9AsEaq@ce&HW z;8$A{LPDf0Wd><#%PU-L1X6~(!+R~cxy$><>)Bjp+jYRBd>Ni-a8* O=Re zEQke>bTu{o! >N|?2e&0s9CMvx{4jf9Dc(7^|?Z?JqL>~9Rc54NLRR&RAg8p@o; z#*&YK0>KYhqKP$~a*$-nuG0r+xDKR`Z45Cm?!_$@i!y`M2J?B#ss%P~2WRPZ5)nn= z2nOX$eb2CY2nT@yZAr3%;!AJ}-UjBB4=wx(k_}~PpI@2N=~Fc;Ff_gco+Jkh9j8xa zHibMOFPqdPcm|7LR2DH2U`p*GhAA> zkjtZpYa8=9CL +U=g_Vu zR#di#NC$U4onXjdAP6NF`Vt($Z8>z5km^!xaAtGM>|v3OA&eK7#POu?jI7iLmHd9l z1G3Zzss|i~k%^8*(>}EgN2FDx68vj`fGjEf`m#{!%N!X@8(K$TodKo^J`_oSIrpu> z!0N51*z(Z3Lv9apnV5rkd<{mPC|WWc1S+iyE=C<34Xc6N#qn{+Q1e0Ga~6~L9zA^c z!F&As{SW2u$Nc+y@AB{W9`WyYKa#({$G;zaAb-E}?xTl~@z>*r@6(%rjmoVLQh|a{ z#&hlrY)4u)G=!E=Uf3Kq7>BX}qZqC&NG1Q=8oF=Nu-pP5&p|Yj%^?4t`LO)H2`v#m zgkqY+;SM!u4#5T^my;-Z0f*K`7@c (;4+V_;}3>pPg6ETBV z!B~KmFLDk0ed@U67p&aYkjLW=SiE31b8f)q6=EzWGDd>fU`LCY%`wwR=79M#cx?$8 z{b`a)1B(g602Vlx&%lK2zKjPXqIE@$GQkF*2IDfi6jXQj_zYGmjYDg`a`fO}^3jZ? z6(g@3Se`X0+Z&o610@sPn>WnWo~RAqgr+J6Gvi(0 |} z3Kd3k7*h}x34c&Fd#BcJ!0B?qxGwfzYwLJcmUseS`40Cbc0i%2d;xv0cUu 3Kj^p!%Q|S9*Ns*2QzUoe=)%d0}5Q)In*&?=D=@~OI6SA;Rkr8 z@_jkS3Y}{1V`y-4igM3E344QIy9Oq$jA97qz7HG?_T=}v2m!ze!G?!dw}jF%U_Wi? z*}?-Ag)?6d24p|aPol-Ad=}a-d@ME#cl !Q4>Rg?5%d7gw0u$*_0Y+AzeZ#@I19I^S?Rr|@}TO+aZXNlCOF z_e;ND2m^;^0*=DYLqvhIfDjWy5_7l{8Hy)ZV(An_!hyS1Ba)gP9GO6i%&(C%dKmZ? zwVVrYnlHgKu~!vEW%`s63^y@`1En>@4u0M=mmsrvdlHi(&*ObZBd7)D`CQnYO7s4h zF07#`GESLX9yxo?bP6m1G9*m|tN~>J?S-xZkNOZ*8bYUoFX^Ja@LkYR6S~9xR-z29 z0Ca(}`6+HxyhbRZ`2@pnkuR|j;=^H$;8XAKk5PLtijdTU&plI7y$y^j!W8syN8m7J zyV14=V_ DW3TqGXfYMjz`np<#fXPXXR9wWs-UlWw#AUF zc6`9M-=P$xa??qftudsG(W6kafTpAqjRC`Cajh9AId=9~A;g5tSr>-8CJ jDJbXKi%eniNjDRwmFH0=gq+aaD1J;-_JqIr}Xy^_kc=J8K3K|?K%)WYB^VM zXE<6h%fV3RmTiovS`pK8xueCTm@%j(PPTHslyHJqyo05awP1u}{V^1TeYk-j_F$H| zu=(_Kk8CA2Ap<0od^E9rpr7Z0|BPD&x(}gBjtS4vK~qkOnp#GB5JNI2-bGgi;uF)x zMv&Nvt$a)xS}+264=%z?;Dab8luARu!%)*IV+gBtAM{%K$_U}9U+($$;Mz}>T%>S@ zV2AJEQhPhxDb+Ps-B*JXq=lB;3wmCS_QLQj-+2x2hb?$=sambo^d+n3zGfbu!6M~) zX^G=;hFTC2pdF}Mh9u!py(SU0!-py9tt&pKNMR^bM3NBUqph#G0^1aH!Zsh#{#Gyc zTspW+M=d8`pqdtQ1e`Wdg6p`sK{yy+oLh^&fEd7H=xDAKje3YNV_lTe7j_bV3z{Dh zAZj12_CbV$wMHWmsscI;b`n{Ed=4{Ldl?7gL@kM@T7=#ZAz%T112G1JOFF #rw|+
b*j4>9~<-=FgTr~LmZ z|1UX$Q~n?E5~Kybu14UL|3@_-Vkx3iPx=3E&Hp3P%2*FVRLFY33PDJ|hp4m-r{uSJ zaNiF Bv z|NNZM0Y2b2@jsu&=bZkWDDW4T0{`LY(Z}d`|LWlf|M?@dyf>S{aw@#aN7k$4un_MX zXX8z37b})C&@r4` z>&i)Xu1dZ9RkUjr a>?Q>y-8C=rpn{_r;-~eCV*)t+!+YCu`88 z+RhlDF#!E#c@|HiAX{n`Y001<4Ua%kA$q@``%eD<$^Sq3|0n++qTuBJx0qpJ@yn_I zAGIE*{{K_|KdcGv2?>s#`v1Qvw}0~gPyPRY*8Trq=l{QDbH_V$_TPE;cOQKChxqIC z=R|=M1x^(B^P<4t`D-5#SupeX@A?sD*=O2OGn{M0# HxY`z%`=EZyr`?J5!6Y|Ha?d7ZTv{)_JXx-+@4 z%A;6oTr_)`%SAMNHaTD9ro(!rmm5{*nVh+-Z{6S`R!gEy8dg0zpNAGwI<#)h#d>X{ zCG>?(cxrr)zO(MG*dPtmB)9_XJ_TITOpuxoH9`1fYXByL{dzOiHOQ8D-m3613}Wi( zF9NFVX95Pu20p CKagXq!d2uqQe0szRssMOTapsp|*aqV0KM4(3#cs@X(0Pw_9 z-?>yxqT`VR?xQW0GkO`8P4y&nF`E(EfOX3zhKHg!L4c9gUj)!xqCDX1{9UFcozb!; zX!=x8aK>E0wxJtdt`dDlj}~|f9vTY ;}q2Guj1{v764KPPw2}naL-~#|2Ll7Fi zQbKS?R7w=@5w}RA@3D-}UcJf{9zvq#!F#RC1TF(G2ys7VbMP%6CCC{HfoVD=@={aC z40i>6%JAe5=mvtdrl0)+p+MEXs)4W2(hnS;pZoo2B_@mwQk+EH=%X!lqCH@uhM_|n zoux^E#3axG$cT~fSR>*xf@H)=!#^O2Lb0o9d`jQphk`-@VDwQ 948iw&dn ;Oh@A%eW&+{g3*Sjns__X_YCc2pUyzz7(# zBbYo)5CF8u6Xpbefv3!;K45(iCj$)&6h*XDfyoIh6@p9R1Z*rNfquvUfd8 ~Ul~ zsNb9o7v;@5Q1`pV#dRx(KzhWsIx#@a)=amNiJk3=mBws%(@bZsir4jK*4VEr+f*uj zwn@})!oBulag%8kuZF3*)l38}H#rPeDh0FA8r}t><;<-)NhFJBP&@hIty4c=UM0<& z>`ijjJik~PCg3(#7DDW%!`%&}$d!dyyh5Kv{=e@`S%&M(0G5SzAba(K;*x8Z>>F4q zQ5Av`CfFAQ4*)_8=*?I=C`njPh=^x%$3lnyGeQF3%g=u%49zbKAdjpWHk$n6;gJy+ zL9ZwVw8q9oz#m{QgrR){0*l~EiTK|VJx-91oF1?fmI_6ILn7F$f}g|7z%_<#JCFk@ zQ-DZWbj;Ni92}q$xq!`l?n~!ujdl~TScUt|^#aKPvEC?DOTJ15l;DsAI24ly8I=}Y z+rWpFHhFG!g#VbFFz(R5y^haRU^KmIeUGA+XuV5jaRs zGysQ*BAWY1b|XQ$G^~!AoQ7K=st>ohz$yq<6oH9A6-v`*1m@{u7P48jC?v`m JaE@5G+BWoqOG$BGa;{!r$P>k@WQ`%9UkmS3lR?r2b54%1cw8I zga7dw2ugr+LB$Z}i0C%5zW5YCh}dk9G q`f?2nvHl18R|8vY>r|r-X7QgcTtJ zp*3C1f@Hu3Atj--F?OXXrkFf75C}4ZWE4eFfc)gcsmJ(C?hqgha`R&662-#?l7=zE z9tI(jRQ|3BEM|g7>7Vi4!_S8wYU=9K=bs4*ma+k5(J@^ZNHio~MJqX~v(FVuiiS+a zi#@Qp*P$HVgziB9|KYp; UWyVmJlI^9tr5U{Kp`#j!B z1#iRIe#dp2&hRETIV T5OK{4x90hJA%oM&sfb)Ik9*X?{L55M#`UjMbr)~a&?DuFG z{@w@wX&S6pwmaKdDA)^MgciHo*0{H-l+K-Y@$#nMGSZo3I(pMi7VA~BxH4{AxAoZa zV%2M%&HB0P)UKAfs7@2%8>ijKJN0gOHCtTg&+KG;=iIu9tD&9Ahlh9Pp-A#J6F4us zPAjv!UbV)tf`tN*RjVI~H-JbF?0Z&a02UYQD7-i`EbyiaC^KTRLA%oZ^O-arg)@PO zOXE-=;{;+H27xl>21-j|VWA}75~8296MIJhwn%y?8$nBBt%ra)rA&hM66BMpo#M7E z88hHl!CJ(cyFzG!%p>cGg&K&;1GphL O;}nZq*vbkQQCz_uJXvC|uIJbT z{N@B fexNz-*#0i2ssd2=OcH^-18MLL+RFq+ZeeR;)r9Q#Y&4Enj4)bn z5N!f5jO7pjE(#&ox9d5g1STv?L?=iM049@Q@D?VDnF-FV18ug|RUWPKGGRuN;Q+D5 zZ}4RU3lNeJHH2}~Mi3+hpDHHm!7JbbXz+3jtssD}2H^kJ*u?%N1d%6agmOQ_@@TJv zBh)s&GM+V(93BXhU11)}<{_4`27gqdJ%d9sz+gnxR9ov6|D02xnWr2sL^oi`(q@-6 zHwTa+Bd#S)U DJ#wbe~_pH0m)es#%H7*8@g}_v`0Y6WgQS-1ty?9 zaQT-<62#0g5}-{AC?|M=Lx6)cwFcNP;@)A%EyAIBVnR;Rz;q>qsGLiV(5qDY{FNdT zcwh7s1x8gA oAw&9ezJTEWirq8jCKMZN^PRJKzeSlHi<|N@Tum^duJ%D+Q)rJW`xx z;OPPn&=HTs8=(kXur+rsf0qd50ryot5yy)sRRF46$T7oeCv|vQd1(wvfK2z9Sft}2 zR_SxVqCh7% WIY5g?SZX*FPa2)7Pk+q1-XHBXH*lC?)WUc zN{MR?L o@~h7ldyD@Ma$+`S|MFe<(gXtWaT5@~!7bbF>Y z6Zub{tb-*|B1v0GlsFK7!Z(mFSV~9^3)u70xc&79z>u^G(ihJr)VZ1z-^5@qu1a)I zTcOmT_f9-Qa1f=TghA*19?~NDN1fO+ 0<6DK0c$q2mhb*c|9O&>D}IQ(Pdnq4$CK2u?-7$>8Zrz`Hhg(7H1f zY{aTO&yRbP<&tq_z11-1$^NOYJw!nLh@AVJ@{nd!wbsI0aTbsVse7=Wl_r^)=46V9 zr8@rwLIkV96^Cllh@8r9z{*j_k(mUS^r?@LQ|c`fAp bi3(gUrUt&J zt#wjQ1R)#oiA}C#Id%sqCW4X+qVpvrA(vSYe8mQ*;Xp~Ml0_m94;aMX0NwgBAnEEY zBdqU=|NqXv^Z@^z{+uZAO%yo!|6V}- ng+N5g z3i`u~8N8I#qXPiZ%&4$r@gauq=g*X06|)6)Bm($m|1(JY0dy86_Hc^y39S`I;bB?B z=7vE-gWQF{RvwqUR?KX%6%NP0K+i(9<96yv@z_U5{fS*RG3WGZ0N8$ar#xk4SJVEb z!$`fMd@;|MqFsbX5w#k#ffKEd*8?=ZBr1U2E(7K_RSw2Gd;p}&@_;p7@!Yg4 O_itCACQl+B<|qJs6pZ+VukBrQlP`Ynbe+U2?endf;EVxQB?r(Q#Z$Hw_F^h9}N z)ZROqNe*KotT9gNfcbSL R-8U;Hm_#2=D{5yq2$B@4*wT%OyuwZ^>hS9I8qTu+`9~$sMs~)yC%5k z5b83P@T}!NI)zA-4G~0V8kyfeq$%NbujgH4+fYb@;c@qi8{_vwME3T+bi2`NW;%sx zj+Y^$=}z!0O+MupU@wo%N(zt?!a I3hM6k)Q#GMz{BV*&77SM}Nqy(g}7Y{EBf!9NZk6*)X1t&?Y zXl@YH9fwCkM<51a%h`|&VhC^WNw1s>_Y3!YPn|Vb7ycm1FqkG4ZG~6j9b5_T3_uCK zVki>R `ll;43%dcd0`Tf7~EsL2UoC9Q~KybtenakbJ#OR>b!8Dx>wF2-~o)> zQdnYs3U3m7$UBr&3lIp|=PiaGUOGrXpZKGARP-;8kCl+5Rx #;6>*PisUae55u8x{bWacp*;=)X5us#*(CaU%{~g7?X@!@gZDc@|(+yGycKE zRbI!d8AM8*t4i5!^m^b^8)goq>v}HXQe2{-0mC6~AvHyCkW~}~#*etwP(O!U(o2i( zsR=0t0d1&NME8T9T3CC0A)=0FVv_s@39E`yl;Wj(kMA!TVj+#?kb=Vz7NWRyNM!CT zS=@sApOtt<8LxSV#eRvIKw{I9+mf$g2`EvtcAu(EBB37e8S-X_b2|EvsmJILm$s?N zrGx{mqI(<(%40PcbH|ENrGwp3E2vsyC9Z2BSlAMFR?$G@0=p(GF8K0_Q$+?bo-LC_ z3J1GHo$rB|7S3iAQw{5=ZqeEuR-PZZA+Qx5!%_yW8zu>vs IyOh e}tyZA`&V7a&S|347_|HH(C4-=>Q|8H`BPuETq zI8orwm;$Hze{q^$t@IX4LhKY}k)G=RPxb#a%u$UEJML8ff2#jSJqg^k@3j8^-#&kr z6 h 8M<7y4k?l>b7EDRHB_}w;4oc^5&-1xk`=i2D45$ zyJ!W@N3mKYzr0906UVu{ysVpq$dTGVDa>az8Jq+ZSY0KXjEd^=X2t{&-N4CvNX6k6 zR;n>4NXMBD0Z&__@O2omI2 mo$r23iu`N4<44S!lUL*|97vlM}2 zmI&60Pt00qFP_Q+ZnTTzou;VbIUorfBKa!MsfB;ZLIa=*iE9TB7M)@|;aNL2Ud0XP z#hfwR(sVv~(LxnWp-_0?OM2xt4Xl**a8%(R^Pq?-`Ehckind5x0p9@q`7~U|8R|Ib z)kWNj4R9nAi`d{j4e9b@q$t`L( Gb>G *Xygc2>a)$BN*8xQx0=jcm`6^K(| E=>}=rEo*zdF_o^7TgR-UO925)TLG4c08FKV(mi{OP3$zyk2pdAcmVO^T@qwH$Zi z#iX7WUOKNCuf!I6Ip6}qOL$L$_4-grki}*tjEY9^HE_a5Yo3iNfpj(%+|P*|yS0vK z7oIIH;TqQek o@Vm|E z>d#*)ph5&4<%0-FBNc!sCX@k^2aeYcOo30!7Q!CV5{K4H&6L-p{w2nP__D4o)0$Fr zGqi@3Y*O +5P$mR=tTPOWs2O;hYjf~tg@&-J49CTA<04hTuT9wDu(;iTIQxXGD z#jtU30Y=N52eC^TIwI1HPpi&KU rIAkoC zx%!$4X_X3PX6>c^eQm)Y=PgN1mMvoOU>zIWd_V=oyO7C6-@QywQu2xlWPim&$or#1 zQ`;dlx#Ur3P9LcBi)Uz}Nop_